diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c68533fb..5d6dd2b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: - name: hydrate LFS assets exercised by tests run: > git lfs pull - --include="client-3d/public/assets/pawn-pack/equipment/Under/boots_canvas_ankle.glb,client-3d/public/assets/pawn-pack/equipment/Under/under_bodysuit.glb,client-3d/public/assets/pawn-pack/equipment/Under/gloves_knuckled_half.glb,client-3d/public/assets/pawn-pack/weapons/custom/scrapline_machete.glb,client-3d/public/assets/pawn-pack/weapons/custom/field_saber.glb,client-3d/public/assets/pawn-pack/weapons/custom/quarry_chopper.glb,client-3d/public/assets/pawn-pack/weapons/custom/wpn_smg_sten_mk2.glb,client-3d/public/assets/pawn-pack/weapons/custom/wpn_carbine_kiln.glb,client-3d/public/assets/pawn-pack/weapons/custom/lightning_carbine.glb,client-3d/public/assets/pawn-pack/vibrosword.glb,client-3d/public/assets/pawn-pack/pawn_male.glb,client-3d/public/assets/pawn-pack/special/droid_grok_humanoid.glb,client-3d/public/assets/world-items/travel_terminal_grok_wedge.glb,client-3d/public/assets/world-items/travel_terminal_grok_wedge_screen.png" + --include="client-3d/public/assets/pawn-pack/equipment/Under/boots_canvas_ankle.glb,client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_canvas_ankle.glb,client-3d/public/assets/pawn-pack/equipment/Under/under_bodysuit.glb,client-3d/public/assets/pawn-pack/equipment/Under/gloves_knuckled_half.glb,client-3d/public/assets/pawn-pack/weapons/custom/scrapline_machete.glb,client-3d/public/assets/pawn-pack/weapons/custom/field_saber.glb,client-3d/public/assets/pawn-pack/weapons/custom/quarry_chopper.glb,client-3d/public/assets/pawn-pack/weapons/custom/wpn_smg_sten_mk2.glb,client-3d/public/assets/pawn-pack/weapons/custom/wpn_carbine_kiln.glb,client-3d/public/assets/pawn-pack/weapons/custom/lightning_carbine.glb,client-3d/public/assets/pawn-pack/vibrosword.glb,client-3d/public/assets/pawn-pack/pawn_male.glb,client-3d/public/assets/pawn-pack/pawn_female.glb,client-3d/public/assets/pawn-pack/special/droid_grok_humanoid.glb,client-3d/public/assets/world-items/travel_terminal_grok_wedge.glb,client-3d/public/assets/world-items/travel_terminal_grok_wedge_screen.png" --exclude="" - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 @@ -84,6 +84,11 @@ jobs: needs: denylist steps: - uses: actions/checkout@v4 + - name: hydrate audited LFS sources + run: > + git lfs pull + --include="content-pipeline/labs/humanoid-runtime-refit/source/male/rb_v4_anim.glb,content-pipeline/labs/humanoid-runtime-refit/source/male/pawn_male_refined.glb,content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v2_anim.glb,content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v3_anim.glb,content-pipeline/labs/humanoid-runtime-refit/source/female/pawn_female_refined.glb" + --exclude="" - uses: actions/setup-python@v5 with: python-version: "3.12" diff --git a/.gitignore b/.gitignore index 971fedfe..82af806d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ /target/ **/target/ **/*.rs.bk +# Rust client build products (target/ already covered above) +client-rust/out/ Cargo.lock.bak # Node @@ -83,6 +85,7 @@ logs/ # Local runtime persistence server/.local-state/ +server/.local-state-archive-*/ server/data/ .game-lab/ diff --git a/AGENTS.md b/AGENTS.md index d04b3004..75117e28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ # Successor Agent Notes -Treat `~/dev/games/successor` on Bunker as the sole canonical checkout. Its -branch is `main`. Inspect current code, processes, fixture identity, and Git -status before broad changes. Preserve unrelated local work. +Work from the repository checkout provided by the current environment. Inspect +the current source tree, processes, fixture identity, and Git status before +broad changes. Preserve unrelated local work. Read these files in order before making architecture or runtime claims: @@ -10,13 +10,16 @@ Read these files in order before making architecture or runtime claims: 2. `docs/CURRENT_PROJECT_STATE.md` 3. `docs/CURRENT_DEPLOYMENT.md` 4. `docs/VERIFICATION.md` +5. `docs/TOOLS_AVAILABLE.md` The canonical context owns architecture and scope. The state document is a dated implementation snapshot. The deployment document owns volatile public release identity. The verification document owns commands and runtime proof. -Narrow design docs may add detail but cannot override those four files. -Files under `docs/future/` are retained proposals, not current behavior. Verify -their assumptions against `main` before implementing them. +The tools guide documents repository-owned developer tooling agents should use +for inspection and automation. Narrow design docs may add detail but cannot +override those four authoritative project documents. Files under +`docs/future/` are retained proposals, not current behavior. Verify their +assumptions against the current source tree before implementing them. ## Credential authority @@ -50,19 +53,19 @@ release may use the backed-up reset path instead of compatibility work. Record the backup identity, reset scope, new generation, exact release identities, and post-reset player journey in `docs/CURRENT_DEPLOYMENT.md`. -## Host, source, and service ownership +## Source, service, and tooling ownership -| Use | Host and path | Agent rule | +| Use | Location | Agent rule | | --- | --- | --- | -| Canonical integrated source | Bunker: `~/dev/games/successor`, branch `main` | Inspect HEAD, dirt, and `docs/CURRENT_DEPLOYMENT.md` before using it. Canonical checkout HEAD is development truth, not automatically production truth. | -| Temporary isolated work | Bunker disposable worktree | Start from `main` or the exact requested source commit. Merge or archive intentional work, then remove the worktree and its task branch before handoff. | -| PawnForge authoring | Bunker: `~/dev/games/pawn-forge/pawnforgev2` | Use for humanoid, rig, socket, and GLB source work. | -| Interactive cockpit | Michael's Mac | Use for interactive review and macOS-specific package proof. Do not assume a Mac checkout matches Bunker. | -| Public alpha | AWS | Site/client/downloads are S3/CloudFront; the single-writer authority is private EC2 behind the ALB. Bunker is not the public game host. | +| Integrated source | Current repository checkout | Inspect source identity, local changes, and `docs/CURRENT_DEPLOYMENT.md` before using it. The checked-out source is development truth, not automatically production truth. | +| Temporary isolated work | Disposable local checkout or worktree | Start from the requested source revision. Preserve or integrate intentional work, then remove temporary state before handoff. | +| PawnForge authoring | Neighboring `pawn-forge/pawnforgev2` checkout | Use for humanoid, rig, socket, and GLB source work; resolve its location from the environment rather than a fixed host path. | +| Interactive review | Any supported workstation with the required display and platform tools | Bind proof to the exact source and built artifact rather than the workstation name. | +| Public alpha | AWS | Site/client/downloads are S3/CloudFront; the single-writer authority is private EC2 behind the ALB. Local development hosts are not public game hosts. | The public surfaces are `https://www.successorgame.com` and -`https://world.successorgame.com`. The AWS instance has no public SSH ingress; -use the documented SSM/operator route. Local listeners and disposable +`https://world.successorgame.com`. The AWS instance has no public remote-shell +ingress; use the documented provider operator route. Local listeners and disposable verification shards remain loopback-only. A Vite page, screenshot, listener, temporary pod, or Rust process does not become the public alpha merely because it is reachable from a test client. @@ -73,21 +76,16 @@ Never infer production identity from the current branch name or newest commit. ## Repository discipline -`main` is the only long-lived branch in both the canonical checkout and the -local Bunker hub. A temporary task worktree is allowed when isolation is -useful, but it is not another source of truth. Before handoff: +Do not infer source authority from a branch name, checkout path, workstation, +or local worktree layout. Preserve unrelated local changes and bind every +verification result to the exact source revision and working tree that produced +it. Temporary isolation is allowed when useful, but it is not another source of +truth. Before handoff, integrate or preserve intentional work and remove any +temporary checkout state created for the task. -1. merge verified work into `main`, or preserve unfinished work in a named - recovery bundle or patch; -2. remove the temporary worktree; -3. delete its task branch; and -4. confirm `git worktree list` normally shows only - `~/dev/games/successor`. - -Do not accumulate verification-farm, cache-root, or detached worktrees. Do not -launch a new task from another task worktree. Production release identities -remain independent of `main` and must still come from -`docs/CURRENT_DEPLOYMENT.md`. +Do not accumulate verification-farm, cache-root, or detached worktrees. +Production release identities remain independent of local source-control layout +and must come from `docs/CURRENT_DEPLOYMENT.md`. ## Supported product surfaces @@ -105,6 +103,55 @@ remain independent of `main` and must still come from Do not add another gameplay client, world fixture, combat authority, or asset manifest without an explicit product decision and a canonical-context update. +## Rust client (client-rust/) + +`client-rust/` is the in-development native Rust client intended to eventually +replace `client-3d/`, `desktop/`, and `client-tui/`. Until parity is proven and +a product promotion happens, the existing clients remain the supported player +surfaces; `client-rust/` must not be published, linked from the site, or added +to the download ledger. + +It is a standalone Cargo workspace, deliberately outside the root Rust +workspace and the pnpm workspace. Root repo gates do not cover it; its own +gates are mandatory for any change under `client-rust/`: + +- `make -C client-rust verify` — unit tests, performance regression against the + matching checked-in environment baseline, size regression, and absolute ceilings. +- `make -C client-rust check-allocs` — steady-state frame loop must report + `frame-allocs 0`; any per-frame heap allocation is a gate failure. +- `make -C client-rust runtime-check` — frame-time p50/p99, peak RSS, and + allocation stats for the standard demo scene vs baseline and ceilings. +- `make -C client-rust nostd` — the engine crates must keep building for + `thumbv7em-none-eabihf` (no_std purity proof). + +Hard budgets (`client-rust/budgets.json` is authoritative): + +- stripped wasm <= 4.0 MiB; stripped native binary <= 6.0 MiB; +- zero steady-state heap allocations per frame; +- peak RSS in the standard scene <= 512 MiB; +- runtime and terrain GPU p99 <= 8.33 ms, and generic render GPU p99 <= + 16.67 ms on a supported baseline environment; +- regressions: size +max(512 KiB, 25%), perf +100%, RSS +5% versus the matching + checked-in baseline. The larger size/perf headroom is intentional: + presentation fidelity takes priority inside the absolute caps. + +Environment baselines live in `client-rust/bench/baselines/` and change ONLY +via `make -C client-rust bench-baseline`, reviewed like code. An intentional +regression ships the matching baseline update with a written justification. + +Engine rules: `successor-engine-core` and `successor-engine-render` are +`#![no_std]` + `alloc`; no `core::fmt` in shipped paths; platform access only +through `successor-platform`; rendering backends only through the `Gpu` trait. +The wasm FFI import list in `client-rust/source/platform/src/web/` and the JS +shim `client-rust/web/successor.js` must change in lockstep. New dependencies +are weighed against the size gate — wgpu, winit, bevy, tokio, and wasm-bindgen +are explicitly rejected. The wire protocol reuses `crates/successor-net` types; +gameplay authority stays in Rust `successor-sim` — the client renders streamed +state and submits commands, exactly like the existing clients. The ECS carries +a repo-tailored prefab/JSON/asset layer (versioned `schema`/`format` +discriminators, manifest-by-stable-id, `resolveRuntimePublicPath` fail-closed +rules) mirroring how `client-3d`/`client` consume assets. + ## Authority boundary Rust `successor-sim` owns deterministic gameplay: movement, pathing, structure @@ -126,7 +173,7 @@ The active presentation library is rooted at: - `client-3d/public/assets/` - `client/public/successor-audio/` - `client/public/successor-slice/` -- `~/dev/games/pawn-forge/pawnforgev2/` on Bunker (source checkout) +- neighboring `pawn-forge/pawnforgev2/` source checkout Keep GLB source provenance, manifests, sockets, material rules, and runtime loaders aligned. Preserve authored shaders, effects, weather, crop/flora work, diff --git a/Cargo.lock b/Cargo.lock index a8cb4c4b..0bba63c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -515,6 +515,10 @@ dependencies = [ "thiserror", ] +[[package]] +name = "successor-movement" +version = "0.0.1" + [[package]] name = "successor-net" version = "0.0.1" @@ -532,6 +536,7 @@ dependencies = [ "serde_json", "successor-core", "successor-inventory", + "successor-movement", "successor-net", "thiserror", ] diff --git a/Cargo.toml b/Cargo.toml index b08b3842..d0796227 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "crates/successor-core", "crates/successor-inventory", + "crates/successor-movement", "crates/successor-sim", "crates/successor-net", "crates/successor-wasm", diff --git a/README.md b/README.md index 9fff9f87..a94dfd70 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ client-3d/ Three.js client, HUD, shaders, effects, weather, and GLB loader client-tui/ terminal client and terminal journeys client/ renderer-neutral protocol, state projection, commands, and headless host desktop/ Electron distribution of client-3d +client-rust/ in-development native Rust client (no_std engine, GL renderer, Colyseus protocol) site/ marketing, account, connect, browser-launch, and download shell server/ network edge, rooms, persistence projection, chat, and Rust bridge crates/ deterministic Rust simulation, net types, inventory, and WASM bindings diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/Bicep_L.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/Bicep_L.glb index a7034de7..deff25d6 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/Bicep_L.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/Bicep_L.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ac1e7ae21162578960adb70ae3c37a55b9aeed0f591ec7b7ec3d78025b2a3742 -size 801464 +oid sha256:bd02ae1ec190042bb1015a367784d75dbd469218ed39f8d663a26783db7924d9 +size 803268 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/Bicep_R.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/Bicep_R.glb index 64f21b8f..24161808 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/Bicep_R.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/Bicep_R.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8ad3bdfae32aeebb7f520545941543adab73519beba8e5ed077c666e79c56f65 -size 801468 +oid sha256:0dc1cf487873f6e94e3db7aee09b194bf088de8f36a3ee766864726bb7283ba1 +size 803272 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/Gorget.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/Gorget.glb index 848246d5..8932c5c7 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/Gorget.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/Gorget.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1cc4d6635242ef2240f9413685615ee41e6ac02bd70f5a35450fda82e4add98a -size 814440 +oid sha256:bbd326dd734126a52d2b8f8a839fb0d00c66eba07788bbefa19ea27af9cd40c1 +size 816244 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/Harness.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/Harness.glb index 796d789d..407e1193 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/Harness.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/Harness.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1786729157ecab5fac5e9d429a056e8f95cce8b3cff9bb235c90f6407752ac1a -size 845448 +oid sha256:866b80b1b589e22fc12129079756801fe641da04ac68365b331953bc56b7a0fe +size 847256 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_A.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_A.glb index 022dc92a..851d374b 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_A.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_A.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1c8e80e0bc8d143c07c6db0925ab14cd106fc2b6a7ba276b464bca85afc482ff -size 856552 +oid sha256:e91d1a3344c172683d907d58e993854c00830f4ecbfd098da2f6cf139c51f159 +size 858356 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_B.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_B.glb index b0287d99..f233550e 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_B.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_B.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:469a1c3b3a63187a68a6fdce7fb7426fa90bb7def7e297f6b7721d29e9747686 -size 862852 +oid sha256:110362f84da176b9bb24a2c4d929dd114a987767200d119a27e4f7aba14bfbfe +size 864660 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_C.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_C.glb index 0b13bb89..a09e6341 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_C.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_C.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3872e6c4cb8a14fd4a3f8103ff72e7163c0a9005392dce5a179cd7281ea8ad0c -size 869336 +oid sha256:2c71b4be39da29ceed169a31f3e85298e5f88b1997252752ad7a810a35b6f105 +size 871152 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_D.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_D.glb index 9581df03..b72fe86c 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_D.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_D.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:56540be82ab5c012f51dc52abb2c3d5cc048142b0d2fea09f834ccc783ec9d1f -size 873412 +oid sha256:bd92a85d8351c820423232cf099e6693107c66e057ae0d9aa1c4b0c18c86669e +size 875220 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_S1.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_S1.glb index 38bde467..6bf07159 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_S1.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_S1.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3294d62dbfb02d8933da34c7717a6f75cb9c016035f4f63963dcc6a8c93a6b06 -size 844980 +oid sha256:dc3e6e3b836b64ce25f62cd6ad7f1fbdc6980cd6ee16cad7cdc4cdd1ad7c8289 +size 846792 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_S2.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_S2.glb index 61ffd0ae..b2e640cc 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_S2.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_S2.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:be800c6e1786b89553a7c29184be1c9ccb8cd9e031f82278dada6a039062c76f -size 881464 +oid sha256:1a13f6243c4c1b29c042b6ecb0acd080c148b6a2d03b1e6b8d35a36ddd4f5f9c +size 883276 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_S3.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_S3.glb index 5dca72e8..ed1b2233 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_S3.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_S3.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb1de47526ca216059b6645e05082c14de2887278c70b0e31dc0e0aed4207f4a -size 886324 +oid sha256:ab961cc4a6a56098d52d5ae86c580545c98a55cd3b1251f9ff1199e1e3ec01c4 +size 888140 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_V1PORT.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_V1PORT.glb index adbc7206..cc99ccd6 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_V1PORT.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/Helmet_V1PORT.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f3d8fe6acd690a86fc3538bd1f550f35c28c650217bc2880909be93ce3c7177b -size 838020 +oid sha256:ba99836a98bc3062c14a7a20a68fe3abba32d811a9e6dda16b1bc0752ec648e7 +size 839832 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/Nape_Reinforcement.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/Nape_Reinforcement.glb index 34250381..298342e7 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/Nape_Reinforcement.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/Nape_Reinforcement.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f6543b93363fe48162166be8e1aca606f3977ce914ac82ed700bc7b369e71cae -size 861180 +oid sha256:7bce6fb3da0931c6ae2d94cf68642183ffd8d894162c26afd7bcdb6013a8daa6 +size 862988 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/Reinforcement.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/Reinforcement.glb index dcc9fc6f..bd5c3830 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/Reinforcement.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/Reinforcement.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:98d8326d01fe37ba1a65ca55e0c05c82bac90c5a2b05e88e588f83d45d5fc22d -size 800772 +oid sha256:db35a86cbfd4a7d36ee8b2bc5bbb92eb9d8887330113948a2da945cac24e6b6a +size 802584 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_back_a.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_back_a.glb index 4ed3d4ad..80149d1c 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_back_a.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_back_a.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a4bbf92c0dd4bfe1b12a0afc44518835343358aab49583184da4aa50df60214e -size 846460 +oid sha256:e588875fed99a7b4b8e2d6f1e1fbc1abd1e2318b06f5d74388e9d1f27f4f831b +size 848284 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_back_b.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_back_b.glb index 28002431..b9dad6f0 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_back_b.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_back_b.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:263978126f9642f19d76921dbbd8d613e40599334bfd750c0b5a15723cb78dec -size 853188 +oid sha256:27a1f09536585ec93ad7efb8f5f6cdfae97efeb9c90f0a4ddc5fc3dc271c6ad6 +size 855012 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hair_a.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hair_a.glb index a41bfbd2..e5b01258 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hair_a.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hair_a.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3bd987e7f9c130bbfbc092fa7279d08848f5218742167450ce8910204e4d5de6 -size 822740 +oid sha256:94a0117b701d97fdc6c92703ea878bd33b942a4ab20c50dbc4c45cc1c8b1bdac +size 824564 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hair_b.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hair_b.glb index 651bdf76..356c1133 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hair_b.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hair_b.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:86bd3c4df11967f5a40c70c1edf8ddccc1d912dcfdecc4a18a42785b422c2776 -size 830556 +oid sha256:8e0732537545185b5926440bf41f0d09a03af4955020e2cd0ea3cc62a135e59e +size 832380 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hair_c.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hair_c.glb index d481637e..52b88644 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hair_c.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hair_c.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7a3f45ab5982f8d4b09fc1ee30fed5abcf7024ad9fe4ad76e7e444ba6e851f9a -size 820304 +oid sha256:b20a1d22780fe89400bb3ea2eb6d09b5359d595e25cc57965d2f0f72d9386451 +size 822124 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hair_d.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hair_d.glb index ff408737..c3db6432 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hair_d.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hair_d.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e209192be8a538f5deda88dfa27bbe66079361898d7f7ccf7b22130a2bf4ca0b -size 876188 +oid sha256:217faa7a8a259a951cf97137a43dea78696248795170d2138cfa11a364abef12 +size 878008 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_helm_a.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_helm_a.glb index 5a73cea5..7408c7fb 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_helm_a.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_helm_a.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3d8a92650200bd296d2878cbbd94fec817a80871e85169acc5bcfa341b61bc5d -size 821064 +oid sha256:d418e83ebe51177f304cb3ebefd4260a58cd98cb3a43b3094cba0a82d8c421df +size 822892 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_helm_b.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_helm_b.glb index 54bcb1ef..885e116e 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_helm_b.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_helm_b.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cb8cdd60c7044029aa2b84184c13e3f7ff8f44e4494cc81a40d7f1d103b1b963 -size 821492 +oid sha256:8c33891fcadff624358b5b90fa3f685fc0e1e088fe9498ee850801e6984aad92 +size 823316 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_helm_c.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_helm_c.glb index 3019d099..dd87e360 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_helm_c.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_helm_c.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e03df252b53cfaeb1078e407d0572e075ffe4ca2b7b2473cb525b70aa4916d93 -size 810488 +oid sha256:58c5e227d409e63cf2f8ee22a7d810cdaf5b1ee93a5147ac143afeba784088cc +size 812312 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_helm_d.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_helm_d.glb index f397d95f..4152b95b 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_helm_d.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_helm_d.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1c63048f66c2826e7a5cbaee3b146b160097b1c92ec39d6db9b7317aa92816b3 -size 824516 +oid sha256:c3989eb44be7d409e49b1c84b9690562f3729f283963000cad196ad9f3cd00ff +size 826336 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hip_back_a.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hip_back_a.glb index 9697cd94..bfc29446 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hip_back_a.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hip_back_a.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2283421a1702e636401ccff25704978dcf4161ce1d9e279b0fc32df5fb4b8406 -size 802740 +oid sha256:c4a6ddf7e970a2b54ce5fe24c4576548915bfb16ae4177b86e25554ca6ef1e48 +size 804564 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hip_front_a.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hip_front_a.glb index 27a6b320..3d5d5992 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hip_front_a.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hip_front_a.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:029f530b1f386f4371a7e94e4a392d596ee51ba1b2b8638552a761ff68efcc8f -size 822284 +oid sha256:2381b7528854c68e73ce94a1ac6c44aaa6e75fda9642c1c335d497412b0b38bb +size 824104 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hip_side_a.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hip_side_a.glb index 59dabeef..cc58e307 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hip_side_a.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hip_side_a.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:61716ad6fcc867cf1ef92b5321e2e1a1bc3ee5244b5774c851f89138f3203bfd -size 818516 +oid sha256:6868d5b8ee590a919a7a334e5dfe667d30dc42b3cb60fd5e935b39638e160e2a +size 820332 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hood_a.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hood_a.glb index 82e4fe21..aa7f4e6c 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hood_a.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_hood_a.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9fdecfb7c02381edc7ecdc40fa1d5ba7ec434dee1fddcfc370aeb3beb73d6db8 -size 834036 +oid sha256:d59f5229bcc41bb23ae03c2c8b45de747c70fdb58cc3158a3bf5603105f49eff +size 835860 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_mask_b.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_mask_b.glb index 3dbb519c..79b2291c 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_mask_b.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_mask_b.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9f2b186136009289f07f1b5428272c35010512702e1d9b80ccdfa81c3ffe54a1 -size 797596 +oid sha256:af5e259d76ed2d3a3b8aa206f5cae873a886abfae731d82d32b311ca0aee4e62 +size 799420 diff --git a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_visor_a.glb b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_visor_a.glb index c588d81b..b8c841ee 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Armor/trial_visor_a.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Armor/trial_visor_a.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:99fc2f8488cf43592aca41499f8653063981cab493abf08bc345d9365838ecc8 -size 812220 +oid sha256:43efdcf0c5dbb1c7643b68d85261df2963daec8c70e248d3f7ef437c95839be7 +size 814040 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Bicep_L.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Bicep_L.glb new file mode 100644 index 00000000..eada72ee --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Bicep_L.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b6ea802ec64eed2688378ad4cb5ae0637d56762161408a6ca9f8d450a644f25e +size 803272 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Bicep_R.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Bicep_R.glb new file mode 100644 index 00000000..434d6089 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Bicep_R.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a089c7c229905472c5529f2198d42b147871bbef7845c17e8aaa6b269d1d3449 +size 803276 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Gorget.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Gorget.glb new file mode 100644 index 00000000..6df44144 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Gorget.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb23b5346fee69800d6aa9f8200efb3fe432c690abdcf46d3c72bd1646b8f8c8 +size 816248 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Harness.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Harness.glb new file mode 100644 index 00000000..2e583643 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Harness.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c4f3c702936c7e0871b6f415214e2cc09d574456792fef100edfc81e9d23540 +size 847256 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_A.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_A.glb new file mode 100644 index 00000000..8f387b86 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_A.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf11eab6dfec60de110360d6a12f0c3901b5d23ae857a66796a777341fffc309 +size 858360 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_B.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_B.glb new file mode 100644 index 00000000..8681706e --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_B.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54797dda442a0437bb66eee40ba8e22379d0f7a5736cecea4fd9b0f7d8f413d4 +size 864660 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_C.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_C.glb new file mode 100644 index 00000000..abb7565b --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_C.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc5108a822700fdcb834ca28c00ee7301d0a0b0859e957a2c17d1fb648a747c3 +size 871148 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_D.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_D.glb new file mode 100644 index 00000000..6ade9814 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_D.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:601390f6a1c96bd539d4fa2f5166c4f1ae05a1df296da876bbbf3b1184491d7d +size 875220 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_S1.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_S1.glb new file mode 100644 index 00000000..d7dfbc00 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_S1.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e55e575213528eb4cdaf08d937f28bde11ad8b517f31a1e25cb9a73cd558fe5c +size 846792 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_S2.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_S2.glb new file mode 100644 index 00000000..197f7202 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_S2.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e53fd8f8b668494d4932d1b9e98f958647bd2605422a454af3ec2ad4f0f826d +size 883276 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_S3.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_S3.glb new file mode 100644 index 00000000..ad97eabb --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_S3.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b6f91dc50c854010a5bef05c67d295f864d4de0b48575db571b7b27f61da09d +size 888140 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_V1PORT.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_V1PORT.glb new file mode 100644 index 00000000..ccf76dfc --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Helmet_V1PORT.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:09f201aebc6e5766387b866f833bfab0a9ffd777f3d40ba03b691cd7dccaa4c4 +size 839832 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Nape_Reinforcement.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Nape_Reinforcement.glb new file mode 100644 index 00000000..fa354e9e --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Nape_Reinforcement.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de10603f32376ccdcf045e4139be691818bf482840c58b80884a6155bedf5ce1 +size 862992 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Reinforcement.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Reinforcement.glb new file mode 100644 index 00000000..d2d732dc --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/Reinforcement.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ed25dbd72b007e43d0417253c128e378e3ea2647bfc3ae0e585557f65da9927 +size 802584 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_back_a.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_back_a.glb new file mode 100644 index 00000000..7c5ac36b --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_back_a.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b823fd6a0b3678d20a05879a7703ec1dad02decfd0315e2345c089baa0bb5566 +size 848284 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_back_b.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_back_b.glb new file mode 100644 index 00000000..fb593fa6 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_back_b.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3d0e6820fe99c89fea325d86555b462872002477ab2274e8345ecb5db4ff456 +size 855016 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hair_a.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hair_a.glb new file mode 100644 index 00000000..9a300d18 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hair_a.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0003abdd6fdcb49e312f41d7e507dcc56246e833ef805bbaf600dbd51a98f709 +size 824564 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hair_b.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hair_b.glb new file mode 100644 index 00000000..c1cee8bf --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hair_b.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b519b4d7498c4de5ff9f23dae5837d0972364ca6ea062f1e2c0b0f8f3c8cfd2 +size 832384 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hair_c.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hair_c.glb new file mode 100644 index 00000000..5b0cc683 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hair_c.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df61a9791496cbb96695a86e5a45ebc1ee8392b40dcbf13d4c8183aa7424bd84 +size 822124 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hair_d.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hair_d.glb new file mode 100644 index 00000000..a26eee4f --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hair_d.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f7a49687fb549adec77f6eb7f2559b0e5a8002274069c97c6ab2dffc9702062 +size 878012 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_helm_a.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_helm_a.glb new file mode 100644 index 00000000..b4e9aaa5 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_helm_a.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1ebbfdf30724bbaddf95c582a5da0d9da5e6c0568433f699fad368bc908b3cb +size 822892 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_helm_b.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_helm_b.glb new file mode 100644 index 00000000..46910ddd --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_helm_b.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8bfb118fe67c2f0420199171c326c23fbad43418f504e058547fdf9fd00d62d3 +size 823312 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_helm_c.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_helm_c.glb new file mode 100644 index 00000000..20bbb7ad --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_helm_c.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a224d93c2d67eaff6e2fce7f18a0796cd8b089a623a098b11b7211381239351a +size 812312 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_helm_d.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_helm_d.glb new file mode 100644 index 00000000..3c4c5a58 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_helm_d.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d7364e115f4efe9d791d83e66ae09e52e99b0224ea753a5b9976aadfbdf9870 +size 826340 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hip_back_a.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hip_back_a.glb new file mode 100644 index 00000000..95afb25c --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hip_back_a.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ade262cda136e9e7cb8a934e0f1bad31a561437f5d0777b99e51a528be79dcb +size 804564 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hip_front_a.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hip_front_a.glb new file mode 100644 index 00000000..5dc62697 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hip_front_a.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e21a4d4dd4f03916c874744677108a8ca2855c67643c2e9e609e16e8985d5b7b +size 824104 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hip_side_a.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hip_side_a.glb new file mode 100644 index 00000000..8e70558d --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hip_side_a.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4e2d946e0727589bd2f518dcf4df09686718e104e90403ce1f6b275ca0c868e5 +size 820336 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hood_a.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hood_a.glb new file mode 100644 index 00000000..49c778bd --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_hood_a.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fac7e289caa79808fe037c68777a4cef83a055798cf1bc723e15c426fc7ad8f1 +size 835864 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_mask_b.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_mask_b.glb new file mode 100644 index 00000000..e747b028 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_mask_b.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7a871b54359a0892f230ba2f829ab3ebe6c0f36e26e3a58afb318f8afb338e6a +size 799420 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_visor_a.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_visor_a.glb new file mode 100644 index 00000000..9b441d90 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Armor/trial_visor_a.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8718fbecc49e18a8fb285ebec090b7777ca3b59444f51425b30af77afac1fbf8 +size 814044 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/Hair_Afro.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/Hair_Afro.glb new file mode 100644 index 00000000..6e4dec6b --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/Hair_Afro.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20415a8e276a7af39d0124d921a8576f30dad4e7b9bf8752f8fb6453c4c7c27f +size 807244 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/Hair_Crop.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/Hair_Crop.glb new file mode 100644 index 00000000..50d69599 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/Hair_Crop.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7ef20dc9bfe55e590ff870b87cada16ec5fc8d7a36cea4eaa1c97c365a03dd75 +size 807248 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/Hair_Mop.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/Hair_Mop.glb new file mode 100644 index 00000000..a2664f59 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/Hair_Mop.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:221090cd837ff313c6978c7f5ed6d98f5637a0c107195c35709b030ccdba4c8b +size 814548 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/Hat_Warm.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/Hat_Warm.glb new file mode 100644 index 00000000..456616ee --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/Hat_Warm.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:641ee4a279b9c8334fbd078defa94550d580192dbc74f91a699aa2eb3f84b6a9 +size 810984 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/SCFI_Pants_Refit.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/SCFI_Pants_Refit.glb new file mode 100644 index 00000000..8e530c4d --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/SCFI_Pants_Refit.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:848ed1bb3763817690c69cb602fc2d1b0b85b69c3df0a2418a54ad10c0b58a52 +size 217472 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/SCFI_Top_Refit.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/SCFI_Top_Refit.glb new file mode 100644 index 00000000..b1d88112 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/SCFI_Top_Refit.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:193e2204da23605a27d23d7a41b0462f9853302e8d4ea483a357cbfed81231e6 +size 308232 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/Shorts.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/Shorts.glb new file mode 100644 index 00000000..753afc11 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/Shorts.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c013321f2c6f7dd743bcf7618df6c842665ad6d26f797c3bbf3a6f294576c95 +size 819732 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/Tank.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/Tank.glb new file mode 100644 index 00000000..fbe21ce8 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/Tank.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12037a9ab00367cf14c4f017732c78117c81600f45d74ab165e7506d5618e20b +size 824212 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_canvas_ankle.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_canvas_ankle.glb new file mode 100644 index 00000000..2c86f8ca --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_canvas_ankle.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:24fb7c94ad564ded849884cb8e750e9898e85a8703a86012369404c845e93a51 +size 33220 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_combat_sneakers.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_combat_sneakers.glb new file mode 100644 index 00000000..bad534af --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_combat_sneakers.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40b1f316ccba557d7105ea9890b427034cb0997b735ecf75c34f545cde0675b0 +size 61280 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_cuffed_runners.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_cuffed_runners.glb new file mode 100644 index 00000000..f5290bb6 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_cuffed_runners.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf70bcf65d23305d03d2cfc6a92809d183d874e35abff485496a84f4cdcfe9ff +size 62648 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_double_strap.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_double_strap.glb new file mode 100644 index 00000000..b8aa22f6 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_double_strap.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e00ec720c0c5f00af6680a83499a48b8a32807400ca3237864966528e0f54835 +size 79724 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_layered_sneakers.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_layered_sneakers.glb new file mode 100644 index 00000000..6f6bcf39 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_layered_sneakers.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:26b3b3b3599fe6ef20a03bd6492f2e9c8321a7c2102d4577fe6baf20817898e2 +size 55404 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_reinforced_street.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_reinforced_street.glb new file mode 100644 index 00000000..e9434d0d --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_reinforced_street.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc70685489254e4bcbe6fc8593da1b2cfc1457db0d022269c66b12fd19336b5c +size 72388 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_rubberized_canvas.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_rubberized_canvas.glb new file mode 100644 index 00000000..0a6f9aff --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_rubberized_canvas.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0245dc1cef9e6587a63e3216092bacec37e8339e93cfb898d83f7d51a12c667b +size 91504 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_split_toe.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_split_toe.glb new file mode 100644 index 00000000..9ba3ee83 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_split_toe.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c6f49a6b4e5d032618da37300f3d4772a4230a71f909115330e412483f10ec4b +size 64444 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_toe_capped.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_toe_capped.glb new file mode 100644 index 00000000..88cb85cc --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_toe_capped.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6eab43391eb0ff3d1901fd511d253255c884b48697c5154cfca57c7accfaf917 +size 44016 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_trail_shoes.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_trail_shoes.glb new file mode 100644 index 00000000..3255e3b5 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_trail_shoes.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b11796a3562da66670baba2823a5909b008bcc1aa725bbe1a395d35cb5df4a4 +size 68568 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_guarded_leather.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_guarded_leather.glb new file mode 100644 index 00000000..4b22d06b --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_guarded_leather.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f7cfca859f4da855dca1d85eea37f19386e60c9d6cd4295a4540d35e9e0db5d +size 75172 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_knuckled_half.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_knuckled_half.glb new file mode 100644 index 00000000..da109648 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_knuckled_half.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:82dc4642a2007d8dcd2c275cbf165ed8ffc0f94cfecf987850bed12cb5bbcebd +size 43396 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_light_utility.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_light_utility.glb new file mode 100644 index 00000000..4284e511 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_light_utility.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8c13d15cb2ddad823f87b952c855b07151bc256fc2f36c2dd0025dff5737de1 +size 56628 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_plain_work.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_plain_work.glb new file mode 100644 index 00000000..e1566374 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_plain_work.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:816b28bb67f4b65cdf0727b99930902764014a96b30f655b44818fd1900d964d +size 46356 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_plated_handwraps.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_plated_handwraps.glb new file mode 100644 index 00000000..5e046835 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_plated_handwraps.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b99b16c3f6366fd0212396254ef7b7759e989b0cef8203ebadb1e53837deacae +size 59276 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_reinforced_knit.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_reinforced_knit.glb new file mode 100644 index 00000000..df1eeca6 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_reinforced_knit.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af10354463c35504d5155e4480346c7b0de2708912c00a55874c038041815476 +size 49012 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_tipped_work.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_tipped_work.glb new file mode 100644 index 00000000..d8d68c0a --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/gloves_tipped_work.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:666cf7dbd00905e1143b3e49f368c55317fbb6b8fc087af70d1a167f0a3d891d +size 76784 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_banded_mohawk.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_banded_mohawk.glb new file mode 100644 index 00000000..1d476c28 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_banded_mohawk.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3d1367502d47ed1b46d49d11bfd59a04cdf5cbaec146cc204f47b02e217783aa +size 70284 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_braided_crown.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_braided_crown.glb new file mode 100644 index 00000000..7cd7c813 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_braided_crown.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a391a95ae7963c35b85da8bf80641815f623b8f3cbd1c71bd9ae44ea3d76f7d8 +size 229464 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_cropped_buzz.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_cropped_buzz.glb new file mode 100644 index 00000000..9cf90338 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_cropped_buzz.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4eab51b641b8ed98cef15755622fb8cc6ebca232dc3449212d72a195f6fe39c6 +size 87740 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_fauxhawk.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_fauxhawk.glb new file mode 100644 index 00000000..0cc4ebd3 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_fauxhawk.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a8dad284948ef7729a9972d719960f1c1c5113dbe3c66ebded604f624f345072 +size 95244 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_flared_shag.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_flared_shag.glb new file mode 100644 index 00000000..748cc3c9 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_flared_shag.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e62cddfcdb7b07a6fbd9e88e552640da68e29b503556c0f44b97129546d54e17 +size 91004 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_flared_wings.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_flared_wings.glb new file mode 100644 index 00000000..660dadcd --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_flared_wings.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec897a91bcc7516cd8c1b1608d8fda725d8a6d30cc3e6355dcc9d29b53c860fd +size 56140 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_flat_top.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_flat_top.glb new file mode 100644 index 00000000..49adba68 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_flat_top.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4c8434f7d48344ed4dc27bfe0b11734e545ab4b670ff1ad54246ae021d72bd9 +size 90980 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_low_tufts.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_low_tufts.glb new file mode 100644 index 00000000..f899a4c0 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_low_tufts.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f3190e8946803d88faa3decb518b8384a1b0049fac526ccee4aec1136460ac56 +size 67944 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_messy_crop.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_messy_crop.glb new file mode 100644 index 00000000..6dec70ea --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_messy_crop.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b26ec0cd1345593933a2ffed18cc3d64320fd4638a1c7409d32c0d1e7e0fe1ca +size 77444 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_messy_side_part.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_messy_side_part.glb new file mode 100644 index 00000000..91138e50 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_messy_side_part.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:250408e83f0721cdcef46d7965977b6aeac7fed2835c3d34bfa0926d7fa6741d +size 103500 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_ridge_mohawk.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_ridge_mohawk.glb new file mode 100644 index 00000000..d58b07ce --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_ridge_mohawk.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7713355fbaa50afbf2f9950f670b265347fd557e506e971f9e6737e88b585254 +size 28444 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_short_ponytail.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_short_ponytail.glb new file mode 100644 index 00000000..62a8a5dd --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_short_ponytail.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ff7400251e99018b37a4035ee942c1a04176fb8047bfcb432118be4034be649f +size 116144 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_shoulder_shag.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_shoulder_shag.glb new file mode 100644 index 00000000..520d0d48 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_shoulder_shag.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3c23cab55b9d38cdc741666a58880443a707fbf123df7ae5dae4828d32822975 +size 99940 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_side_ponytail.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_side_ponytail.glb new file mode 100644 index 00000000..7ec87f73 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_side_ponytail.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a681147bb58c4a8f7aac5c8c9fd8f6bf119025fea3d7b53f3725d71bb38eee1d +size 152536 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_side_sweep.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_side_sweep.glb new file mode 100644 index 00000000..81c4e44e --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_side_sweep.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1ca0ee05f3b62fc19900897266036a93418fbd23cddac825242526f5570bcb1f +size 74400 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_slicked_crop.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_slicked_crop.glb new file mode 100644 index 00000000..eee59002 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_slicked_crop.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce0d62c1e64befdf0abca520bd88c0a1b474b7694280b0bdc3b8f9bbee107ab6 +size 59760 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_spiked_mohawk.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_spiked_mohawk.glb new file mode 100644 index 00000000..ec923352 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_spiked_mohawk.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c145f4e629ab625499e360b342a29239bf79a7c3e64a6d0e0ab723f8c0b2b075 +size 97172 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_spiked_topknot.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_spiked_topknot.glb new file mode 100644 index 00000000..05be95e7 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_spiked_topknot.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68e6fb86bf1a26baa77f7ca2e7023d2c307c427c22e6d37285a1a00a21d42b5d +size 80860 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_split_mohawk.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_split_mohawk.glb new file mode 100644 index 00000000..25e8140d --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_split_mohawk.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac9a62123470bb55c67f32a6bede4c880385e46d5dc31d97af1b1961f7575b53 +size 105764 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_swept_bob.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_swept_bob.glb new file mode 100644 index 00000000..b4db30d6 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_swept_bob.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6448df045f9fb1af9886ab1d3d15d7d636ad3338def198bb6a812e57ce12f847 +size 91032 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_swept_spikes.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_swept_spikes.glb new file mode 100644 index 00000000..bf24cb67 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_swept_spikes.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c914a76b9cfc2fbdfc60e6b8a971bec68cd4bcd2e3e2486f209e4dd52af4996 +size 87292 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_textured_crop.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_textured_crop.glb new file mode 100644 index 00000000..b9b2fbe2 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_textured_crop.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84a5b79962fecf784b99b1288145d728cd0b3a9d61af6c2f6463d2c3ad209faa +size 89336 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_top_patch.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_top_patch.glb new file mode 100644 index 00000000..ad916f33 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_top_patch.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a0127d217e942c86ec597e0012a4430099a61ca5ea48741f4e58961cd7ef0aa1 +size 47580 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_twin_pigtails.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_twin_pigtails.glb new file mode 100644 index 00000000..3cca3ce0 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/hair_twin_pigtails.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c8c7a85139a13d60520fa5fc188b515c41211d2bdd2029acbfadab0fbeb5b05b +size 136448 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_gaitered_cargo_pants.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_gaitered_cargo_pants.glb new file mode 100644 index 00000000..84d39d69 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_gaitered_cargo_pants.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a867c6b839451a3e53c68afd0dac2cbe0c143899683bfac3f3caba1f2a89b797 +size 199300 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_layered_shorts.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_layered_shorts.glb new file mode 100644 index 00000000..6c183f6a --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_layered_shorts.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d52bc8d7cdaf003a79b18281dedd59e9850a5331d1f4ce1c9d12df33aeb104b +size 226952 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_layered_wrap_skirt.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_layered_wrap_skirt.glb new file mode 100644 index 00000000..8d73d906 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_layered_wrap_skirt.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b7a3b13bc32e3133a9b36bd7eb9b80caf2f5e6481937a0d52b5da4b8cd2f504 +size 184428 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_padded_canvas_trousers.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_padded_canvas_trousers.glb new file mode 100644 index 00000000..82baff24 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_padded_canvas_trousers.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0db94d4d1e2da0764dedb602179e0931edbaa82688cc415be7157d555bfea6f +size 180932 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_plated_trousers.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_plated_trousers.glb new file mode 100644 index 00000000..76340841 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_plated_trousers.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:27d8c5775f1ad50c04d38f29f31e5098dd952b73490e4beb91166d5087861afc +size 172524 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_reinforced_denim_pants.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_reinforced_denim_pants.glb new file mode 100644 index 00000000..e72fa819 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_reinforced_denim_pants.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92b8eb19e3edff310bef9b3e6563d60825d5a0664815c63c71638deca55aca3d +size 198992 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_sashed_patrol_pants.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_sashed_patrol_pants.glb new file mode 100644 index 00000000..5ceec782 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_sashed_patrol_pants.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7fd35fe70c2dd6828840fcf44925490803a833a0b274f3a8b0a4939ef93ddf7d +size 202112 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_skirted_workpants.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_skirted_workpants.glb new file mode 100644 index 00000000..8e68a4d2 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_skirted_workpants.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7a8ca2d6aad62a38ed77a6f4b549028e9a7d72a9d82f1db3daa10d55101eb38f +size 184644 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_strapped_trousers.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_strapped_trousers.glb new file mode 100644 index 00000000..edc90336 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_strapped_trousers.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e6ad6879d49d0c528fecf84b29b5d1ce9e55817ec59e9152b001836764ab8da4 +size 222364 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_wrapped_workpants.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_wrapped_workpants.glb new file mode 100644 index 00000000..96e00a2b --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/legs_wrapped_workpants.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d6bb546bee4354d7546b1c9f725c32d535e6efe79c49e75867bfc960a4856fc +size 190912 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_frayed_tunic.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_frayed_tunic.glb new file mode 100644 index 00000000..5e64ef13 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_frayed_tunic.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:23b09c267ef9fc11b05601c8441521527a3171ed63bdaaa73cdbc682129a317a +size 135788 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_laced_corset_vest.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_laced_corset_vest.glb new file mode 100644 index 00000000..64f1e508 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_laced_corset_vest.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2da263833c592aea0992d2b5f2da4797df8cc98a87d4780f64ecee7b24323ac2 +size 142924 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_padded_leather_vest.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_padded_leather_vest.glb new file mode 100644 index 00000000..a8e31b91 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_padded_leather_vest.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5410decab7f79b452fdd5712c7da81160847e38958a0bfe76ce8d6775003957b +size 134784 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_plated_rig_vest.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_plated_rig_vest.glb new file mode 100644 index 00000000..38f29b4b --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_plated_rig_vest.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:671e17ced76ffa3aae9c8375d6ff2dcbe5a124382c0def14042ba5c1d016cb26 +size 133084 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_reinforced_crop_vest.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_reinforced_crop_vest.glb new file mode 100644 index 00000000..f16862d5 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_reinforced_crop_vest.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:69580f1aa4e2946a375ab7db6d4304f803716f9dcbce1fdf5b81db6718129f58 +size 92044 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_rigged_tank.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_rigged_tank.glb new file mode 100644 index 00000000..6e54dc34 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_rigged_tank.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a70dd0dbeb4be3b09321c55a9ae61bfef4619ecfc151885511c8a48b3c59e66 +size 100380 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_scrap_plate_tunic.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_scrap_plate_tunic.glb new file mode 100644 index 00000000..bf171a7c --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_scrap_plate_tunic.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9ba04553f2ed4623c06f8181eeca7726aa351a82666faf18b1592c312290e7e8 +size 131440 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_spiked_leather_vest.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_spiked_leather_vest.glb new file mode 100644 index 00000000..8efbdb83 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/top_spiked_leather_vest.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e4864467293ca27596f4136bdb767201c607bb6a353bfaa4f25e327b54fccb5 +size 123044 diff --git a/client-3d/public/assets/pawn-pack/equipment/Female/Under/under_bodysuit.glb b/client-3d/public/assets/pawn-pack/equipment/Female/Under/under_bodysuit.glb new file mode 100644 index 00000000..a8b3b54b --- /dev/null +++ b/client-3d/public/assets/pawn-pack/equipment/Female/Under/under_bodysuit.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a4500baef1a8f9fdab63b472b2b2fc04c31c0ec7a93f84321ae43039e807de75 +size 60608 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/Hair_Afro.glb b/client-3d/public/assets/pawn-pack/equipment/Under/Hair_Afro.glb index cced5f09..b8c3c3da 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/Hair_Afro.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/Hair_Afro.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c752c7e1a081f46b57028650b72d527173ba84bed7f0d8a039ec85c645e3500f -size 805420 +oid sha256:ce18a7ff31f40b0ed0149ccb4fa7b14b3dd8f343814ecf112ba44ae55b17ef44 +size 807244 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/Hair_Crop.glb b/client-3d/public/assets/pawn-pack/equipment/Under/Hair_Crop.glb index 800ce9bb..756901a5 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/Hair_Crop.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/Hair_Crop.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2feafd76bec48d40e86ebe8154e66d273468260fbba95c448b735b83a278d380 -size 805424 +oid sha256:725bbd31bcffeb041507d5fee1149502af90ed893b0ef8f4850142f9c2d10722 +size 807248 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/Hair_Mop.glb b/client-3d/public/assets/pawn-pack/equipment/Under/Hair_Mop.glb index 77a059ec..048a3820 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/Hair_Mop.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/Hair_Mop.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6279e55849198be5f761e472c5736a10cd76d8bd422902940396d95fb11a249f -size 812724 +oid sha256:c7f32850960d89a53b2ac4fefbf288c5b8e4fc36527886d585275118b5b21508 +size 814548 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/Hat_Warm.glb b/client-3d/public/assets/pawn-pack/equipment/Under/Hat_Warm.glb index 568978a1..c7bb4dec 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/Hat_Warm.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/Hat_Warm.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:25207acfda673de0dcf6079fc6fe51f3d94a3cce3f7d9f05593e2a8ec7b6a366 -size 809176 +oid sha256:911eb6ecbb61be32cc79f3a0d56bcb7680362c5399e7325352cf43d7385d5569 +size 810984 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/SCFI_Pants_Refit.glb b/client-3d/public/assets/pawn-pack/equipment/Under/SCFI_Pants_Refit.glb index cb2f2ad0..29d4cb21 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/SCFI_Pants_Refit.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/SCFI_Pants_Refit.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dc436e4fc9377ce0ad2250e0e2427abbcce5ac5233184f41614c1553d05d742e -size 217404 +oid sha256:ff6bdf8d8f41f90a5246177f1d31a8058f44b97fbfd2314812c663f4c4f7d6eb +size 217452 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/SCFI_Top_Refit.glb b/client-3d/public/assets/pawn-pack/equipment/Under/SCFI_Top_Refit.glb index 976d14ef..a51ab5c2 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/SCFI_Top_Refit.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/SCFI_Top_Refit.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0a78bc7762ed533dfd5bf067a107df91b823924a1716aff65cc7174355efe009 -size 308172 +oid sha256:52e123b9f6443b38646a07543c7c47896f2689f9e40fbd034789a08f789dd6ee +size 308232 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/Shorts.glb b/client-3d/public/assets/pawn-pack/equipment/Under/Shorts.glb index 0c26db89..f7e97895 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/Shorts.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/Shorts.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e52ff60e170b790941205daebd2f794b1299ad8898b81082afed4fad01adfe1b -size 817924 +oid sha256:1d8a902ea281d760335044630d37444fbc7b35643a276b06437a0836f15d158a +size 819736 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/Tank.glb b/client-3d/public/assets/pawn-pack/equipment/Under/Tank.glb index 3c103754..d9aae0ef 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/Tank.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/Tank.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6614c478ab20f7691276040c3dde6e42e703e3534cce65f17711044f17cef87c -size 822404 +oid sha256:f5f6e6845beb1a6df8b0a065fc726709b1d52912629868fe7a95e40764d6f987 +size 824212 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/boots_canvas_ankle.glb b/client-3d/public/assets/pawn-pack/equipment/Under/boots_canvas_ankle.glb index ee71b573..d2a12553 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/boots_canvas_ankle.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/boots_canvas_ankle.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:84a8472ecb14636b57619078d7cf8704ad35f8d1bf709a1cd7b4fd3a55c266cc -size 57168 +oid sha256:5980ff35c965522e2e88a03b63abdea9a75f49982c5e90fbd4a6c54f3f461cd6 +size 31872 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/boots_combat_sneakers.glb b/client-3d/public/assets/pawn-pack/equipment/Under/boots_combat_sneakers.glb index 9a5325df..05938331 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/boots_combat_sneakers.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/boots_combat_sneakers.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:122a32ce463931f2b4a5ed8e935ae2e783b7f1cdbd09a7904787ac657607d1ea -size 59916 +oid sha256:2e79aa8ada888a5d0e55647330b0dd1bde00728bc7af08a449b9d64df8d9dd5f +size 61276 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/boots_cuffed_runners.glb b/client-3d/public/assets/pawn-pack/equipment/Under/boots_cuffed_runners.glb index 566dcd72..6d7858db 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/boots_cuffed_runners.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/boots_cuffed_runners.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a41a5716e510c2431f16ce50f077f65e0927a2a19a9f45bd17c5fbddf38b8149 -size 61280 +oid sha256:9b0ea8a76b09bb04da560b24f9c528193a564c39e2ccc4828879fe298161ca34 +size 62648 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/boots_double_strap.glb b/client-3d/public/assets/pawn-pack/equipment/Under/boots_double_strap.glb index 1995e504..79f4878a 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/boots_double_strap.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/boots_double_strap.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d2181a0fac74a15847356fa39dd528dc5249549febc7441449603d979d0f3f1 -size 78356 +oid sha256:2854cc8fbf384bd2ddb12fb726857a660d88a45f738b8235e54604538a34b0d8 +size 79728 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/boots_layered_sneakers.glb b/client-3d/public/assets/pawn-pack/equipment/Under/boots_layered_sneakers.glb index f0058198..a567aea6 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/boots_layered_sneakers.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/boots_layered_sneakers.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1839530ac47eb0b3dd4c12c4ec490662f3620713b376430f64c24ad50db08d48 -size 53744 +oid sha256:da6615015193fd6f2aea75fa9924b42029cc35f07ca5c9f9b1774f549cc0f811 +size 55416 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/boots_reinforced_street.glb b/client-3d/public/assets/pawn-pack/equipment/Under/boots_reinforced_street.glb index bd92f772..fd87e29f 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/boots_reinforced_street.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/boots_reinforced_street.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f9ec0acbab606fdd70706896a8b1f6608885a9079d5be3b7490471704201d581 -size 70688 +oid sha256:c70e667130be62226b4e98fac381658fa2ebe51fd5ca746679dcd10b1efb327d +size 72384 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/boots_rubberized_canvas.glb b/client-3d/public/assets/pawn-pack/equipment/Under/boots_rubberized_canvas.glb index 786c5419..0b071bd4 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/boots_rubberized_canvas.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/boots_rubberized_canvas.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:45e6a3f591b3750d11174c05489cb87ae3e05536a9da2c6315d9b5bcfddc76aa -size 89816 +oid sha256:1297f17fa95fead6c099a72de7f8a4512c1c883959ee8149acacdf35e1787ee4 +size 91504 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/boots_split_toe.glb b/client-3d/public/assets/pawn-pack/equipment/Under/boots_split_toe.glb index 95b75892..57bd30cb 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/boots_split_toe.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/boots_split_toe.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4bfa7784f959c9ac37e67f80464fc2cd2594ada1a688135b555969c7a2ed7682 -size 62764 +oid sha256:65fc2884690fb135e001ab7982e7bf4f3e2f4461fc3ea320aace328a3493b834 +size 64444 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/boots_toe_capped.glb b/client-3d/public/assets/pawn-pack/equipment/Under/boots_toe_capped.glb index 1e4e28a9..346e0e5a 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/boots_toe_capped.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/boots_toe_capped.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2185ae8984fa8c0c2bcf434dc369eee59f3696885d4180bf0ecd1f8c85438008 -size 42980 +oid sha256:ed3967d438467d1c249426563c78355bbd7bb134e7190fe4c9ad2798bb46e17d +size 44016 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/boots_trail_shoes.glb b/client-3d/public/assets/pawn-pack/equipment/Under/boots_trail_shoes.glb index c5d216a5..1e90343f 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/boots_trail_shoes.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/boots_trail_shoes.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8b638d2a65126b71cc14e291fb01a6ea1d5a23f0e5f96e478cfcb3cecbf8d461 -size 66568 +oid sha256:53e6757a88c9f2de42003b00a5e9de2f9c235363d4b1684e80544667db8e903b +size 68556 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/gloves_guarded_leather.glb b/client-3d/public/assets/pawn-pack/equipment/Under/gloves_guarded_leather.glb index ae8bbf3c..889da6d7 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/gloves_guarded_leather.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/gloves_guarded_leather.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cdbef349a980b4bd587a9e0b65e72f0edae53b2f7f7136efa2207eb4c1ecd7cf -size 73812 +oid sha256:b30715b22e2931cb8f22eb060c12f17ee39311b4d94729ec46cdae8c2bc2dbf4 +size 75172 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/gloves_knuckled_half.glb b/client-3d/public/assets/pawn-pack/equipment/Under/gloves_knuckled_half.glb index 019b2231..deeebc03 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/gloves_knuckled_half.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/gloves_knuckled_half.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ed6e75b3d437f8245c46836428450b23b931ccf9634f2ddb9284be33499f691 -size 42052 +oid sha256:fd60ddaf6f51b96f820b394460c4afed179f90a5143f0d7b0e68a8d2e1fc4437 +size 43396 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/gloves_light_utility.glb b/client-3d/public/assets/pawn-pack/equipment/Under/gloves_light_utility.glb index cbbb38e0..cad6be7a 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/gloves_light_utility.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/gloves_light_utility.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b115fa0e9e2a701fec68a483dfac0f3f1bae65e01d78b04908aace36ea31ed00 -size 55436 +oid sha256:1d266af174b0dcae56cfafef9a11901b4cf233496474ef6e6877d9b2eef13218 +size 56624 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/gloves_plain_work.glb b/client-3d/public/assets/pawn-pack/equipment/Under/gloves_plain_work.glb index 7462024b..c596d49c 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/gloves_plain_work.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/gloves_plain_work.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7e8d737a267c626d8be0ea3e0ffa4baabb651065987b0ca8020bfbbc8c3abab8 -size 45332 +oid sha256:16db1c2c83a7965a179fbebe1ccf36d7dbac02a83bc55ee41bfa0e8cc6cf89b9 +size 46360 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/gloves_plated_handwraps.glb b/client-3d/public/assets/pawn-pack/equipment/Under/gloves_plated_handwraps.glb index c4147c2d..3722542f 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/gloves_plated_handwraps.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/gloves_plated_handwraps.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:172905566d7f05b6ab75e33f39d094eb3ebbfd81cd092e8106b1360e401735e4 -size 57916 +oid sha256:64cce264f147acfc00f79de986176a811898ce7c4e018b30ee0fe19751cd4fff +size 59280 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/gloves_reinforced_knit.glb b/client-3d/public/assets/pawn-pack/equipment/Under/gloves_reinforced_knit.glb index 070482df..eaf0dea8 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/gloves_reinforced_knit.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/gloves_reinforced_knit.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3e106066d5843ee33818c16450d6209bf6007b3173fe290f4e5fbb4a7abb25ca -size 47352 +oid sha256:bbf327d07de9d39024964404a3f03c95c18baaa02c350f1aecf82b91bc40dd38 +size 49012 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/gloves_tipped_work.glb b/client-3d/public/assets/pawn-pack/equipment/Under/gloves_tipped_work.glb index 6a010ce4..5742d159 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/gloves_tipped_work.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/gloves_tipped_work.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c0c2c3b2f5bd574ddb824775dbe50c3ee9c4a31c5305e894c33ea0e0ce194487 -size 75752 +oid sha256:042a2c177295afce9d1ae322a54bfc2567846975013840adf69bd66e61e2a803 +size 76788 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_banded_mohawk.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_banded_mohawk.glb index dc8a5bab..b781ea98 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_banded_mohawk.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_banded_mohawk.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:77fa5bc24aa4088633401d8e87d0fda6441eb038838b89af20cb1cccc8dcf1d6 -size 69908 +oid sha256:27cdd4fe31b15360a8e69ba60748fc69c9eb3d5159af09303b4530dcb5b8e22a +size 70284 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_braided_crown.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_braided_crown.glb index 40493823..ff4f96e5 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_braided_crown.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_braided_crown.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e3fd895b8dbcb2597e993597f05a57d0157d201a733748f9975c3b78e4674be4 -size 229080 +oid sha256:c5cff7249e4862ce276fb4d68f639de36bf815f65b30741f58cfa7a4c6059445 +size 229464 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_cropped_buzz.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_cropped_buzz.glb index e0f9df80..ba7e0511 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_cropped_buzz.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_cropped_buzz.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:195315a136eb21f6186dfd4ede2c715b8768aaa4ec8832c7a85e42ebe765f212 -size 87364 +oid sha256:23743304b951906298f41082d1ac8dce5405fc9c1ad76c51e57ce8896a6fee73 +size 87740 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_fauxhawk.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_fauxhawk.glb index 31fcc22f..f2add2f6 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_fauxhawk.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_fauxhawk.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1cf9bfa3d96fb1e0cc0b4a6761963039b8a143ecab3b024d61de3adc2709ee16 -size 94868 +oid sha256:e031817cc6af60e25a4905591c402be73702a928e2feae14d6c281138208481e +size 95248 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_flared_shag.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_flared_shag.glb index 625a4060..f7fca9b9 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_flared_shag.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_flared_shag.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a537fca5d4bb898a79a065d2d3a38bd91538f9e459fe06f8c7ed03c244a472ae -size 90788 +oid sha256:f0ead5fa46a311342a529388bb2c76e7cc60fd9a7b4967e655bd3a39fbbf0d2d +size 91004 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_flared_wings.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_flared_wings.glb index eb65677e..3222b0a8 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_flared_wings.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_flared_wings.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6562615cf7b1d211484d27e5d84a627bab4915b89ad73caa4cb86c35763afb07 -size 55924 +oid sha256:6fd705c4faec1125f39aca1352780bb0c5102187db811f02d387ec7987ce5f52 +size 56140 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_flat_top.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_flat_top.glb index 5e20397e..c25a2009 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_flat_top.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_flat_top.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4c635041305009ebdf760749c0e3c89617642c937c61544133466057fca68b50 -size 90764 +oid sha256:ff7599cd17bcb2cb903ce3703b37ffaa62f1a8099cb052d267dff481904d1ed5 +size 90980 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_low_tufts.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_low_tufts.glb index 7b772ef0..18dbce61 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_low_tufts.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_low_tufts.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2f2ec9c5b9fc2916d26b0ab494e85ad7e68d4199140d01089536e088176c17a -size 67724 +oid sha256:4d0bb206001e0556edf3a06e2260621678cce7e2fccf3c68712dfceb6822a381 +size 67944 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_messy_crop.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_messy_crop.glb index 2c24ab34..be48094a 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_messy_crop.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_messy_crop.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bbbb56a57609b38eeb7ef6f0cc49e1869d32e077ec92ddfbfeb02079054526fd -size 77228 +oid sha256:6b0fb5805244ab504dae872de50f22632a8ff2db3fb494019236dec6e06e5669 +size 77440 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_messy_side_part.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_messy_side_part.glb index 2d84740a..643ce547 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_messy_side_part.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_messy_side_part.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9a01587de17bcf92a315c4f5a4b2770ad1eed8f57fc0ff5a4502a12edf7f15cb -size 103124 +oid sha256:5851f781fdd308a35db26f734eaf76e858c5ef705123101e709a90717bff3b06 +size 103500 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_ridge_mohawk.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_ridge_mohawk.glb index 746c4f99..12b5ab77 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_ridge_mohawk.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_ridge_mohawk.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5aa872cdbdc556f3c7578966b7cbb0366de9f5d62ff4f104570d254f8a8f4fe4 -size 28076 +oid sha256:c125ceb5895d5f4d530aaea7efc8b5149e99bfdc228f4c70729531a7ebd35cee +size 28444 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_short_ponytail.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_short_ponytail.glb index 5eb82134..1d4a19be 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_short_ponytail.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_short_ponytail.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:42c96c4c7a4499c14d33454f217e6362dfe11dc1d5eb0d5a81da8c81decc3b53 -size 115768 +oid sha256:b2ee66523c98b592b2fe260aadb4c1acbe4db233d55f3a17d9c2912c4413f2ed +size 116144 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_shoulder_shag.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_shoulder_shag.glb index 1a3e0a34..6036f444 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_shoulder_shag.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_shoulder_shag.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7865ea57918ba3bdcbf420bad2cd1c4b33a0a85ca774e1dd44b197e4b2f4cc55 -size 99560 +oid sha256:1b2dd243ea74319a89e2f7ab5c9164a817eba82406ba63d4de1001bd377ff768 +size 99936 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_side_ponytail.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_side_ponytail.glb index 076b7515..f79ff307 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_side_ponytail.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_side_ponytail.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9a50da68be8f7bf54e0456cefbea93359cb0f08bce876927ec5bf0b92fbad24d -size 151648 +oid sha256:fb67f9ff6a87730b86952b24505eda736fe727af8dab5b2e760c2749abd6554c +size 152532 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_side_sweep.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_side_sweep.glb index 6401d4e2..34cb28d4 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_side_sweep.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_side_sweep.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9594aed5534cea94f86d58740a166c1e2f54f3fce5047657c572edcf460cb461 -size 74184 +oid sha256:70ec019b681390bb120761199d6f68d89227f5983b6df86ef3581f32f79434b2 +size 74400 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_slicked_crop.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_slicked_crop.glb index d113d607..f0d296a4 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_slicked_crop.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_slicked_crop.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e102bedd662f6e8934ad28d96e57e5a0dacf22dfdc2adb94a7b44e5fc3e47ec3 -size 59544 +oid sha256:74f11589238a01d98426fb978cd9c94dc703cff3aadfeb77f40aba483f4f3330 +size 59760 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_spiked_mohawk.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_spiked_mohawk.glb index 56b7da53..4332e4f3 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_spiked_mohawk.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_spiked_mohawk.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5a9ab2659875bf090774876b9d33596323d3bae2fd4cf77890260485e830dbfe -size 96956 +oid sha256:2e399dc712bd179d5de5de8ae95c8e25f4bf7e6ffe98d3124bac20f86799bb93 +size 97172 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_spiked_topknot.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_spiked_topknot.glb index 1a4df97c..303453cc 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_spiked_topknot.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_spiked_topknot.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8417ae03d48154486d3a59d12a4a9e3cb27362b06c4677fbe3764b5a5a30d140 -size 80644 +oid sha256:77665c29ba83f0e31a712c690a4f2517bc279903e068a8e6989dac1d06074ce6 +size 80860 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_split_mohawk.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_split_mohawk.glb index b61b1c1b..07c20d29 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_split_mohawk.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_split_mohawk.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7c947fa126b1292deeb918f6120a34e5413b045f9c7241a783cea9bae8ba149f -size 105380 +oid sha256:a9c25fa690062f4997de172ba48ac0f7343335d4aca9cfd78ee5e83251fbb8df +size 105764 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_swept_bob.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_swept_bob.glb index 23d0c425..0791664a 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_swept_bob.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_swept_bob.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:718881953f946bb30fef671b2d5a6930cd661b6f4576842637c3e7ac855bdca7 -size 90652 +oid sha256:f0dbe1af613a6a79309e8171bd8bc751acba3217a4fe5bffe31f247c65880e0a +size 91032 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_swept_spikes.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_swept_spikes.glb index f8faea4a..45315c09 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_swept_spikes.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_swept_spikes.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d8fc059ae40380c7fef018e1802bc516dab26de27ba35b94ae02bae59838ed58 -size 87072 +oid sha256:18ffb36ec2782a328b0bc15bc364c93d522b7a18fd2f9cb193e107da72376b32 +size 87292 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_textured_crop.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_textured_crop.glb index 2750a8b9..42008789 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_textured_crop.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_textured_crop.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:df2c49a8d52d345fbd5e02d63343e4d9901087e75c4837049b5e9b14069ac880 -size 89120 +oid sha256:c1171d17bdef052ae4163f04e1104aaa03b787dd5a95061a9692963e796f0c04 +size 89336 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_top_patch.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_top_patch.glb index 33f89a6b..850cea2f 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_top_patch.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_top_patch.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3ad4ddf9a297c58559d48ea020278813ff8658a416e8601f3c692c425ea2c523 -size 47364 +oid sha256:1c92bc6ab7eeb0315368b21a9865c9cf77d9f42c44b08288d54a0795fa8d95dc +size 47576 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/hair_twin_pigtails.glb b/client-3d/public/assets/pawn-pack/equipment/Under/hair_twin_pigtails.glb index 9d774ed9..e3512395 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/hair_twin_pigtails.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/hair_twin_pigtails.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a63d3bc4e480042d246e686889b6b36d1569f59541018a67d956ace17f8aacfd -size 136068 +oid sha256:e86e8877ac0af6f1e17a10426c4c6a0a83ecfaa09bafce4d6b8bbe2abd7e1266 +size 136452 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/legs_gaitered_cargo_pants.glb b/client-3d/public/assets/pawn-pack/equipment/Under/legs_gaitered_cargo_pants.glb index 57347185..577b8432 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/legs_gaitered_cargo_pants.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/legs_gaitered_cargo_pants.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:72c0f31bb8c8cff7655a1344a8ebfe542b15ee0ac2ca14de0f330c36d745cefa -size 196776 +oid sha256:f97e97f3f95fc02eef6c3b3ca4a273e84aee6de516a6c9cd640710ecd1e88008 +size 199300 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/legs_layered_shorts.glb b/client-3d/public/assets/pawn-pack/equipment/Under/legs_layered_shorts.glb index 45693b7a..b598a9e8 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/legs_layered_shorts.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/legs_layered_shorts.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a24bc77d6df37da9c083734c374e413e54df8b6197313eed594884494eef912b -size 224576 +oid sha256:cbb79c60671cec8b20a337edc3622390b2e1376d9b6d1e94e5d4e294f424a29e +size 226952 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/legs_layered_wrap_skirt.glb b/client-3d/public/assets/pawn-pack/equipment/Under/legs_layered_wrap_skirt.glb index 5977ba95..0ce2ec5d 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/legs_layered_wrap_skirt.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/legs_layered_wrap_skirt.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:18eda0142d283a889a9f1b5e8a0a646600d925b94be7234cdecbca223dfd6372 -size 182212 +oid sha256:2cc84068c65fc832e3d61a7129b69667f431a80163527eef79272d707fd4f41e +size 184420 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/legs_padded_canvas_trousers.glb b/client-3d/public/assets/pawn-pack/equipment/Under/legs_padded_canvas_trousers.glb index 3fe65d76..6c83c54e 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/legs_padded_canvas_trousers.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/legs_padded_canvas_trousers.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c571859921b586bd5042fee88cf8d4d39b139d35663673cb2c42041ee7e9d7e3 -size 179200 +oid sha256:cc436e9e7b780968b42efcd795be8be0ad5608efba3d91535425ee8c67be130f +size 180924 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/legs_plated_trousers.glb b/client-3d/public/assets/pawn-pack/equipment/Under/legs_plated_trousers.glb index 5d723159..9fd62e15 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/legs_plated_trousers.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/legs_plated_trousers.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eddbabbe2b0bd9627b4be050ceaf13b2cd6f0943817dd1e0b56709e2ea978e81 -size 170648 +oid sha256:1ea63718f63259d93d297e35116d265e9057d12b7a44d57d370f67605495c67a +size 172516 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/legs_reinforced_denim_pants.glb b/client-3d/public/assets/pawn-pack/equipment/Under/legs_reinforced_denim_pants.glb index 78163a21..000d07d5 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/legs_reinforced_denim_pants.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/legs_reinforced_denim_pants.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4d464faa1a3677fea6ab92efa7ce8116ff8a20c703dc882d89f922f1de5049c3 -size 196472 +oid sha256:ae56e4e4910aefb89878fc4142d60b7e87cee3f0cd894f575185fe36bb6693ee +size 199004 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/legs_sashed_patrol_pants.glb b/client-3d/public/assets/pawn-pack/equipment/Under/legs_sashed_patrol_pants.glb index 442f2fc8..6948f4c8 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/legs_sashed_patrol_pants.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/legs_sashed_patrol_pants.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bf6d4ce3e473b3aebadbd76b8d3f488f84d48fa82e591fe716940b8332d2cded -size 199564 +oid sha256:467f2a7cf1bfc2b57929ff5b453cd5f4ddf0b6d0abedbd0a66c5fe04c2cbabe8 +size 202104 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/legs_skirted_workpants.glb b/client-3d/public/assets/pawn-pack/equipment/Under/legs_skirted_workpants.glb index 2e275fdb..df431363 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/legs_skirted_workpants.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/legs_skirted_workpants.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:241e35a84f6590bd81ab8cae28d1ca4b1f2232894576d763a171e021fa59d68a -size 181952 +oid sha256:24371513c72469daa5574d4218339110e70a35e1199296c7d963f8c30680e6e1 +size 184652 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/legs_strapped_trousers.glb b/client-3d/public/assets/pawn-pack/equipment/Under/legs_strapped_trousers.glb index 8c43f439..8e818304 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/legs_strapped_trousers.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/legs_strapped_trousers.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2f329df2c336712b2d7097f9508b1eebae3f571bc8204c5a5682397579296bb8 -size 220304 +oid sha256:414043cda6c0231c9ab4056860d207ee5d600dc3fc05772947329acc08bb507b +size 222368 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/legs_wrapped_workpants.glb b/client-3d/public/assets/pawn-pack/equipment/Under/legs_wrapped_workpants.glb index 859f4bc3..c4849f59 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/legs_wrapped_workpants.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/legs_wrapped_workpants.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c984bd9c04681f4e2b6f29f9ff21caccc026fb78e582cc4c1c5651f111ae5285 -size 188872 +oid sha256:5e257e96e0c0738222f91f9cda2124c22bf0e0ee1a76ad378a5175bc9f99ce67 +size 190916 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/top_frayed_tunic.glb b/client-3d/public/assets/pawn-pack/equipment/Under/top_frayed_tunic.glb index fdfca0ed..c182b19b 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/top_frayed_tunic.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/top_frayed_tunic.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d702b136584cbaef4ce9ca4864a921f4e83d2360258eb968a2c215f1bca1073f -size 135404 +oid sha256:089528b03c801a6ee56e286909e983b9f6c13c1517ed90a3ce727bd01f18c0fb +size 135784 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/top_laced_corset_vest.glb b/client-3d/public/assets/pawn-pack/equipment/Under/top_laced_corset_vest.glb index 5afe0d40..c97f88d1 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/top_laced_corset_vest.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/top_laced_corset_vest.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:298366a81ef8284daa76cd5c0f3258f212f5737a7cd143b3059f07c9c2ef627f -size 142048 +oid sha256:789bad6e14d8fba7e9a1a1dd81c68b3d0c9caebd532b15e6d18849dbb1380967 +size 142932 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/top_padded_leather_vest.glb b/client-3d/public/assets/pawn-pack/equipment/Under/top_padded_leather_vest.glb index eabc5d15..60764533 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/top_padded_leather_vest.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/top_padded_leather_vest.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c95c5e0f4334551ff5608d2fb772e6ee1bee4cae2cda597fb9f69d9603cccf75 -size 133908 +oid sha256:e7e7739eff8497da89917d0242301ecd57ce3de90dd0fcae7042dbbd80e7482f +size 134780 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/top_plated_rig_vest.glb b/client-3d/public/assets/pawn-pack/equipment/Under/top_plated_rig_vest.glb index 383d7fe8..894f6a87 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/top_plated_rig_vest.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/top_plated_rig_vest.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb36685c30ac86f34c941cffe7f618c109a285945d7a28c5f9f8be9617109832 -size 132204 +oid sha256:9f3d4e246a335d589657fd8b5fb907c7e1f6c7e22146a20f4c889acdaceab988 +size 133088 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/top_reinforced_crop_vest.glb b/client-3d/public/assets/pawn-pack/equipment/Under/top_reinforced_crop_vest.glb index 3b78268e..3889988c 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/top_reinforced_crop_vest.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/top_reinforced_crop_vest.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a7871edd26fef18d4c552c1bccf5ec4ebfa10495f27adf2ebd3cbb3fe61010e9 -size 91336 +oid sha256:963eaf7a8c3aa9770a6fed7bc0a7209c8b0bc4b67348b78cb5843b7034eaec07 +size 92040 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/top_rigged_tank.glb b/client-3d/public/assets/pawn-pack/equipment/Under/top_rigged_tank.glb index 279a3687..4f586763 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/top_rigged_tank.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/top_rigged_tank.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c5e69f50d480631960b682f961306731309db19ccc3264fb0f389df8a9c4c48a -size 99512 +oid sha256:f77061b829a38c539bd40e1029aa1e0fcac3327d0f43962974e4b7bca5f6c2a5 +size 100380 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/top_scrap_plate_tunic.glb b/client-3d/public/assets/pawn-pack/equipment/Under/top_scrap_plate_tunic.glb index c63e6e47..2dd645b7 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/top_scrap_plate_tunic.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/top_scrap_plate_tunic.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dce7327976b0fdd39effc33978ff341cc83b4970fdd14a29753c6f017946aaef -size 130560 +oid sha256:846ee6fcbe24c2dc78087759c2e2486ee956149a4c408be8aabdd950ff335488 +size 131444 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/top_spiked_leather_vest.glb b/client-3d/public/assets/pawn-pack/equipment/Under/top_spiked_leather_vest.glb index 295f3273..b00962b3 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/top_spiked_leather_vest.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/top_spiked_leather_vest.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fb048dc96984f8d27f3d310c140477539ccf556c215dad3898be427de0363b9a -size 122172 +oid sha256:d08c1a6a21f5793c4929c775677a98cb2b892c36192c8069a238fdd52e8cfc4b +size 123044 diff --git a/client-3d/public/assets/pawn-pack/equipment/Under/under_bodysuit.glb b/client-3d/public/assets/pawn-pack/equipment/Under/under_bodysuit.glb index 8d7e3764..60a557da 100644 --- a/client-3d/public/assets/pawn-pack/equipment/Under/under_bodysuit.glb +++ b/client-3d/public/assets/pawn-pack/equipment/Under/under_bodysuit.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6e556aba4224c2a03a3414e7c5d01604c81bcebcf0c2816493522a8c0e456eb2 -size 850340 +oid sha256:24e67fe6ab502546f58a07cb9be24e87880cb2d341f4e2c56488eee37bde2d0b +size 58648 diff --git a/client-3d/public/assets/pawn-pack/equipment/manifest.json b/client-3d/public/assets/pawn-pack/equipment/manifest.json index 1cc7d974..a54a539f 100644 --- a/client-3d/public/assets/pawn-pack/equipment/manifest.json +++ b/client-3d/public/assets/pawn-pack/equipment/manifest.json @@ -36,6 +36,7 @@ "id": "armor_harness", "slot": "armor_harness", "glb": "Armor/Harness.glb", + "glbFemale": "Female/Armor/Harness.glb", "meshes": [ "harness" ] @@ -47,6 +48,7 @@ "id": "armor_nape_reinforcement", "slot": "armor_nape_reinforcement", "glb": "Armor/Nape_Reinforcement.glb", + "glbFemale": "Female/Armor/Nape_Reinforcement.glb", "meshes": [ "backtop" ], @@ -61,6 +63,7 @@ "id": "armor_reinforcement", "slot": "armor_reinforcement", "glb": "Armor/Reinforcement.glb", + "glbFemale": "Female/Armor/Reinforcement.glb", "meshes": [ "bp" ], @@ -75,6 +78,7 @@ "id": "armor_gorget", "slot": "armor_gorget", "glb": "Armor/Gorget.glb", + "glbFemale": "Female/Armor/Gorget.glb", "meshes": [ "collar_l", "collar_r", @@ -92,6 +96,7 @@ "id": "armor_bicep_l", "slot": "armor_bicep_l", "glb": "Armor/Bicep_L.glb", + "glbFemale": "Female/Armor/Bicep_L.glb", "meshes": [ "shoulderpad_l", "uparm_l" @@ -104,6 +109,7 @@ "id": "armor_bicep_r", "slot": "armor_bicep_r", "glb": "Armor/Bicep_R.glb", + "glbFemale": "Female/Armor/Bicep_R.glb", "meshes": [ "shoulderpad_r", "uparm_r" @@ -116,6 +122,7 @@ "id": "helmet_a", "slot": "cranium", "glb": "Armor/Helmet_A.glb", + "glbFemale": "Female/Armor/Helmet_A.glb", "meshes": [ "helmet" ] @@ -127,6 +134,7 @@ "id": "helmet_b", "slot": "cranium", "glb": "Armor/Helmet_B.glb", + "glbFemale": "Female/Armor/Helmet_B.glb", "meshes": [ "helmet" ] @@ -138,6 +146,7 @@ "id": "helmet_c", "slot": "cranium", "glb": "Armor/Helmet_C.glb", + "glbFemale": "Female/Armor/Helmet_C.glb", "meshes": [ "helmet" ] @@ -149,6 +158,7 @@ "id": "helmet_d", "slot": "cranium", "glb": "Armor/Helmet_D.glb", + "glbFemale": "Female/Armor/Helmet_D.glb", "meshes": [ "helmet" ] @@ -160,6 +170,7 @@ "id": "helmet_v1port", "slot": "cranium", "glb": "Armor/Helmet_V1PORT.glb", + "glbFemale": "Female/Armor/Helmet_V1PORT.glb", "meshes": [ "helmet" ] @@ -171,6 +182,7 @@ "id": "helmet_s1", "slot": "cranium", "glb": "Armor/Helmet_S1.glb", + "glbFemale": "Female/Armor/Helmet_S1.glb", "meshes": [ "helmet" ] @@ -182,6 +194,7 @@ "id": "helmet_s2", "slot": "cranium", "glb": "Armor/Helmet_S2.glb", + "glbFemale": "Female/Armor/Helmet_S2.glb", "meshes": [ "helmet" ] @@ -193,6 +206,7 @@ "id": "helmet_s3", "slot": "cranium", "glb": "Armor/Helmet_S3.glb", + "glbFemale": "Female/Armor/Helmet_S3.glb", "meshes": [ "helmet" ] @@ -205,6 +219,7 @@ "slot": "cranium", "mat": "wool_felt", "glb": "Under/Hat_Warm.glb", + "glbFemale": "Female/Under/Hat_Warm.glb", "meshes": [ "warmhat" ] @@ -226,6 +241,7 @@ "id": "under_tank", "slot": "under_torso", "glb": "Under/Tank.glb", + "glbFemale": "Female/Under/Tank.glb", "meshes": [ "tank" ] @@ -237,6 +253,7 @@ "id": "under_shorts", "slot": "under_legs", "glb": "Under/Shorts.glb", + "glbFemale": "Female/Under/Shorts.glb", "meshes": [ "shorts" ] @@ -249,6 +266,7 @@ "slot": "cranium", "mat": "hair_umber", "glb": "Under/Hair_Mop.glb", + "glbFemale": "Female/Under/Hair_Mop.glb", "meshes": [ "hair_mop_shell" ] @@ -261,6 +279,7 @@ "slot": "cranium", "mat": "hair_umber", "glb": "Under/Hair_Afro.glb", + "glbFemale": "Female/Under/Hair_Afro.glb", "meshes": [ "hair_afro2" ] @@ -273,6 +292,7 @@ "slot": "cranium", "mat": "hair_chestnut", "glb": "Under/Hair_Crop.glb", + "glbFemale": "Female/Under/Hair_Crop.glb", "meshes": [ "hair_crop2" ] @@ -285,6 +305,7 @@ "slot": "under_full", "mat": "cotton_jersey", "glb": "Under/under_bodysuit.glb", + "glbFemale": "Female/Under/under_bodysuit.glb", "meshes": [ "bodysuit" ], @@ -300,7 +321,19 @@ } ] }, - "note": "full-body under garment (neck->wrists->ankles); fixed-issue starter suit — occupies the whole under layer (under_full slot)" + "note": "full-body under garment (neck->wrists->ankles); fixed-issue starter suit — occupies the whole under layer (under_full slot)", + "hideBodyZones": [ + "torso", + "pelvis", + "left_upper_arm", + "right_upper_arm", + "left_forearm", + "right_forearm", + "left_thigh", + "right_thigh", + "left_calf", + "right_calf" + ] }, { "layer": "Armor", @@ -310,6 +343,7 @@ "slot": "cranium", "mat": "smooth_steel", "glb": "Armor/trial_helm_a.glb", + "glbFemale": "Female/Armor/trial_helm_a.glb", "meshes": [ "trial_helm_a" ], @@ -323,6 +357,7 @@ "slot": "cranium", "mat": "smooth_steel", "glb": "Armor/trial_helm_b.glb", + "glbFemale": "Female/Armor/trial_helm_b.glb", "meshes": [ "trial_helm_b" ], @@ -336,6 +371,7 @@ "slot": "cranium", "mat": "smooth_steel", "glb": "Armor/trial_helm_c.glb", + "glbFemale": "Female/Armor/trial_helm_c.glb", "meshes": [ "trial_helm_c" ], @@ -349,6 +385,7 @@ "slot": "cranium", "mat": "smooth_steel", "glb": "Armor/trial_helm_d.glb", + "glbFemale": "Female/Armor/trial_helm_d.glb", "meshes": [ "trial_helm_d" ], @@ -362,6 +399,7 @@ "slot": "cranium", "mat": "cotton_jersey", "glb": "Armor/trial_hood_a.glb", + "glbFemale": "Female/Armor/trial_hood_a.glb", "meshes": [ "trial_hood_a" ], @@ -375,6 +413,7 @@ "slot": "face", "mat": "smooth_steel", "glb": "Armor/trial_visor_a.glb", + "glbFemale": "Female/Armor/trial_visor_a.glb", "meshes": [ "trial_visor_a" ], @@ -388,6 +427,7 @@ "slot": "face", "mat": "smooth_steel", "glb": "Armor/trial_mask_b.glb", + "glbFemale": "Female/Armor/trial_mask_b.glb", "meshes": [ "trial_mask_b" ], @@ -401,6 +441,7 @@ "slot": "back", "mat": "smooth_steel", "glb": "Armor/trial_back_a.glb", + "glbFemale": "Female/Armor/trial_back_a.glb", "meshes": [ "trial_back_a" ], @@ -414,6 +455,7 @@ "slot": "back", "mat": "cotton_jersey", "glb": "Armor/trial_back_b.glb", + "glbFemale": "Female/Armor/trial_back_b.glb", "meshes": [ "trial_back_b" ], @@ -427,6 +469,7 @@ "slot": "hip", "mat": "smooth_steel", "glb": "Armor/trial_hip_front_a.glb", + "glbFemale": "Female/Armor/trial_hip_front_a.glb", "meshes": [ "trial_hip_front_a" ], @@ -440,6 +483,7 @@ "slot": "hip", "mat": "smooth_steel", "glb": "Armor/trial_hip_back_a.glb", + "glbFemale": "Female/Armor/trial_hip_back_a.glb", "meshes": [ "trial_hip_back_a" ], @@ -453,6 +497,7 @@ "slot": "hip", "mat": "smooth_steel", "glb": "Armor/trial_hip_side_a.glb", + "glbFemale": "Female/Armor/trial_hip_side_a.glb", "meshes": [ "trial_hip_side_a" ], @@ -466,6 +511,7 @@ "slot": "cranium", "mat": "hair_umber", "glb": "Armor/trial_hair_a.glb", + "glbFemale": "Female/Armor/trial_hair_a.glb", "meshes": [ "trial_hair_a" ], @@ -479,6 +525,7 @@ "slot": "cranium", "mat": "hair_chestnut", "glb": "Armor/trial_hair_b.glb", + "glbFemale": "Female/Armor/trial_hair_b.glb", "meshes": [ "trial_hair_b" ], @@ -492,6 +539,7 @@ "slot": "cranium", "mat": "hair_umber", "glb": "Armor/trial_hair_c.glb", + "glbFemale": "Female/Armor/trial_hair_c.glb", "meshes": [ "trial_hair_c" ], @@ -505,6 +553,7 @@ "slot": "cranium", "mat": "hair_chestnut", "glb": "Armor/trial_hair_d.glb", + "glbFemale": "Female/Armor/trial_hair_d.glb", "meshes": [ "trial_hair_d" ], @@ -517,6 +566,7 @@ "id": "under_scfi_top", "slot": "under_torso", "glb": "Under/SCFI_Top_Refit.glb", + "glbFemale": "Female/Under/SCFI_Top_Refit.glb", "meshes": [ "SK_SCFI_CIVL_01_10TORS_HU01.fbx__Mesh", "SK_SCFI_CIVL_01_11AUPL_HU01.fbx__Mesh", @@ -534,6 +584,7 @@ "id": "under_scfi_pants", "slot": "under_legs", "glb": "Under/SCFI_Pants_Refit.glb", + "glbFemale": "Female/Under/SCFI_Pants_Refit.glb", "meshes": [ "SK_SCFI_CIVL_01_17HIPS_HU01.fbx__Mesh", "SK_SCFI_CIVL_01_18LEGL_HU01.fbx__Mesh", @@ -549,6 +600,7 @@ "id": "top_rigged_tank", "slot": "under_torso", "glb": "Under/top_rigged_tank.glb", + "glbFemale": "Female/Under/top_rigged_tank.glb", "meshes": [], "palette": { "zones": [ @@ -578,6 +630,7 @@ "id": "top_frayed_tunic", "slot": "under_torso", "glb": "Under/top_frayed_tunic.glb", + "glbFemale": "Female/Under/top_frayed_tunic.glb", "meshes": [], "palette": { "zones": [ @@ -599,6 +652,7 @@ "id": "top_plated_rig_vest", "slot": "under_torso", "glb": "Under/top_plated_rig_vest.glb", + "glbFemale": "Female/Under/top_plated_rig_vest.glb", "meshes": [], "palette": { "zones": [ @@ -636,6 +690,7 @@ "id": "top_padded_leather_vest", "slot": "under_torso", "glb": "Under/top_padded_leather_vest.glb", + "glbFemale": "Female/Under/top_padded_leather_vest.glb", "meshes": [], "palette": { "zones": [ @@ -665,6 +720,7 @@ "id": "top_spiked_leather_vest", "slot": "under_torso", "glb": "Under/top_spiked_leather_vest.glb", + "glbFemale": "Female/Under/top_spiked_leather_vest.glb", "meshes": [], "palette": { "zones": [ @@ -694,6 +750,7 @@ "id": "top_reinforced_crop_vest", "slot": "under_torso", "glb": "Under/top_reinforced_crop_vest.glb", + "glbFemale": "Female/Under/top_reinforced_crop_vest.glb", "meshes": [], "palette": { "zones": [ @@ -731,6 +788,7 @@ "id": "top_scrap_plate_tunic", "slot": "under_torso", "glb": "Under/top_scrap_plate_tunic.glb", + "glbFemale": "Female/Under/top_scrap_plate_tunic.glb", "meshes": [], "palette": { "zones": [ @@ -768,6 +826,7 @@ "id": "top_laced_corset_vest", "slot": "under_torso", "glb": "Under/top_laced_corset_vest.glb", + "glbFemale": "Female/Under/top_laced_corset_vest.glb", "meshes": [], "palette": { "zones": [ @@ -805,6 +864,7 @@ "id": "legs_wrapped_workpants", "slot": "under_legs", "glb": "Under/legs_wrapped_workpants.glb", + "glbFemale": "Female/Under/legs_wrapped_workpants.glb", "meshes": [], "palette": { "zones": [ @@ -842,6 +902,7 @@ "id": "legs_reinforced_denim_pants", "slot": "under_legs", "glb": "Under/legs_reinforced_denim_pants.glb", + "glbFemale": "Female/Under/legs_reinforced_denim_pants.glb", "meshes": [], "palette": { "zones": [ @@ -871,6 +932,7 @@ "id": "legs_plated_trousers", "slot": "under_legs", "glb": "Under/legs_plated_trousers.glb", + "glbFemale": "Female/Under/legs_plated_trousers.glb", "meshes": [], "palette": { "zones": [ @@ -900,6 +962,7 @@ "id": "legs_layered_shorts", "slot": "under_legs", "glb": "Under/legs_layered_shorts.glb", + "glbFemale": "Female/Under/legs_layered_shorts.glb", "meshes": [], "palette": { "zones": [ @@ -938,6 +1001,7 @@ "id": "legs_strapped_trousers", "slot": "under_legs", "glb": "Under/legs_strapped_trousers.glb", + "glbFemale": "Female/Under/legs_strapped_trousers.glb", "meshes": [], "palette": { "zones": [ @@ -975,6 +1039,7 @@ "id": "legs_skirted_workpants", "slot": "under_legs", "glb": "Under/legs_skirted_workpants.glb", + "glbFemale": "Female/Under/legs_skirted_workpants.glb", "meshes": [], "palette": { "zones": [ @@ -1012,6 +1077,7 @@ "id": "legs_gaitered_cargo_pants", "slot": "under_legs", "glb": "Under/legs_gaitered_cargo_pants.glb", + "glbFemale": "Female/Under/legs_gaitered_cargo_pants.glb", "meshes": [], "palette": { "zones": [ @@ -1041,6 +1107,7 @@ "id": "legs_padded_canvas_trousers", "slot": "under_legs", "glb": "Under/legs_padded_canvas_trousers.glb", + "glbFemale": "Female/Under/legs_padded_canvas_trousers.glb", "meshes": [], "palette": { "zones": [ @@ -1070,6 +1137,7 @@ "id": "legs_sashed_patrol_pants", "slot": "under_legs", "glb": "Under/legs_sashed_patrol_pants.glb", + "glbFemale": "Female/Under/legs_sashed_patrol_pants.glb", "meshes": [], "palette": { "zones": [ @@ -1107,6 +1175,7 @@ "id": "legs_layered_wrap_skirt", "slot": "under_legs", "glb": "Under/legs_layered_wrap_skirt.glb", + "glbFemale": "Female/Under/legs_layered_wrap_skirt.glb", "meshes": [], "palette": { "zones": [ @@ -1144,6 +1213,7 @@ "id": "boots_canvas_ankle", "slot": "under_feet", "glb": "Under/boots_canvas_ankle.glb", + "glbFemale": "Female/Under/boots_canvas_ankle.glb", "meshes": [], "palette": { "zones": [ @@ -1164,7 +1234,11 @@ "default": "#808080" } ] - } + }, + "hideBodyZones": [ + "left_foot", + "right_foot" + ] }, { "layer": "Under", @@ -1173,6 +1247,7 @@ "id": "boots_layered_sneakers", "slot": "under_feet", "glb": "Under/boots_layered_sneakers.glb", + "glbFemale": "Female/Under/boots_layered_sneakers.glb", "meshes": [], "palette": { "zones": [ @@ -1202,6 +1277,7 @@ "id": "boots_split_toe", "slot": "under_feet", "glb": "Under/boots_split_toe.glb", + "glbFemale": "Female/Under/boots_split_toe.glb", "meshes": [], "palette": { "zones": [ @@ -1231,6 +1307,7 @@ "id": "boots_trail_shoes", "slot": "under_feet", "glb": "Under/boots_trail_shoes.glb", + "glbFemale": "Female/Under/boots_trail_shoes.glb", "meshes": [], "palette": { "zones": [ @@ -1260,6 +1337,7 @@ "id": "boots_double_strap", "slot": "under_feet", "glb": "Under/boots_double_strap.glb", + "glbFemale": "Female/Under/boots_double_strap.glb", "meshes": [], "palette": { "zones": [ @@ -1289,6 +1367,7 @@ "id": "boots_rubberized_canvas", "slot": "under_feet", "glb": "Under/boots_rubberized_canvas.glb", + "glbFemale": "Female/Under/boots_rubberized_canvas.glb", "meshes": [], "palette": { "zones": [ @@ -1318,6 +1397,7 @@ "id": "boots_toe_capped", "slot": "under_feet", "glb": "Under/boots_toe_capped.glb", + "glbFemale": "Female/Under/boots_toe_capped.glb", "meshes": [], "palette": { "zones": [ @@ -1339,6 +1419,7 @@ "id": "boots_combat_sneakers", "slot": "under_feet", "glb": "Under/boots_combat_sneakers.glb", + "glbFemale": "Female/Under/boots_combat_sneakers.glb", "meshes": [], "palette": { "zones": [ @@ -1368,6 +1449,7 @@ "id": "boots_cuffed_runners", "slot": "under_feet", "glb": "Under/boots_cuffed_runners.glb", + "glbFemale": "Female/Under/boots_cuffed_runners.glb", "meshes": [], "palette": { "zones": [ @@ -1397,6 +1479,7 @@ "id": "boots_reinforced_street", "slot": "under_feet", "glb": "Under/boots_reinforced_street.glb", + "glbFemale": "Female/Under/boots_reinforced_street.glb", "meshes": [], "palette": { "zones": [ @@ -1426,6 +1509,7 @@ "id": "gloves_knuckled_half", "slot": "under_hands", "glb": "Under/gloves_knuckled_half.glb", + "glbFemale": "Female/Under/gloves_knuckled_half.glb", "meshes": [], "palette": { "zones": [ @@ -1455,6 +1539,7 @@ "id": "gloves_guarded_leather", "slot": "under_hands", "glb": "Under/gloves_guarded_leather.glb", + "glbFemale": "Female/Under/gloves_guarded_leather.glb", "meshes": [], "palette": { "zones": [ @@ -1484,6 +1569,7 @@ "id": "gloves_plain_work", "slot": "under_hands", "glb": "Under/gloves_plain_work.glb", + "glbFemale": "Female/Under/gloves_plain_work.glb", "meshes": [], "palette": { "zones": [ @@ -1505,6 +1591,7 @@ "id": "gloves_reinforced_knit", "slot": "under_hands", "glb": "Under/gloves_reinforced_knit.glb", + "glbFemale": "Female/Under/gloves_reinforced_knit.glb", "meshes": [], "palette": { "zones": [ @@ -1542,6 +1629,7 @@ "id": "gloves_light_utility", "slot": "under_hands", "glb": "Under/gloves_light_utility.glb", + "glbFemale": "Female/Under/gloves_light_utility.glb", "meshes": [], "palette": { "zones": [ @@ -1571,6 +1659,7 @@ "id": "gloves_tipped_work", "slot": "under_hands", "glb": "Under/gloves_tipped_work.glb", + "glbFemale": "Female/Under/gloves_tipped_work.glb", "meshes": [], "palette": { "zones": [ @@ -1600,6 +1689,7 @@ "id": "gloves_plated_handwraps", "slot": "under_hands", "glb": "Under/gloves_plated_handwraps.glb", + "glbFemale": "Female/Under/gloves_plated_handwraps.glb", "meshes": [], "palette": { "zones": [ @@ -1629,6 +1719,7 @@ "id": "hair_banded_mohawk", "slot": "cranium", "glb": "Under/hair_banded_mohawk.glb", + "glbFemale": "Female/Under/hair_banded_mohawk.glb", "meshes": [], "mat": "hair_umber" }, @@ -1639,6 +1730,7 @@ "id": "hair_spiked_topknot", "slot": "cranium", "glb": "Under/hair_spiked_topknot.glb", + "glbFemale": "Female/Under/hair_spiked_topknot.glb", "meshes": [], "mat": "hair_umber" }, @@ -1649,6 +1741,7 @@ "id": "hair_twin_pigtails", "slot": "cranium", "glb": "Under/hair_twin_pigtails.glb", + "glbFemale": "Female/Under/hair_twin_pigtails.glb", "meshes": [], "mat": "hair_umber" }, @@ -1659,6 +1752,7 @@ "id": "hair_swept_spikes", "slot": "cranium", "glb": "Under/hair_swept_spikes.glb", + "glbFemale": "Female/Under/hair_swept_spikes.glb", "meshes": [], "mat": "hair_umber" }, @@ -1669,6 +1763,7 @@ "id": "hair_short_ponytail", "slot": "cranium", "glb": "Under/hair_short_ponytail.glb", + "glbFemale": "Female/Under/hair_short_ponytail.glb", "meshes": [], "mat": "hair_umber" }, @@ -1679,6 +1774,7 @@ "id": "hair_spiked_mohawk", "slot": "cranium", "glb": "Under/hair_spiked_mohawk.glb", + "glbFemale": "Female/Under/hair_spiked_mohawk.glb", "meshes": [], "mat": "hair_umber" }, @@ -1689,6 +1785,7 @@ "id": "hair_flared_wings", "slot": "cranium", "glb": "Under/hair_flared_wings.glb", + "glbFemale": "Female/Under/hair_flared_wings.glb", "meshes": [], "mat": "hair_umber" }, @@ -1699,6 +1796,7 @@ "id": "hair_low_tufts", "slot": "cranium", "glb": "Under/hair_low_tufts.glb", + "glbFemale": "Female/Under/hair_low_tufts.glb", "meshes": [], "mat": "hair_umber" }, @@ -1709,6 +1807,7 @@ "id": "hair_swept_bob", "slot": "cranium", "glb": "Under/hair_swept_bob.glb", + "glbFemale": "Female/Under/hair_swept_bob.glb", "meshes": [], "mat": "hair_umber" }, @@ -1719,6 +1818,7 @@ "id": "hair_braided_crown", "slot": "cranium", "glb": "Under/hair_braided_crown.glb", + "glbFemale": "Female/Under/hair_braided_crown.glb", "meshes": [], "mat": "hair_umber" }, @@ -1729,6 +1829,7 @@ "id": "hair_ridge_mohawk", "slot": "cranium", "glb": "Under/hair_ridge_mohawk.glb", + "glbFemale": "Female/Under/hair_ridge_mohawk.glb", "meshes": [], "mat": "hair_umber" }, @@ -1739,6 +1840,7 @@ "id": "hair_side_ponytail", "slot": "cranium", "glb": "Under/hair_side_ponytail.glb", + "glbFemale": "Female/Under/hair_side_ponytail.glb", "meshes": [], "mat": "hair_umber" }, @@ -1749,6 +1851,7 @@ "id": "hair_split_mohawk", "slot": "cranium", "glb": "Under/hair_split_mohawk.glb", + "glbFemale": "Female/Under/hair_split_mohawk.glb", "meshes": [], "mat": "hair_umber" }, @@ -1759,6 +1862,7 @@ "id": "hair_fauxhawk", "slot": "cranium", "glb": "Under/hair_fauxhawk.glb", + "glbFemale": "Female/Under/hair_fauxhawk.glb", "meshes": [], "mat": "hair_umber" }, @@ -1769,6 +1873,7 @@ "id": "hair_slicked_crop", "slot": "cranium", "glb": "Under/hair_slicked_crop.glb", + "glbFemale": "Female/Under/hair_slicked_crop.glb", "meshes": [], "mat": "hair_umber" }, @@ -1779,6 +1884,7 @@ "id": "hair_side_sweep", "slot": "cranium", "glb": "Under/hair_side_sweep.glb", + "glbFemale": "Female/Under/hair_side_sweep.glb", "meshes": [], "mat": "hair_umber" }, @@ -1789,6 +1895,7 @@ "id": "hair_flared_shag", "slot": "cranium", "glb": "Under/hair_flared_shag.glb", + "glbFemale": "Female/Under/hair_flared_shag.glb", "meshes": [], "mat": "hair_umber" }, @@ -1799,6 +1906,7 @@ "id": "hair_cropped_buzz", "slot": "cranium", "glb": "Under/hair_cropped_buzz.glb", + "glbFemale": "Female/Under/hair_cropped_buzz.glb", "meshes": [], "mat": "hair_umber" }, @@ -1809,6 +1917,7 @@ "id": "hair_messy_crop", "slot": "cranium", "glb": "Under/hair_messy_crop.glb", + "glbFemale": "Female/Under/hair_messy_crop.glb", "meshes": [], "mat": "hair_umber" }, @@ -1819,6 +1928,7 @@ "id": "hair_textured_crop", "slot": "cranium", "glb": "Under/hair_textured_crop.glb", + "glbFemale": "Female/Under/hair_textured_crop.glb", "meshes": [], "mat": "hair_umber" }, @@ -1829,6 +1939,7 @@ "id": "hair_shoulder_shag", "slot": "cranium", "glb": "Under/hair_shoulder_shag.glb", + "glbFemale": "Female/Under/hair_shoulder_shag.glb", "meshes": [], "mat": "hair_umber" }, @@ -1839,6 +1950,7 @@ "id": "hair_messy_side_part", "slot": "cranium", "glb": "Under/hair_messy_side_part.glb", + "glbFemale": "Female/Under/hair_messy_side_part.glb", "meshes": [], "mat": "hair_umber" }, @@ -1849,6 +1961,7 @@ "id": "hair_flat_top", "slot": "cranium", "glb": "Under/hair_flat_top.glb", + "glbFemale": "Female/Under/hair_flat_top.glb", "meshes": [], "mat": "hair_umber" }, @@ -1859,6 +1972,7 @@ "id": "hair_top_patch", "slot": "cranium", "glb": "Under/hair_top_patch.glb", + "glbFemale": "Female/Under/hair_top_patch.glb", "meshes": [], "mat": "hair_umber" } diff --git a/client-3d/public/assets/pawn-pack/pawn_female.glb b/client-3d/public/assets/pawn-pack/pawn_female.glb index 3671ca95..06ce6bf0 100644 --- a/client-3d/public/assets/pawn-pack/pawn_female.glb +++ b/client-3d/public/assets/pawn-pack/pawn_female.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:16d8c879aabc58b5ee176edcfc0b0dc43fc695e7d59f4a2ef6d6a15890e7d000 -size 167652 +oid sha256:04037b7ec4e5a580b53175771c88116b14ce27d0feaf280a9eae7a1d01b7b764 +size 2870464 diff --git a/client-3d/public/assets/pawn-pack/pawn_female.provenance.json b/client-3d/public/assets/pawn-pack/pawn_female.provenance.json new file mode 100644 index 00000000..7758fb7c --- /dev/null +++ b/client-3d/public/assets/pawn-pack/pawn_female.provenance.json @@ -0,0 +1,326 @@ +{ + "schema": "successor-asset-provenance/1", + "asset_id": "pawn_female", + "asset_path": "client-3d/public/assets/pawn-pack/pawn_female.glb", + "asset_hash": "sha256:9eb7242ce49fcd7d7afa53a898c35e5b7b5fd0d5d99d7109bfc82e33e4f095e9", + "asset_kind": "pawn_body_glb", + "source": { + "approved_body": "content-pipeline/labs/humanoid-runtime-refit/source/female/pawn_female_refined.glb", + "approved_body_hash": "sha256:bd99fdb32fc40291780d92b1494f07a428c439204b306a2a6d32d2bc403c0c8b", + "promotion_input": "content-pipeline/labs/humanoid-runtime-refit/source/female/pawn_female_refined.glb", + "promotion_input_hash": "sha256:bd99fdb32fc40291780d92b1494f07a428c439204b306a2a6d32d2bc403c0c8b", + "runtime_shell": "content-pipeline/labs/humanoid-runtime-refit/build/reference/male.glb", + "runtime_shell_hash": "sha256:6eaac9062bcfbc5efdf40c64f9e3236402a0d81832f1e0e8f4ce9a0a4dacac42", + "sidecar": { + "body": "pawn_female_refined.glb", + "source_repository": "ssh://bunker/home/lycaon/git/successor.git", + "source_branch": "body-refine-20260802", + "source_commit": "d3fdfa2831bf938c0d9c8bf06f8796a00a935af5", + "source_sha256": "bd99fdb32fc40291780d92b1494f07a428c439204b306a2a6d32d2bc403c0c8b", + "clips_from": "hash-pinned runtime shell during promotion", + "actions": 0, + "runtime_actions": 47, + "verts": 3262, + "tris": 5882, + "height": 1.7525, + "vertex_groups": 50, + "head": { + "method": "reviewed shared-topology 10-ring, 16-column faceted head and neck graft", + "face_panel_faces": 56, + "art_gates": { + "depth_over_height": true, + "rear_fill": true, + "occiput_peak": true + } + }, + "hands": { + "source": "verified legacy runtime hands", + "unchanged_by_body_refinement": true, + "islands_per_side": 36 + }, + "review": "Final Bunker humanoid refinement; immutable, head, transition, animation-proof, and viewer gates completed before commit." + }, + "canonical_hands": "content-pipeline/labs/humanoid-runtime-refit/build/reference/female.glb", + "canonical_hands_hash": "sha256:16d8c879aabc58b5ee176edcfc0b0dc43fc695e7d59f4a2ef6d6a15890e7d000", + "generator": "successor content-pipeline/labs/humanoid-runtime-refit/promote_bodies.py", + "pipeline": [ + "reference_bodies.py (hash-pinned shell + approved sources)", + "bake_body_shading.py (per-vertex AO)", + "bake_face_texture.py (background-erased RGBA components)", + "promote_bodies.py (refined body + canonical animated hands + zone split + face skin base/overlay + accessor transplant)" + ] + }, + "segmentation": { + "vocabulary": [ + "torso", + "pelvis", + "neck", + "head", + "left_upper_arm", + "right_upper_arm", + "left_forearm", + "right_forearm", + "left_hand", + "right_hand", + "left_thigh", + "right_thigh", + "left_calf", + "right_calf", + "left_foot", + "right_foot" + ], + "material_pattern": "BodyZone_", + "face_overlay_primitive": "RB_Face", + "face_skin_base_material": "BodyZone_head", + "method": "per-tree-edge weight cuts, relaxed over the welded dual graph, descended from the pelvis", + "seams": [ + [ + "head", + "neck" + ], + [ + "left_calf", + "left_foot" + ], + [ + "left_calf", + "left_thigh" + ], + [ + "left_thigh", + "pelvis" + ], + [ + "left_upper_arm", + "torso" + ], + [ + "neck", + "torso" + ], + [ + "pelvis", + "right_thigh" + ], + [ + "pelvis", + "torso" + ], + [ + "right_calf", + "right_foot" + ], + [ + "right_calf", + "right_thigh" + ], + [ + "right_upper_arm", + "torso" + ] + ], + "specks_absorbed": [], + "zones": { + "torso": { + "triangles": 397, + "patches": 1, + "area_cm2": 3190.456, + "centroid_y_range_m": [ + 1.0881, + 1.5253 + ] + }, + "pelvis": { + "triangles": 146, + "patches": 1, + "area_cm2": 1876.243, + "centroid_y_range_m": [ + 0.8449, + 1.1148 + ] + }, + "neck": { + "triangles": 66, + "patches": 1, + "area_cm2": 63.203, + "centroid_y_range_m": [ + 1.5038, + 1.5378 + ] + }, + "head": { + "triangles": 227, + "patches": 1, + "area_cm2": 861.892, + "centroid_y_range_m": [ + 1.5378, + 1.7455 + ] + }, + "left_upper_arm": { + "triangles": 84, + "patches": 1, + "area_cm2": 981.422, + "centroid_y_range_m": [ + 1.3809, + 1.4893 + ] + }, + "right_upper_arm": { + "triangles": 84, + "patches": 1, + "area_cm2": 981.422, + "centroid_y_range_m": [ + 1.3809, + 1.4893 + ] + }, + "left_forearm": { + "triangles": 133, + "patches": 2, + "area_cm2": 477.372, + "centroid_y_range_m": [ + 1.4063, + 1.4724 + ] + }, + "right_forearm": { + "triangles": 133, + "patches": 2, + "area_cm2": 477.372, + "centroid_y_range_m": [ + 1.4063, + 1.4724 + ] + }, + "left_hand": { + "triangles": 2032, + "patches": 36, + "area_cm2": 480.741, + "centroid_y_range_m": [ + 1.3825, + 1.4483 + ], + "vertices": 1088, + "geometry_source": "content-pipeline/labs/humanoid-runtime-refit/build/reference/female.glb" + }, + "right_hand": { + "triangles": 2032, + "patches": 36, + "area_cm2": 480.741, + "centroid_y_range_m": [ + 1.3825, + 1.4483 + ], + "vertices": 1088, + "geometry_source": "content-pipeline/labs/humanoid-runtime-refit/build/reference/female.glb" + }, + "left_thigh": { + "triangles": 77, + "patches": 1, + "area_cm2": 1580.901, + "centroid_y_range_m": [ + 0.4963, + 0.9195 + ] + }, + "right_thigh": { + "triangles": 77, + "patches": 1, + "area_cm2": 1580.901, + "centroid_y_range_m": [ + 0.4963, + 0.9195 + ] + }, + "left_calf": { + "triangles": 101, + "patches": 1, + "area_cm2": 1050.905, + "centroid_y_range_m": [ + 0.0923, + 0.5073 + ] + }, + "right_calf": { + "triangles": 101, + "patches": 1, + "area_cm2": 1050.905, + "centroid_y_range_m": [ + 0.0923, + 0.5073 + ] + }, + "left_foot": { + "triangles": 68, + "patches": 1, + "area_cm2": 603.509, + "centroid_y_range_m": [ + 0.0004, + 0.1015 + ] + }, + "right_foot": { + "triangles": 68, + "patches": 1, + "area_cm2": 603.509, + "centroid_y_range_m": [ + 0.0004, + 0.1015 + ] + } + } + }, + "metrics": { + "bytes": 2870468, + "joints": 50, + "animations": 47, + "primitives": 18, + "triangles": 5938, + "expected_hybrid_triangles": 5882, + "refined_source_triangles": 5882, + "height_max_y_m": 1.7525 + }, + "validation": { + "materials_exact": true, + "joint_order_identical_to_shell": true, + "clip_names_identical_to_shell": true, + "inverse_bind_max_delta": 0.0, + "index_accessor_types": [ + "SCALAR" + ], + "zone_material_pbr_variants": 1, + "welded_points_match_hybrid_sources": true, + "canonical_hands_exact": { + "left_hand": true, + "right_hand": true + }, + "canonical_hand_compatibility": { + "rest_translation_drift_m": 9.601935744285583e-07, + "rest_rotation_drift_deg": 0.03837102297622957, + "inverse_bind_drift": 5.338340997695923e-06 + }, + "gate": "content-pipeline/labs/humanoid-runtime-refit/verify_body_zones.py", + "reports": [ + "content-pipeline/labs/humanoid-runtime-refit/reports/apparel_refit.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/body_shading.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/body_zone_coverage.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/body_zone_verification.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/coverage_triage.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/female_posterior.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/female_source_comparison.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/promotion.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/proof_manifest.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/starter_apparel.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/starter_fit.json" + ] + }, + "coverage": { + "manifest": "client-3d/public/assets/pawn-pack/equipment/manifest.json", + "manifest_hash": "sha256:459e3a566ba60c9de993ab4ac42fc233f89b38ee6ef7382381957c07b5c9c156", + "items": 99, + "items_with_coverage": 2, + "items_with_female_variant": 98 + }, + "generator": "successor content-pipeline/labs/humanoid-runtime-refit/write_provenance.py" +} diff --git a/client-3d/public/assets/pawn-pack/pawn_male.glb b/client-3d/public/assets/pawn-pack/pawn_male.glb index e95bb30b..70b0a9fa 100644 --- a/client-3d/public/assets/pawn-pack/pawn_male.glb +++ b/client-3d/public/assets/pawn-pack/pawn_male.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6eaac9062bcfbc5efdf40c64f9e3236402a0d81832f1e0e8f4ce9a0a4dacac42 -size 2732136 +oid sha256:1ebb4dc845298f978210775a37bbac59fefe8fb8bd16f0c5da5c67995fa2bf0d +size 2867016 diff --git a/client-3d/public/assets/pawn-pack/pawn_male.provenance.json b/client-3d/public/assets/pawn-pack/pawn_male.provenance.json index 71591951..9fdb7996 100644 --- a/client-3d/public/assets/pawn-pack/pawn_male.provenance.json +++ b/client-3d/public/assets/pawn-pack/pawn_male.provenance.json @@ -2,39 +2,325 @@ "schema": "successor-asset-provenance/1", "asset_id": "pawn_male", "asset_path": "client-3d/public/assets/pawn-pack/pawn_male.glb", - "asset_hash": "sha256:6eaac9062bcfbc5efdf40c64f9e3236402a0d81832f1e0e8f4ce9a0a4dacac42", + "asset_hash": "sha256:a865e80f3fe740c135633c2c03b94afcfb0d10def3756cb7ca43fec5e51e7f9c", "asset_kind": "pawn_body_glb", "source": { - "derived_from": "previous runtime pawn_male.glb (sha256:3b4920454aaf9909878bda7ddaef53a9844e2072f71c4c672a3dc849d7875376, 2026-07-07) with ONLY the body primitive's POSITION/NORMAL/JOINTS_0/WEIGHTS_0 replaced", - "body_state": "source-assets:characters/synty-fit/labs/_backup/bottoms_precoresplit_0000.blend (sha256:eaedc9efdab7032e3146c718221cdf5a0fc7e5b2562490d9d2cf4787672a86e1) — the pre-core-split v8 accommodated body: weight-field match to garment donors (body_match_pants.py, zone z 0.12-1.02 ramped) + v4 geometry shrink (12mm, seat +14mm). No core split (shelved by owner 2026-07-08), no helper bones, no crease rings.", - "generator": "surgical accessor transplant (byte-preserving for skeleton/animations/materials); vertex order proven identical via index-buffer equality; JOINTS_0 remapped by joint name", - "not_used": "labs/bottoms.blend head state (54-bone helper-rig variant from body_prepare_clothed.py) — constraint-driven helpers cannot ship; runtime contract is exactly 50 bones (ue5_mannequin_50)" + "approved_body": "content-pipeline/labs/humanoid-runtime-refit/source/male/pawn_male_refined.glb", + "approved_body_hash": "sha256:d5cb14b321a5f6024820e6c49eaa7f4200dca303336c95758792847f34211401", + "promotion_input": "content-pipeline/labs/humanoid-runtime-refit/source/male/pawn_male_refined.glb", + "promotion_input_hash": "sha256:d5cb14b321a5f6024820e6c49eaa7f4200dca303336c95758792847f34211401", + "runtime_shell": "content-pipeline/labs/humanoid-runtime-refit/build/reference/male.glb", + "runtime_shell_hash": "sha256:6eaac9062bcfbc5efdf40c64f9e3236402a0d81832f1e0e8f4ce9a0a4dacac42", + "sidecar": { + "body": "pawn_male_refined.glb", + "source_repository": "ssh://bunker/home/lycaon/git/successor.git", + "source_branch": "body-refine-20260802", + "source_commit": "d3fdfa2831bf938c0d9c8bf06f8796a00a935af5", + "source_sha256": "d5cb14b321a5f6024820e6c49eaa7f4200dca303336c95758792847f34211401", + "clips_from": "hash-pinned runtime shell during promotion", + "actions": 0, + "runtime_actions": 47, + "verts": 3226, + "tris": 5818, + "height": 1.7525, + "vertex_groups": 50, + "head": { + "method": "reviewed shared-topology 10-ring, 16-column faceted head and neck graft", + "face_panel_faces": 56, + "art_gates": { + "depth_over_height": true, + "rear_fill": true, + "occiput_peak": true + } + }, + "hands": { + "source": "verified legacy runtime hands", + "unchanged_by_body_refinement": true, + "islands_per_side": 36 + }, + "review": "Final Bunker humanoid refinement; immutable, head, transition, animation-proof, and viewer gates completed before commit." + }, + "canonical_hands": "content-pipeline/labs/humanoid-runtime-refit/build/reference/male.glb", + "canonical_hands_hash": "sha256:6eaac9062bcfbc5efdf40c64f9e3236402a0d81832f1e0e8f4ce9a0a4dacac42", + "generator": "successor content-pipeline/labs/humanoid-runtime-refit/promote_bodies.py", + "pipeline": [ + "reference_bodies.py (hash-pinned shell + approved sources)", + "bake_body_shading.py (per-vertex AO)", + "bake_face_texture.py (background-erased RGBA components)", + "promote_bodies.py (refined body + canonical animated hands + zone split + face skin base/overlay + accessor transplant)" + ] }, - "promotion": { - "method": "in-place body-attribute swap into a copy of the prior runtime GLB", - "promoted_at": "2026-07-21", - "reason": "waistband/hip clipping on all synty pants in-game; lab body accommodation was never promoted to the runtime pack" + "segmentation": { + "vocabulary": [ + "torso", + "pelvis", + "neck", + "head", + "left_upper_arm", + "right_upper_arm", + "left_forearm", + "right_forearm", + "left_hand", + "right_hand", + "left_thigh", + "right_thigh", + "left_calf", + "right_calf", + "left_foot", + "right_foot" + ], + "material_pattern": "BodyZone_", + "face_overlay_primitive": "RB_Face", + "face_skin_base_material": "BodyZone_head", + "method": "per-tree-edge weight cuts, relaxed over the welded dual graph, descended from the pelvis", + "seams": [ + [ + "head", + "neck" + ], + [ + "left_calf", + "left_foot" + ], + [ + "left_calf", + "left_thigh" + ], + [ + "left_thigh", + "pelvis" + ], + [ + "left_upper_arm", + "torso" + ], + [ + "neck", + "torso" + ], + [ + "pelvis", + "right_thigh" + ], + [ + "pelvis", + "torso" + ], + [ + "right_calf", + "right_foot" + ], + [ + "right_calf", + "right_thigh" + ], + [ + "right_upper_arm", + "torso" + ] + ], + "specks_absorbed": [], + "zones": { + "torso": { + "triangles": 311, + "patches": 1, + "area_cm2": 3855.826, + "centroid_y_range_m": [ + 1.0634, + 1.5253 + ] + }, + "pelvis": { + "triangles": 121, + "patches": 1, + "area_cm2": 1609.484, + "centroid_y_range_m": [ + 0.8399, + 1.109 + ] + }, + "neck": { + "triangles": 77, + "patches": 1, + "area_cm2": 87.306, + "centroid_y_range_m": [ + 1.4937, + 1.5338 + ] + }, + "head": { + "triangles": 230, + "patches": 1, + "area_cm2": 958.522, + "centroid_y_range_m": [ + 1.5309, + 1.7453 + ] + }, + "left_upper_arm": { + "triangles": 97, + "patches": 1, + "area_cm2": 1212.401, + "centroid_y_range_m": [ + 1.3889, + 1.5114 + ] + }, + "right_upper_arm": { + "triangles": 97, + "patches": 1, + "area_cm2": 1212.401, + "centroid_y_range_m": [ + 1.3889, + 1.5114 + ] + }, + "left_forearm": { + "triangles": 145, + "patches": 2, + "area_cm2": 518.889, + "centroid_y_range_m": [ + 1.4176, + 1.4926 + ] + }, + "right_forearm": { + "triangles": 145, + "patches": 2, + "area_cm2": 518.889, + "centroid_y_range_m": [ + 1.4176, + 1.4926 + ] + }, + "left_hand": { + "triangles": 2032, + "patches": 36, + "area_cm2": 480.673, + "centroid_y_range_m": [ + 1.3825, + 1.4483 + ], + "vertices": 1088, + "geometry_source": "content-pipeline/labs/humanoid-runtime-refit/build/reference/male.glb" + }, + "right_hand": { + "triangles": 2032, + "patches": 36, + "area_cm2": 480.673, + "centroid_y_range_m": [ + 1.3825, + 1.4483 + ], + "vertices": 1088, + "geometry_source": "content-pipeline/labs/humanoid-runtime-refit/build/reference/male.glb" + }, + "left_thigh": { + "triangles": 94, + "patches": 1, + "area_cm2": 1648.396, + "centroid_y_range_m": [ + 0.5055, + 0.9329 + ] + }, + "right_thigh": { + "triangles": 95, + "patches": 1, + "area_cm2": 1676.093, + "centroid_y_range_m": [ + 0.5055, + 0.9329 + ] + }, + "left_calf": { + "triangles": 87, + "patches": 1, + "area_cm2": 1293.324, + "centroid_y_range_m": [ + 0.1049, + 0.5279 + ] + }, + "right_calf": { + "triangles": 87, + "patches": 1, + "area_cm2": 1293.324, + "centroid_y_range_m": [ + 0.1049, + 0.5279 + ] + }, + "left_foot": { + "triangles": 56, + "patches": 1, + "area_cm2": 742.925, + "centroid_y_range_m": [ + 0.0, + 0.1082 + ] + }, + "right_foot": { + "triangles": 56, + "patches": 1, + "area_cm2": 742.925, + "centroid_y_range_m": [ + 0.0, + 0.1082 + ] + } + } }, "metrics": { - "bytes": 2732136, + "bytes": 2867012, "joints": 50, "animations": 47, - "body_vertices": 2752, - "changed_vertices": 107, - "max_position_delta_mm": 22.0, - "height_max_y_m": 1.76264 + "primitives": 18, + "triangles": 5874, + "expected_hybrid_triangles": 5818, + "refined_source_triangles": 5818, + "height_max_y_m": 1.7525 }, "validation": { - "gltf_errors": 0, - "joint_names_and_order_identical_to_previous": true, - "animation_names_and_order_identical_to_previous": true, - "weight_rows_normalized": true, - "changed_weights_outside_z_zone": 0, - "gates": ["src/render/pawns.specialHumanoid.test.ts", "src/assets/pawnPack.catalog.test.ts", "tsc --noEmit"], - "visual_proof": "asset-lab before/after frame pairs (walk_f CLOSE waist framing): legs_wrapped_workpants, legs_reinforced_denim_pants, legs_padded_canvas_trousers, workpants+top_frayed_tunic, naked idle 3/4 — clipping resolved in all AFTER frames, naked silhouette unchanged" + "materials_exact": true, + "joint_order_identical_to_shell": true, + "clip_names_identical_to_shell": true, + "inverse_bind_max_delta": 0.0, + "index_accessor_types": [ + "SCALAR" + ], + "zone_material_pbr_variants": 1, + "welded_points_match_hybrid_sources": true, + "canonical_hands_exact": { + "left_hand": true, + "right_hand": true + }, + "canonical_hand_compatibility": { + "rest_translation_drift_m": 0.0, + "rest_rotation_drift_deg": 0.038466318374601585, + "inverse_bind_drift": 0.0 + }, + "gate": "content-pipeline/labs/humanoid-runtime-refit/verify_body_zones.py", + "reports": [ + "content-pipeline/labs/humanoid-runtime-refit/reports/apparel_refit.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/body_shading.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/body_zone_coverage.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/body_zone_verification.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/coverage_triage.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/female_posterior.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/female_source_comparison.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/promotion.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/proof_manifest.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/starter_apparel.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/starter_fit.json" + ] }, - "review": { - "status": "structural-and-headed-visual-pass", - "notes": "pawn_female.glb intentionally untouched: the synty-fit bottoms lab body-accommodation program covered only the male pawn_body; no female pass exists in the lab lineage." - } + "coverage": { + "manifest": "client-3d/public/assets/pawn-pack/equipment/manifest.json", + "manifest_hash": "sha256:459e3a566ba60c9de993ab4ac42fc233f89b38ee6ef7382381957c07b5c9c156", + "items": 99, + "items_with_coverage": 2, + "items_with_female_variant": 98 + }, + "generator": "successor content-pipeline/labs/humanoid-runtime-refit/write_provenance.py" } diff --git a/client-3d/public/assets/pawn-pack/pawn_male_bare.glb b/client-3d/public/assets/pawn-pack/pawn_male_bare.glb index fff4829d..6fa6aa5c 100644 --- a/client-3d/public/assets/pawn-pack/pawn_male_bare.glb +++ b/client-3d/public/assets/pawn-pack/pawn_male_bare.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5c182b3f7dcbb33a61965f633e66e820342f6d0f46746e30bce591a5b3182bca -size 2732124 +oid sha256:a865e80f3fe740c135633c2c03b94afcfb0d10def3756cb7ca43fec5e51e7f9c +size 2867012 diff --git a/client-3d/public/assets/pawn-pack/pawn_male_bare.provenance.json b/client-3d/public/assets/pawn-pack/pawn_male_bare.provenance.json index 347094e5..71e0e03a 100644 --- a/client-3d/public/assets/pawn-pack/pawn_male_bare.provenance.json +++ b/client-3d/public/assets/pawn-pack/pawn_male_bare.provenance.json @@ -2,23 +2,327 @@ "schema": "successor-asset-provenance/1", "asset_id": "pawn_male_bare", "asset_path": "client-3d/public/assets/pawn-pack/pawn_male_bare.glb", - "asset_hash": "sha256:5c182b3f7dcbb33a61965f633e66e820342f6d0f46746e30bce591a5b3182bca", + "asset_hash": "sha256:a865e80f3fe740c135633c2c03b94afcfb0d10def3756cb7ca43fec5e51e7f9c", "asset_kind": "pawn_body_glb", "source": { - "derived_from": "verification/anchors/body-20260721/pawn_male_W.glb (sha256:5c182b3f7dcbb33a61965f633e66e820342f6d0f46746e30bce591a5b3182bca)", - "body_state": "full-volume pre-shrink geometry with accommodation weight-field; the anchor preserves the shipped pawn_male skeleton, joints, clips, and 50-bone contract", - "generator": "byte copy of the verified W anchor; no runtime mesh, skeleton, or animation edits", - "anchor_reference": "verification/anchors/body-20260721/pawn_male_W.glb" + "approved_body": "content-pipeline/labs/humanoid-runtime-refit/source/male/pawn_male_refined.glb", + "approved_body_hash": "sha256:d5cb14b321a5f6024820e6c49eaa7f4200dca303336c95758792847f34211401", + "promotion_input": "content-pipeline/labs/humanoid-runtime-refit/source/male/pawn_male_refined.glb", + "promotion_input_hash": "sha256:d5cb14b321a5f6024820e6c49eaa7f4200dca303336c95758792847f34211401", + "runtime_shell": "content-pipeline/labs/humanoid-runtime-refit/build/reference/male.glb", + "runtime_shell_hash": "sha256:6eaac9062bcfbc5efdf40c64f9e3236402a0d81832f1e0e8f4ce9a0a4dacac42", + "sidecar": { + "body": "pawn_male_refined.glb", + "source_repository": "ssh://bunker/home/lycaon/git/successor.git", + "source_branch": "body-refine-20260802", + "source_commit": "d3fdfa2831bf938c0d9c8bf06f8796a00a935af5", + "source_sha256": "d5cb14b321a5f6024820e6c49eaa7f4200dca303336c95758792847f34211401", + "clips_from": "hash-pinned runtime shell during promotion", + "actions": 0, + "runtime_actions": 47, + "verts": 3226, + "tris": 5818, + "height": 1.7525, + "vertex_groups": 50, + "head": { + "method": "reviewed shared-topology 10-ring, 16-column faceted head and neck graft", + "face_panel_faces": 56, + "art_gates": { + "depth_over_height": true, + "rear_fill": true, + "occiput_peak": true + } + }, + "hands": { + "source": "verified legacy runtime hands", + "unchanged_by_body_refinement": true, + "islands_per_side": 36 + }, + "review": "Final Bunker humanoid refinement; immutable, head, transition, animation-proof, and viewer gates completed before commit." + }, + "canonical_hands": "content-pipeline/labs/humanoid-runtime-refit/build/reference/male.glb", + "canonical_hands_hash": "sha256:6eaac9062bcfbc5efdf40c64f9e3236402a0d81832f1e0e8f4ce9a0a4dacac42", + "generator": "successor content-pipeline/labs/humanoid-runtime-refit/promote_bodies.py", + "pipeline": [ + "reference_bodies.py (hash-pinned shell + approved sources)", + "bake_body_shading.py (per-vertex AO)", + "bake_face_texture.py (background-erased RGBA components)", + "promote_bodies.py (refined body + canonical animated hands + zone split + face skin base/overlay + accessor transplant)" + ], + "alias_of": "client-3d/public/assets/pawn-pack/pawn_male.glb", + "reason": "game_pack.json pawns.male.bare_file; the refit unified the accommodation body and its full-volume sibling, so the bare path is the same mesh and must carry the same zone primitives" }, - "promotion": { - "method": "optional worn-state body variant in the pawn pack", - "promoted_at": "2026-07-21", - "reason": "render full-volume male body when no leg-covering equipment is worn; retain accommodation body under legwear to prevent clipping" + "segmentation": { + "vocabulary": [ + "torso", + "pelvis", + "neck", + "head", + "left_upper_arm", + "right_upper_arm", + "left_forearm", + "right_forearm", + "left_hand", + "right_hand", + "left_thigh", + "right_thigh", + "left_calf", + "right_calf", + "left_foot", + "right_foot" + ], + "material_pattern": "BodyZone_", + "face_overlay_primitive": "RB_Face", + "face_skin_base_material": "BodyZone_head", + "method": "per-tree-edge weight cuts, relaxed over the welded dual graph, descended from the pelvis", + "seams": [ + [ + "head", + "neck" + ], + [ + "left_calf", + "left_foot" + ], + [ + "left_calf", + "left_thigh" + ], + [ + "left_thigh", + "pelvis" + ], + [ + "left_upper_arm", + "torso" + ], + [ + "neck", + "torso" + ], + [ + "pelvis", + "right_thigh" + ], + [ + "pelvis", + "torso" + ], + [ + "right_calf", + "right_foot" + ], + [ + "right_calf", + "right_thigh" + ], + [ + "right_upper_arm", + "torso" + ] + ], + "specks_absorbed": [], + "zones": { + "torso": { + "triangles": 311, + "patches": 1, + "area_cm2": 3855.826, + "centroid_y_range_m": [ + 1.0634, + 1.5253 + ] + }, + "pelvis": { + "triangles": 121, + "patches": 1, + "area_cm2": 1609.484, + "centroid_y_range_m": [ + 0.8399, + 1.109 + ] + }, + "neck": { + "triangles": 77, + "patches": 1, + "area_cm2": 87.306, + "centroid_y_range_m": [ + 1.4937, + 1.5338 + ] + }, + "head": { + "triangles": 230, + "patches": 1, + "area_cm2": 958.522, + "centroid_y_range_m": [ + 1.5309, + 1.7453 + ] + }, + "left_upper_arm": { + "triangles": 97, + "patches": 1, + "area_cm2": 1212.401, + "centroid_y_range_m": [ + 1.3889, + 1.5114 + ] + }, + "right_upper_arm": { + "triangles": 97, + "patches": 1, + "area_cm2": 1212.401, + "centroid_y_range_m": [ + 1.3889, + 1.5114 + ] + }, + "left_forearm": { + "triangles": 145, + "patches": 2, + "area_cm2": 518.889, + "centroid_y_range_m": [ + 1.4176, + 1.4926 + ] + }, + "right_forearm": { + "triangles": 145, + "patches": 2, + "area_cm2": 518.889, + "centroid_y_range_m": [ + 1.4176, + 1.4926 + ] + }, + "left_hand": { + "triangles": 2032, + "patches": 36, + "area_cm2": 480.673, + "centroid_y_range_m": [ + 1.3825, + 1.4483 + ], + "vertices": 1088, + "geometry_source": "content-pipeline/labs/humanoid-runtime-refit/build/reference/male.glb" + }, + "right_hand": { + "triangles": 2032, + "patches": 36, + "area_cm2": 480.673, + "centroid_y_range_m": [ + 1.3825, + 1.4483 + ], + "vertices": 1088, + "geometry_source": "content-pipeline/labs/humanoid-runtime-refit/build/reference/male.glb" + }, + "left_thigh": { + "triangles": 94, + "patches": 1, + "area_cm2": 1648.396, + "centroid_y_range_m": [ + 0.5055, + 0.9329 + ] + }, + "right_thigh": { + "triangles": 95, + "patches": 1, + "area_cm2": 1676.093, + "centroid_y_range_m": [ + 0.5055, + 0.9329 + ] + }, + "left_calf": { + "triangles": 87, + "patches": 1, + "area_cm2": 1293.324, + "centroid_y_range_m": [ + 0.1049, + 0.5279 + ] + }, + "right_calf": { + "triangles": 87, + "patches": 1, + "area_cm2": 1293.324, + "centroid_y_range_m": [ + 0.1049, + 0.5279 + ] + }, + "left_foot": { + "triangles": 56, + "patches": 1, + "area_cm2": 742.925, + "centroid_y_range_m": [ + 0.0, + 0.1082 + ] + }, + "right_foot": { + "triangles": 56, + "patches": 1, + "area_cm2": 742.925, + "centroid_y_range_m": [ + 0.0, + 0.1082 + ] + } + } }, - "validation": { - "gltf_errors": 0, - "byte_identical_to_anchor": true, + "metrics": { + "bytes": 2867012, "joints": 50, - "provenance_anchor_sha256": "sha256:5c182b3f7dcbb33a61965f633e66e820342f6d0f46746e30bce591a5b3182bca" - } + "animations": 47, + "primitives": 18, + "triangles": 5874, + "expected_hybrid_triangles": 5818, + "refined_source_triangles": 5818, + "height_max_y_m": 1.7525 + }, + "validation": { + "materials_exact": true, + "joint_order_identical_to_shell": true, + "clip_names_identical_to_shell": true, + "inverse_bind_max_delta": 0.0, + "index_accessor_types": [ + "SCALAR" + ], + "zone_material_pbr_variants": 1, + "welded_points_match_hybrid_sources": true, + "canonical_hands_exact": { + "left_hand": true, + "right_hand": true + }, + "canonical_hand_compatibility": { + "rest_translation_drift_m": 0.0, + "rest_rotation_drift_deg": 0.038466318374601585, + "inverse_bind_drift": 0.0 + }, + "gate": "content-pipeline/labs/humanoid-runtime-refit/verify_body_zones.py", + "reports": [ + "content-pipeline/labs/humanoid-runtime-refit/reports/apparel_refit.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/body_shading.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/body_zone_coverage.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/body_zone_verification.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/coverage_triage.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/female_posterior.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/female_source_comparison.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/promotion.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/proof_manifest.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/starter_apparel.json", + "content-pipeline/labs/humanoid-runtime-refit/reports/starter_fit.json" + ] + }, + "coverage": { + "manifest": "client-3d/public/assets/pawn-pack/equipment/manifest.json", + "manifest_hash": "sha256:459e3a566ba60c9de993ab4ac42fc233f89b38ee6ef7382381957c07b5c9c156", + "items": 99, + "items_with_coverage": 2, + "items_with_female_variant": 98 + }, + "generator": "successor content-pipeline/labs/humanoid-runtime-refit/write_provenance.py" } diff --git a/client-3d/public/assets/pawn-pack/slugthrower_attach.json b/client-3d/public/assets/pawn-pack/slugthrower_attach.json index 03e8f74a..a2bf832c 100644 --- a/client-3d/public/assets/pawn-pack/slugthrower_attach.json +++ b/client-3d/public/assets/pawn-pack/slugthrower_attach.json @@ -4,42 +4,132 @@ "coordinate_space": "weapon_glb_local_gltf", "space_notes": "Sockets/orientation are in the weapon's GLB-local (glTF) frame, the shared asset space for ALL consumers. three.js uses them directly. Blender's glTF importer rotates loose meshes +90deg about X, so a Blender consumer maps glTF (x,y,z) -> Blender (x,-z,y) before use. UE applies its own glTF import transform. Keep this file the single source of truth; the anim manifest references it via weapons.slugthrower.spec.", "units": "m", - "nodes": { "frame": "Gear_sten_mk2", "mag": "Gear_sten_mk2_mag" }, + "nodes": { + "frame": "Gear_sten_mk2", + "mag": "Gear_sten_mk2_mag" + }, "_nodes_note": "Node names are GLB-internal identifiers baked into slugthrower.glb (asset bytes are locked byte-for-byte); they change only when the mesh is re-exported with renamed nodes.", "sockets": { - "grip": [0.0, -0.109, -0.042], - "foregrip": [0.0, -0.03, 0.250], - "muzzle": [0.0, 0.008, 0.438], - "stock": [0.0, -0.014, -0.410] + "grip": [ + 0.0, + -0.109, + -0.042 + ], + "foregrip": [ + 0.0, + -0.03, + 0.25 + ], + "muzzle": [ + 0.0, + 0.008, + 0.438 + ], + "stock": [ + 0.0, + -0.014, + -0.41 + ] }, "orientation": { - "forward": [0.0, 0.0, 1.0], - "up": [0.0, 1.0, 0.0] + "forward": [ + 0.0, + 0.0, + 1.0 + ], + "up": [ + 0.0, + 1.0, + 0.0 + ] }, "mount_hand_r_local": { - "_status": "USER_TUNED_2026-06-26 — locked firing-hand placement (closest by eye)", - "pos": [-0.03788, 0.14408, 0.05585], - "quat": [-0.09279, 0.83834, 0.53009, -0.08703], - "adjust_deltas": { "apx": 0.024, "apy": -0.026, "apz": -0.012, "arx": 5, "ary": 21, "arz": 5 }, + "_status": "USER_TUNED_2026-06-26 \u2014 locked firing-hand placement (closest by eye)", + "pos": [ + -0.03788, + 0.14408, + 0.05585 + ], + "quat": [ + -0.09279, + 0.83834, + 0.53009, + -0.08703 + ], + "adjust_deltas": { + "apx": 0.024, + "apy": -0.026, + "apz": -0.012, + "arx": 5, + "ary": 21, + "arz": 5 + }, "_note": "hand_r-local transform. Viewer/UE attach the Slugthrower with this. Support hand still reaches past the short frame -> left-hand IK to foregrip (or longer barrel) needed." }, "legacy_shouldered_mount_DEPRECATED_do_not_consume": { "type": "shouldered", "shoulder_bone": "clavicle_r", - "forward": 0.06, "down": 0.05, "inboard": 0.03, "blade_deg": -35.0, - "target_palm_r": [-1.0, 0.0, 0.0], "target_finger_r": [0.0, 0.0, 1.0], - "target_palm_l": [0.0, 1.0, 0.0], "target_thumb_l": [0.0, 0.0, 1.0], + "forward": 0.06, + "down": 0.05, + "inboard": 0.03, + "blade_deg": -35.0, + "target_palm_r": [ + -1.0, + 0.0, + 0.0 + ], + "target_finger_r": [ + 0.0, + 0.0, + 1.0 + ], + "target_palm_l": [ + 0.0, + 1.0, + 0.0 + ], + "target_thumb_l": [ + 0.0, + 0.0, + 1.0 + ], "_note": "Buttstock seats into the shoulder pocket: anchor = clavicle_r TAIL (shoulder joint, torso space, OUTSIDE the arm IK chain), offset forward/down toward the pocket + inboard toward spine. blade_deg yaws the upper spine to advance the support-side shoulder so the forward support hand reaches the barrel shroud (support hand stays FORWARD on the shroud, never the mag housing)." }, "attach": "two_hand", "handedness": "right", - "hands": { "grip": "hand_r", "foregrip": "hand_l" }, + "hands": { + "grip": "hand_r", + "foregrip": "hand_l" + }, "aim_defaults": { "_space": "scene world (engine placement guidance, not asset-local)", - "world_forward": [0.0, -1.0, 0.0], - "world_up": [0.0, 0.0, 1.0], + "world_forward": [ + 0.0, + -1.0, + 0.0 + ], + "world_up": [ + 0.0, + 0.0, + 1.0 + ], "_deprecated_grip_target_world": "REMOVED from placement. The shouldered solver seats the STOCK into the shoulder pocket (see mount), NOT a floating grip target. Do NOT reintroduce this as the placement anchor -- doing so recreates the pistol/SMG hip-hold." }, "provenance": "sockets+orientation+stock+mount seeded by tmp_solve_aim.py heuristic detector (measured Blender-space, converted to glTF-local), frozen as authored CANDIDATE values (promote to ground-truth after per-weapon visual confirmation). Detector is a GENERATOR ONLY. The support hand targets FORWARD on the barrel shroud (user-confirmed correct; NOT the mag housing); its exact along-barrel position (~0.62) is geometric and may be nudged on visual review. CAVEAT: the spec-vs-measured delta proves only the glTF<->Blender conversion + no drift from the seeding heuristic (same heuristic both sides), NOT semantic socket identity. The 0mm IK verify proves reach to these coords and, with the stock->shoulder mount + torso blade, that both hands reach a shouldered hold -- it does not replace a per-weapon visual check of the LOOK.", - "generator": "blender -b -P tmp_solve_aim.py -- (no spec present -> writes a _attach.json draft you confirm + freeze)" + "generator": "blender -b -P tmp_solve_aim.py -- (no spec present -> writes a _attach.json draft you confirm + freeze)", + "stow_socket": { + "space": "spine_03_local", + "pos": [ + 0.16, + 0, + -0.14 + ], + "rot_deg": [ + 85, + -45, + 0 + ], + "arc_lift": 0.14, + "derivation": "The accepted legacy rifle back carry, written down. SlugthrowerRig previously took these from config.pawnPack.weaponStow with no authored socket at all, so the native port had nothing to read. These numbers ARE that config block, making the endpoint explicit and shared without changing the rendered pose by a single millimetre." + } } diff --git a/client-3d/public/assets/pawn-pack/vibrosword_attach.json b/client-3d/public/assets/pawn-pack/vibrosword_attach.json index bc4f11d7..7d9195d2 100644 --- a/client-3d/public/assets/pawn-pack/vibrosword_attach.json +++ b/client-3d/public/assets/pawn-pack/vibrosword_attach.json @@ -74,16 +74,17 @@ "stow_socket": { "space": "spine_03_local", "pos": [ - 0.16, - 0, - -0.14 + -0.04, + 0.25, + -0.135 ], "rot_deg": [ - 85, - -45, - 0 + 91, + 9, + -93 ], - "derivation": "Existing Successor spine_03 back-carry contract." + "arc_lift": 0.14, + "derivation": "The ACCEPTED live carry, written down. This file previously carried the rifle contract's [0.16,0,-0.14]/[85,-45,0] as a placeholder, which disagreed with what actually shipped: SwordRig ignored the file entirely and used config.pawnPack.swordStow. These values ARE that config block (solved 2026-07-08 against the live skeleton: guard just behind/above the right shoulder blade, blade raking down the back, flat against the back plane), so promoting them to the authored socket is pose-identical to the accepted build and simply makes the legacy benchmark explicit and per-model tunable." }, "provenance": "Source-authored powered cleaver redesign derived from the F4 lane; deterministic rebuild via build_vibrosword_powered.py." } diff --git a/client-3d/public/assets/pawn-pack/weapons/custom/field_saber_attach.json b/client-3d/public/assets/pawn-pack/weapons/custom/field_saber_attach.json index 4d7769e4..323ee5d6 100644 --- a/client-3d/public/assets/pawn-pack/weapons/custom/field_saber_attach.json +++ b/client-3d/public/assets/pawn-pack/weapons/custom/field_saber_attach.json @@ -37,9 +37,10 @@ }, "stow_socket": { "space": "spine_03_local", - "pos": [0.16, 0, -0.14], - "rot_deg": [85, -45, 0], - "derivation": "Existing Successor spine_03 back-carry contract." + "pos": [-0.0691, 0.4317, -0.1381], + "rot_deg": [-179.47, -2.96, -9.01], + "arc_lift": 0.15, + "derivation": "Solved from the ACCEPTED Vibrosword back carry, same melee_early family frame as scrapline_machete. The previous placeholder was never consumed (SwordRig forced one global melee transform), leaving this 0.918 m +Y-blade saber horizontal across the neck with the tip 0.78 m out past the pawn's left shoulder. rot_deg maps this family's +Y blade axis onto the accepted spine-local blade direction and its +Z thickness axis onto the accepted flat-normal, so the blade lies flat on the back, edge lateral. pos seats this saber's OWN measured midpoint (y=0.371) on the accepted carry midpoint plus its half-thickness offset (0.030 vs the Vibrosword's 0.0237). Tip lands at spine-local y=-0.3879 — ABOVE the accepted Vibrosword tip at -0.4305, which is what clears the right thigh/glute contact reported on the female body." }, "measured_runtime": { "mesh_count": 1, diff --git a/client-3d/public/assets/pawn-pack/weapons/custom/lightning_carbine_attach.json b/client-3d/public/assets/pawn-pack/weapons/custom/lightning_carbine_attach.json index 99d7e327..e9c537ab 100644 --- a/client-3d/public/assets/pawn-pack/weapons/custom/lightning_carbine_attach.json +++ b/client-3d/public/assets/pawn-pack/weapons/custom/lightning_carbine_attach.json @@ -30,6 +30,11 @@ 0.001, 0.07 ], + "foregrip_contact": [ + 0, + -0.0234, + 0.07 + ], "muzzle": [ 0, 0, @@ -144,5 +149,26 @@ ] }, "deterministic": true + }, + "support_hand_derivation": "Authored support-WRIST target measured from THIS model's own shroud, replacing the global foregrip contact offset (which assumes the legacy handguard radius at scale 1 and buried the wrist inside fat / scaled / caged housings). y = -(shroud half-height - 0.003) leaves the wrist ~3 mm inside the shroud shell, matching the measured contact on the accepted Kiln carbine (-2.9 mm); z = shroud mid-length, which also walks the forearm forward off the side magazine and the front canister cage.", + "stow_socket": { + "space": "spine_03_local", + "pos": [ + 0.16, + 0, + -0.14 + ], + "rot_deg": [ + 85, + -45, + 0 + ], + "arc_lift": 0.14, + "derivation": "This file previously had NO stow_socket at all, so it fell through to the class default and the native port had nothing to read. These are exactly that rifle class default, made concrete so Three and native share one endpoint; the rendered carry is unchanged." + }, + "hold": { + "resting_yaw_deg": 26.0, + "rebaseline_2026-08-03": "Re-swept against the pawn bodies now on disk (the retro rb_v4/rbf_v3 sources replaced the older higher-poly humanoid-lab body, changing the upper-arm silhouette). At the previous 17.19 deg this model's Module_stock read -1.9 mm into upperarm_r at male/rifle_run_f on the new body. This is NOT related to the support-arm IK work: the identical -1.9 mm is measured with and without the support_arm posture, on every weapon, because no posture is authored here. Re-swept 17.19/22/26/30/34/38 deg on both bodies across idle/walk/run/fire/reload x3/stow: 22 deg is the first that clears (+1.1 mm), 26 deg gives +16.3 mm - the same margin band the original tuning accepted - and the support-hand contact is unchanged at -0.9 mm at every value. The bore correction still pivots on the grip socket, so the firing-hand weld is untouched.", + "derivation": "Lightning Carbine carries one of the longest tails behind the grip in the catalogue, and at the class-default resting yaw (0.1 rad / 5.7 deg) its Module_stock PENETRATED the right upper arm (-14.1 mm worst, during rifle_run_f). Bore leveling pins pitch and yaw about the grip socket, so the tail can only be moved by the resting bore yaw - and because the correction pivots ON the grip, the firing-hand weld is untouched. Swept 0.10-0.60 rad in the live Lab across idle/walk/run(x2)/fire/reload(eject,pouch,seat) on both bodies. 0.30 rad (17.19 deg) is the smallest value that clears every pose on BOTH bodies with real margin (male 84.5 mm, female 100.4 mm) while leaving the support-hand contact unchanged at -0.9 mm; larger values (0.40+) buy nothing because the yaw correction saturates at boreLevel.maxYawCorrectionRad = 0.45, and they visibly over-cant the hold." } } diff --git a/client-3d/public/assets/pawn-pack/weapons/custom/plasma_hilt_attach.json b/client-3d/public/assets/pawn-pack/weapons/custom/plasma_hilt_attach.json new file mode 100644 index 00000000..5eb22817 --- /dev/null +++ b/client-3d/public/assets/pawn-pack/weapons/custom/plasma_hilt_attach.json @@ -0,0 +1,110 @@ +{ + "schema": "successor-weapon-attach/1", + "weapon": "plasma_sword", + "item_id": 3104, + "source_glb": "pawn-pack/plasma_hilt.glb", + "silhouette_class": "melee", + "coordinate_space": "weapon_glb_local_gltf", + "units": "m", + "scale_to_pawn": 1, + "space_notes": "Shares the Vibrosword family frame: +Z toward the emitter/blade, +Y up, +X right face. Measured runtime bounds z -0.058 (pommel cap) .. +0.090 (emitter tip), 0.068 square section. PlasmaBlade's EMITTER_OFFSET_M of 0.09 is exactly this emitter tip, which is what confirms the shared axis convention.", + "attach": "one_hand", + "handedness": "right", + "hands": { + "grip": "hand_r" + }, + "nodes": { + "frame": "Gear_plasma_hilt" + }, + "sockets": { + "grip": [ + 0, + 0, + 0.006 + ], + "foregrip": [ + 0, + 0, + 0.03 + ], + "muzzle": [ + 0, + 0, + 0.84 + ], + "stock": [ + 0, + 0, + -0.058 + ], + "emitter": [ + 0, + 0, + 0.09 + ], + "pommel": [ + 0, + 0, + -0.058 + ] + }, + "orientation": { + "forward": [ + 0, + 0, + 1 + ], + "up": [ + 0, + 1, + 0 + ] + }, + "mount_hand_r_local": { + "pos": [ + -0.01213, + 0.06998, + 0.01134 + ], + "quat": [ + 0.1213, + -0.24455, + -0.23404, + -0.93312 + ], + "derivation": "FIXES the 15.25 cm floating hilt. The plasma presentation used to have no model of its own: it borrowed the Vibrosword's spec and parented plasma_hilt.glb straight onto the weaponRoot. That root sits at the Vibrosword's ORIGIN, which is 0.1525 m up the blade from its grip midpoint, so a hilt whose own origin is at its grip centre hung 15.25 cm out in mid-air ahead of hand_r. Quat is the Vibrosword weld verbatim (identical +Z-blade family frame). Pos is re-solved with the same rule the rest of the family uses: p = p_vibro + R(q) @ (0,0, gripZ_vibro - gripZ_hilt) = p_vibro + R(q) @ (0,0,-0.1585), so THIS hilt's grip midpoint (z=+0.006, the centre of the graspable column between the pommel cap at -0.058 and the emitter collar at +0.070) lands on the same hand_r-local palm point the accepted Vibrosword grip occupies." + }, + "stow_socket": { + "space": "spine_03_local", + "pos": [ + -0.205, + -0.26, + -0.085 + ], + "rot_deg": [ + -104, + 0, + -13 + ], + "arc_lift": 0.08, + "derivation": "Retracted carry is a BELT CLIP, not the over-the-shoulder sword socket: a 148 mm hilt on the back sword carry reads as a floating stub. spine_03-local axes are +X anatomical left, +Y up the spine, -Z behind, so [-0.16,-0.28,-0.055] is the weapon-hand (right) hip, slightly behind the hip crest. rot_deg is R_x(-90) plus cant, mapping the hilt's +Z emitter axis onto +Y (0, 0.970, -0.242) so the hilt stands upright with the emitter up and tipped back. Pommel lands at spine-local y=-0.336 and the emitter at y=-0.193. arc_lift is reduced to 0.08 because the draw is a short hip-to-hand sweep, not a shoulder unsling." + }, + "measured_runtime": { + "mesh_count": 1, + "triangles": 224, + "bbox_min_m": [ + -0.034, + -0.034, + -0.058 + ], + "bbox_max_m": [ + 0.034, + 0.034, + 0.09 + ], + "overall_length_m": 0.148, + "grip_reference": "grip midpoint z=+0.006 (pommel cap -0.058 .. emitter collar +0.070)", + "blade_reference": "pure-effect blade: PlasmaBlade emits at z=+0.09 and runs 0.75 m to the muzzle socket at z=+0.84" + }, + "provenance": "Registry promotion of the existing pack asset plasma_hilt.glb. The GLB is untouched; only the previously-missing attach packet is new, so the plasma sword now resolves through the same per-model path as every other catalogue weapon instead of borrowing the Vibrosword's." +} diff --git a/client-3d/public/assets/pawn-pack/weapons/custom/quarry_chopper_attach.json b/client-3d/public/assets/pawn-pack/weapons/custom/quarry_chopper_attach.json index 406eebff..0f33e36c 100644 --- a/client-3d/public/assets/pawn-pack/weapons/custom/quarry_chopper_attach.json +++ b/client-3d/public/assets/pawn-pack/weapons/custom/quarry_chopper_attach.json @@ -37,9 +37,10 @@ }, "stow_socket": { "space": "spine_03_local", - "pos": [0.16, 0, -0.14], - "rot_deg": [85, -45, 0], - "derivation": "Existing Successor spine_03 back-carry contract." + "pos": [-0.0396, 0.2477, -0.1347], + "rot_deg": [-179.47, -2.96, -9.01], + "arc_lift": 0.13, + "derivation": "Solved from the ACCEPTED Vibrosword back carry, same melee_early family frame as scrapline_machete. The previous placeholder was never consumed (SwordRig forced one global melee transform), leaving this chopper horizontal across the neck with the tip 0.44 m out past the pawn's left shoulder. rot_deg maps this family's +Y blade axis onto the accepted spine-local blade direction and its +Z thickness axis onto the accepted flat-normal, so the heavy blade lies flat on the back with the cutting edge lateral. pos seats this chopper's OWN measured midpoint (y=0.18475) on the accepted carry midpoint plus its half-thickness offset (0.0234 vs the Vibrosword's 0.0237); the short 0.58 m silhouette therefore rides high and centred on the back instead of dangling. Tip lands at spine-local y=-0.2214." }, "measured_runtime": { "mesh_count": 1, diff --git a/client-3d/public/assets/pawn-pack/weapons/custom/scrapline_machete_attach.json b/client-3d/public/assets/pawn-pack/weapons/custom/scrapline_machete_attach.json index 37a89fb6..3ed54de7 100644 --- a/client-3d/public/assets/pawn-pack/weapons/custom/scrapline_machete_attach.json +++ b/client-3d/public/assets/pawn-pack/weapons/custom/scrapline_machete_attach.json @@ -37,9 +37,10 @@ }, "stow_socket": { "space": "spine_03_local", - "pos": [0.16, 0, -0.14], - "rot_deg": [85, -45, 0], - "derivation": "Existing Successor spine_03 back-carry contract." + "pos": [-0.0474, 0.2959, -0.1372], + "rot_deg": [-179.47, -2.96, -9.01], + "arc_lift": 0.14, + "derivation": "Solved from the ACCEPTED Vibrosword back carry (config swordStow 91/9/-93 @ -0.04,0.25,-0.135). The previous value was a placeholder copied from the rifle contract and was never even consumed: SwordRig used one global transform for every melee model, which laid this +Y-blade export HORIZONTALLY ACROSS THE NECK (measured carry pitch +9.8 deg, tip 0.50 m out to the pawn's left at neck height) while the +Z-blade Vibrosword raked correctly down the back at -80 deg. rot_deg reproduces the Vibrosword's spine-local blade direction (0.1564,-0.9875,-0.0172) on this family's +Y blade axis AND its flat-normal (-0.0517,0.0092,-0.9986) on this family's +Z thickness axis, so the blade lies flat on the back with the cutting edge lateral, never toward the body. pos seats this model's OWN measured midpoint (y=0.2335) on the accepted carry midpoint, then shifts along the back normal by this blade's half-thickness (0.0267) minus the Vibrosword's (0.0237), so the flat rests where the accepted flat rested. Blade tip lands at spine-local y=-0.2408, well inside the accepted -0.4305 envelope." }, "measured_runtime": { "mesh_count": 1, diff --git a/client-3d/public/assets/pawn-pack/weapons/custom/wpn_carbine_kiln_attach.json b/client-3d/public/assets/pawn-pack/weapons/custom/wpn_carbine_kiln_attach.json index 455359ce..3eb1a75a 100644 --- a/client-3d/public/assets/pawn-pack/weapons/custom/wpn_carbine_kiln_attach.json +++ b/client-3d/public/assets/pawn-pack/weapons/custom/wpn_carbine_kiln_attach.json @@ -10,29 +10,84 @@ "scale_to_pawn": 1, "attach": "two_hand", "handedness": "right", - "hands": { "grip": "hand_r", "foregrip": "hand_l" }, - "nodes": { "frame": "Module_receiver", "mag": "Module_power_cell" }, + "hands": { + "grip": "hand_r", + "foregrip": "hand_l" + }, + "nodes": { + "frame": "Module_receiver", + "mag": "Module_power_cell" + }, "sockets": { - "grip": [0, -0.091, -0.218], - "foregrip": [0, 0, 0.07], - "muzzle": [0, 0, 0.142], - "stock": [0, 0.0015, -0.5885] + "grip": [ + 0, + -0.091, + -0.218 + ], + "foregrip": [ + 0, + 0, + 0.07 + ], + "muzzle": [ + 0, + 0, + 0.142 + ], + "stock": [ + 0, + 0.0015, + -0.5885 + ] + }, + "orientation": { + "forward": [ + 0, + 0, + 1 + ], + "up": [ + 0, + 1, + 0 + ] }, - "orientation": { "forward": [0, 0, 1], "up": [0, 1, 0] }, "mount_hand_r_local": { - "pos": [-0.07973668, 0.29009154, -0.0348619], - "quat": [-0.09279, 0.83834, 0.53009, -0.08703], + "pos": [ + -0.07973668, + 0.29009154, + -0.0348619 + ], + "quat": [ + -0.09279, + 0.83834, + 0.53009, + -0.08703 + ], "derivation": "Old hand weld translated by measured accepted grip_palm_center delta; preserves the existing hand_r palm contact while the accepted Kiln geometry supplies all sockets." }, "stow_socket": { "space": "spine_03_local", - "pos": [0.16, 0, -0.14], - "rot_deg": [85, -45, 0], + "pos": [ + 0.16, + 0, + -0.14 + ], + "rot_deg": [ + 85, + -45, + 0 + ], + "arc_lift": 0.14, "derivation": "Existing Successor spine_03 back-carry contract; geometry-independent root mount, not a legacy weapon bbox." }, "measured_runtime": { "module_count": 8, - "bbox_m": [0.1528, 0.1975, 0.737], + "bbox_m": [ + 0.1528, + 0.1975, + 0.737 + ], "bore_len_m": 0.737, "grip_reference": "grip_palm_center", "foregrip_reference": "shroud_support_hand_center", diff --git a/client-3d/public/assets/pawn-pack/weapons/custom/wpn_heavy_bastion_lmg_attach.json b/client-3d/public/assets/pawn-pack/weapons/custom/wpn_heavy_bastion_lmg_attach.json index 48a9fc6d..928dea73 100644 --- a/client-3d/public/assets/pawn-pack/weapons/custom/wpn_heavy_bastion_lmg_attach.json +++ b/client-3d/public/assets/pawn-pack/weapons/custom/wpn_heavy_bastion_lmg_attach.json @@ -36,6 +36,11 @@ 0, 0, 0.1 + ], + "foregrip_contact": [ + 0, + -0.0262, + 0.1121 ] }, "orientation": { @@ -76,7 +81,13 @@ -45, 0 ], + "arc_lift": 0.14, "derivation": "Copied from accepted STEN back-carry contract; geometry-independent root mount." }, - "provenance": "reset-zero-01 modular family; textured via STEN 2048 atlas bake pattern (bake_modular.py, deterministic)." + "provenance": "reset-zero-01 modular family; textured via STEN 2048 atlas bake pattern (bake_modular.py, deterministic).", + "support_hand_derivation": "Authored support-WRIST target measured from THIS model's own shroud, replacing the global foregrip contact offset (which assumes the legacy handguard radius at scale 1 and buried the wrist inside fat / scaled / caged housings). y = -(shroud half-height - 0.003) leaves the wrist ~3 mm inside the shroud shell, matching the measured contact on the accepted Kiln carbine (-2.9 mm); z = shroud mid-length, which also walks the forearm forward off the side magazine and the front canister cage.", + "hold": { + "resting_yaw_deg": 24.06, + "derivation": "Bastion LMG carries the longest tail behind the grip in the catalogue (0.424 m from grip socket to butt), and at the class-default resting yaw (0.1 rad / 5.7 deg) its Module_stock PENETRATED the right upper arm during rifle_run_f (-13.5 mm). Bore leveling pins pitch and yaw about the grip socket, so the tail can only be moved by the resting bore yaw - and because the correction pivots ON the grip, the firing-hand weld is untouched. Swept 0.10-0.60 rad in the live Lab across idle/walk/run(x2)/fire/reload(eject,pouch,seat) on both bodies and re-measured after each authored change: 0.30 rad still left -11.7 mm at run, 0.35 rad only +0.6 mm. 0.42 rad (24.06 deg) is the first value with real margin on BOTH bodies in EVERY pose, and it stays under boreLevel.maxYawCorrectionRad (0.45) where the correction saturates. Support-hand contact is unchanged at -3.0 mm." + } } diff --git a/client-3d/public/assets/pawn-pack/weapons/custom/wpn_launcher_flare_net_attach.json b/client-3d/public/assets/pawn-pack/weapons/custom/wpn_launcher_flare_net_attach.json index 0ee1ed8c..4b72d69d 100644 --- a/client-3d/public/assets/pawn-pack/weapons/custom/wpn_launcher_flare_net_attach.json +++ b/client-3d/public/assets/pawn-pack/weapons/custom/wpn_launcher_flare_net_attach.json @@ -36,6 +36,11 @@ 0, 0, 0.1 + ], + "foregrip_contact": [ + 0, + -0.0301, + 0.132 ] }, "orientation": { @@ -76,7 +81,17 @@ -45, 0 ], + "arc_lift": 0.14, "derivation": "Copied from accepted STEN back-carry contract; geometry-independent root mount." }, - "provenance": "reset-zero-01 modular family; textured via STEN 2048 atlas bake pattern (bake_modular.py, deterministic)." + "hold": { + "support_arm": { + "min_elbow_bend_deg": 34, + "shoulder_advance_max_m": 0.05, + "elbow_pole_deg": 56, + "derivation": "Same defect and same measured cure as the Coilgate Scatter: this model's support contact is 599.0 mm idle / 606.3 walk / 650.1 run from the support shoulder against a 584.0 mm arm (upperarm 325 + lowerarm 259), so the solver clamped to full extension and the elbow collapsed onto the shoulder-wrist line at 1.7 mm off-axis. min_elbow_bend_deg 34 puts the elbow back at 84.2 mm off-axis (the accepted Kiln carbine measures 85.1 mm); shoulder_advance_max_m 0.05 caps the clavicle swing that buys the reach back (49.9 mm of measured shoulder travel); elbow_pole_deg 56 sits mid-plateau of the 52-60 deg band that clears Module_receiver on both bodies. Measured male+female across idle/walk/run/fire/reload x3/stow: forearm-vs-receiver worst went from -23.0 mm (walk) / -26.6 mm (run) to +35.6 mm, support wrist stays seated at -2.9..-2.0 mm, foregrip_contact and the firing-hand weld are unchanged." + } + }, + "provenance": "reset-zero-01 modular family; textured via STEN 2048 atlas bake pattern (bake_modular.py, deterministic).", + "support_hand_derivation": "Authored support-WRIST target measured from THIS model's own shroud, replacing the global foregrip contact offset (which assumes the legacy handguard radius at scale 1 and buried the wrist inside fat / scaled / caged housings). y = -(shroud half-height - 0.003) leaves the wrist ~3 mm inside the shroud shell, matching the measured contact on the accepted Kiln carbine (-2.9 mm); z = shroud mid-length, which also walks the forearm forward off the side magazine and the front canister cage. Support z moved 0.0805 -> 0.128 after the full sweep: at rifle_run_f the support FOREARM still crossed Module_receiver by -32.3 mm on both bodies (the canister-cage defect), and walking the hand forward along the handguard is what lifts the forearm clear without moving the palm off the housing." } diff --git a/client-3d/public/assets/pawn-pack/weapons/custom/wpn_pistol_badge_bolt_attach.json b/client-3d/public/assets/pawn-pack/weapons/custom/wpn_pistol_badge_bolt_attach.json index 75e87eb3..e054db2c 100644 --- a/client-3d/public/assets/pawn-pack/weapons/custom/wpn_pistol_badge_bolt_attach.json +++ b/client-3d/public/assets/pawn-pack/weapons/custom/wpn_pistol_badge_bolt_attach.json @@ -22,6 +22,16 @@ -0.028, -0.095 ], + "foregrip": [ + 0, + -0.028, + -0.095 + ], + "foregrip_contact": [ + 0, + -0.095, + -0.075 + ], "muzzle": [ 0, 0, @@ -71,7 +81,9 @@ -45, 0 ], + "arc_lift": 0.14, "derivation": "Copied from accepted STEN back-carry contract; geometry-independent root mount." }, - "provenance": "reset-zero-01 modular family; textured via STEN 2048 atlas bake pattern (bake_modular.py, deterministic)." + "provenance": "reset-zero-01 modular family; textured via STEN 2048 atlas bake pattern (bake_modular.py, deterministic).", + "support_hand_derivation": "Intentional two-hand CUP, replacing a floating left hand. The Badge Bolt authors no foregrip, so the shared two-hand fallback aimed the support wrist at `grip` - the socket the FIRING hand already owns - and the left hand rendered splayed in mid-air beside the slide (measured +1.4 mm off every module, nearest surface Module_receiver). There is no handguard on a 0.26 m pistol to cup, so the support hand wraps the firing hand instead: the wrist target sits on the weapon centreline inside the lower-front of Module_grip (grip spans y -0.129..-0.028, z -0.147..-0.069), which seats the support palm under the firing fingers against the magazine baseplate. Swept 36 candidates across idle/walk/run/fire in the live Lab; this one holds the wrist in firm contact (-5.5 mm inside Module_grip, vs the accepted Kiln carbine's -2.9 mm) in every pose while keeping the forearm 106 mm clear of the slide." } diff --git a/client-3d/public/assets/pawn-pack/weapons/custom/wpn_rifle_slagrail_vanguard_attach.json b/client-3d/public/assets/pawn-pack/weapons/custom/wpn_rifle_slagrail_vanguard_attach.json index 94808da3..907f71d4 100644 --- a/client-3d/public/assets/pawn-pack/weapons/custom/wpn_rifle_slagrail_vanguard_attach.json +++ b/client-3d/public/assets/pawn-pack/weapons/custom/wpn_rifle_slagrail_vanguard_attach.json @@ -27,6 +27,11 @@ 0.0, 0.07 ], + "foregrip_contact": [ + 0, + -0.0235, + 0.0654 + ], "muzzle": [ 0.0, 0.0, @@ -76,7 +81,9 @@ -45, 0 ], + "arc_lift": 0.14, "derivation": "Copied from accepted STEN back-carry contract; geometry-independent root mount." }, - "provenance": "reset-zero-01 modular family; textured via STEN 2048 atlas bake pattern (bake_modular.py, deterministic)." + "provenance": "reset-zero-01 modular family; textured via STEN 2048 atlas bake pattern (bake_modular.py, deterministic).", + "support_hand_derivation": "Authored support-WRIST target measured from THIS model's own shroud, replacing the global foregrip contact offset (which assumes the legacy handguard radius at scale 1 and buried the wrist inside fat / scaled / caged housings). y = -(shroud half-height - 0.003) leaves the wrist ~3 mm inside the shroud shell, matching the measured contact on the accepted Kiln carbine (-2.9 mm); z = shroud mid-length, which also walks the forearm forward off the side magazine and the front canister cage." } diff --git a/client-3d/public/assets/pawn-pack/weapons/custom/wpn_shotgun_coilgate_scatter_attach.json b/client-3d/public/assets/pawn-pack/weapons/custom/wpn_shotgun_coilgate_scatter_attach.json index 04bfbab9..3859cc8a 100644 --- a/client-3d/public/assets/pawn-pack/weapons/custom/wpn_shotgun_coilgate_scatter_attach.json +++ b/client-3d/public/assets/pawn-pack/weapons/custom/wpn_shotgun_coilgate_scatter_attach.json @@ -27,6 +27,11 @@ 0.0, 0.07 ], + "foregrip_contact": [ + 0, + -0.0272, + 0.072 + ], "muzzle": [ 0.0, 0.0, @@ -76,7 +81,17 @@ -45, 0 ], + "arc_lift": 0.14, "derivation": "Copied from accepted STEN back-carry contract; geometry-independent root mount." }, - "provenance": "reset-zero-01 modular family; textured via STEN 2048 atlas bake pattern (bake_modular.py, deterministic)." + "hold": { + "support_arm": { + "min_elbow_bend_deg": 34, + "shoulder_advance_max_m": 0.05, + "elbow_pole_deg": 56, + "derivation": "This model's support contact sits BEYOND the support arm: measured shoulder-to-contact distance is 605.6 mm idle / 613.7 walk / 657.9 run against an arm of upperarm 325 mm + lowerarm 259 mm = 584.0 mm, so the two-bone solver clamped to a+b and locked the arm dead straight (elbow only 1.7 mm off the shoulder-wrist line, versus 85.1 mm on the accepted Kiln carbine and 119-152 mm on the accepted Slugthrower). A rod-straight support arm is what drove the forearm through Module_receiver. min_elbow_bend_deg 34 restores the accepted carbine's elbow offset exactly (84.2 mm measured); shoulder_advance_max_m 0.05 is the girdle swing that pays for the 26.6 mm of reach that bend costs (measured shoulder travel 49.9 mm, 17.2 deg of clavicle rotation) so the palm stays on the handguard instead of falling short; elbow_pole_deg 56 was swept 20-64 and 52-60 is the plateau where the elbow clears the receiver on both bodies (48 deg still grazed at -0.4 mm on the launcher). Measured on male+female across idle/walk/run/fire/reload x3/stow: forearm-vs-receiver worst went from -6.8 mm to +47.2 mm, support wrist stays seated at -3.6..+0.5 mm, bore weld and firing-hand grip untouched (the posture only rotates clavicle_l and upperarm_l about axes through the shoulder and the wrist)." + } + }, + "provenance": "reset-zero-01 modular family; textured via STEN 2048 atlas bake pattern (bake_modular.py, deterministic).", + "support_hand_derivation": "Authored support-WRIST target measured from THIS model's own shroud, replacing the global foregrip contact offset (which assumes the legacy handguard radius at scale 1 and buried the wrist inside fat / scaled / caged housings). y = -(shroud half-height - 0.003) leaves the wrist ~3 mm inside the shroud shell, matching the measured contact on the accepted Kiln carbine (-2.9 mm); z = shroud mid-length, which also walks the forearm forward off the side magazine and the front canister cage. Support z moved 0.065 -> 0.095 after the full sweep: at rifle_run_f the support FOREARM still crossed Module_receiver by -6.8 mm, and walking the hand forward along the handguard is what lifts the forearm clear without moving the palm off the housing." } diff --git a/client-3d/public/assets/pawn-pack/weapons/custom/wpn_smg_sten_mk2_attach.json b/client-3d/public/assets/pawn-pack/weapons/custom/wpn_smg_sten_mk2_attach.json index 092cb617..c6b9b00e 100644 --- a/client-3d/public/assets/pawn-pack/weapons/custom/wpn_smg_sten_mk2_attach.json +++ b/client-3d/public/assets/pawn-pack/weapons/custom/wpn_smg_sten_mk2_attach.json @@ -10,29 +10,89 @@ "scale_to_pawn": 1, "attach": "two_hand", "handedness": "right", - "hands": { "grip": "hand_r", "foregrip": "hand_l" }, - "nodes": { "frame": "Module_receiver", "mag": "Module_magazine" }, + "hands": { + "grip": "hand_r", + "foregrip": "hand_l" + }, + "nodes": { + "frame": "Module_receiver", + "mag": "Module_magazine" + }, "sockets": { - "grip": [0, -0.091, -0.218], - "foregrip": [0, 0, 0.07], - "muzzle": [0, 0, 0.142], - "stock": [0, 0.0015, -0.5885] + "grip": [ + 0, + -0.091, + -0.218 + ], + "foregrip": [ + 0, + 0, + 0.07 + ], + "foregrip_contact": [ + 0, + -0.018, + 0.07 + ], + "muzzle": [ + 0, + 0, + 0.142 + ], + "stock": [ + 0, + 0.0015, + -0.5885 + ] + }, + "orientation": { + "forward": [ + 0, + 0, + 1 + ], + "up": [ + 0, + 1, + 0 + ] }, - "orientation": { "forward": [0, 0, 1], "up": [0, 1, 0] }, "mount_hand_r_local": { - "pos": [-0.07973668, 0.29009154, -0.0348619], - "quat": [-0.09279, 0.83834, 0.53009, -0.08703], + "pos": [ + -0.07973668, + 0.29009154, + -0.0348619 + ], + "quat": [ + -0.09279, + 0.83834, + 0.53009, + -0.08703 + ], "derivation": "Old hand weld translated by measured accepted grip_palm_center delta; preserves the existing hand_r palm contact while the accepted STEN geometry supplies all sockets." }, "stow_socket": { "space": "spine_03_local", - "pos": [0.16, 0, -0.14], - "rot_deg": [85, -45, 0], + "pos": [ + 0.16, + 0, + -0.14 + ], + "rot_deg": [ + 85, + -45, + 0 + ], + "arc_lift": 0.14, "derivation": "Existing Successor spine_03 back-carry contract; geometry-independent root mount, not a legacy weapon bbox." }, "measured_runtime": { "module_count": 8, - "bbox_m": [0.32795, 0.1975, 0.737], + "bbox_m": [ + 0.32795, + 0.1975, + 0.737 + ], "bore_len_m": 0.737, "grip_reference": "grip_palm_center", "foregrip_reference": "shroud_support_hand_center", diff --git a/client-3d/public/assets/pawn-pack/weapons/weapons_manifest.json b/client-3d/public/assets/pawn-pack/weapons/weapons_manifest.json index 420f3631..00ca4160 100644 --- a/client-3d/public/assets/pawn-pack/weapons/weapons_manifest.json +++ b/client-3d/public/assets/pawn-pack/weapons/weapons_manifest.json @@ -31,8 +31,19 @@ "tier_hint": "starter", "scale": 1 }, + { + "id": "plasma_sword", + "glb": "../plasma_hilt.glb", + "attach": "custom/plasma_hilt_attach.json", + "class": "melee", + "track": "brawler", + "label": "Plasma Sword", + "tier_hint": "exotic", + "scale": 1 + }, { "id": "wpn_pistol", + "item_ids": [3122], "glb": "custom/wpn_pistol_badge_bolt.glb", "attach": "custom/wpn_pistol_badge_bolt_attach.json", "class": "pistol", @@ -43,6 +54,7 @@ }, { "id": "wpn_smg", + "item_ids": [3101, 3111], "glb": "custom/wpn_smg_sten_mk2.glb", "attach": "custom/wpn_smg_sten_mk2_attach.json", "class": "smg", @@ -53,6 +65,7 @@ }, { "id": "wpn_carbine", + "item_ids": [3112], "glb": "custom/wpn_carbine_kiln.glb", "attach": "custom/wpn_carbine_kiln_attach.json", "class": "rifle", @@ -73,6 +86,7 @@ }, { "id": "wpn_assault", + "item_ids": [3123], "glb": "custom/wpn_rifle_slagrail_vanguard.glb", "attach": "custom/wpn_rifle_slagrail_vanguard_attach.json", "class": "rifle", @@ -83,6 +97,7 @@ }, { "id": "wpn_shotgun", + "item_ids": [3124], "glb": "custom/wpn_shotgun_coilgate_scatter.glb", "attach": "custom/wpn_shotgun_coilgate_scatter_attach.json", "class": "shotgun", @@ -93,16 +108,18 @@ }, { "id": "wpn_sniper", + "item_ids": [3125], "glb": "custom/wpn_carbine_kiln.glb", "attach": "custom/wpn_carbine_kiln_attach.json", "class": "rifle", "track": "commando", - "label": "Kiln Long Pattern (interim)", + "label": "Kiln Long Pattern", "tier_hint": "S", "scale": 1 }, { "id": "wpn_heavy", + "item_ids": [3126], "glb": "custom/wpn_heavy_bastion_lmg.glb", "attach": "custom/wpn_heavy_bastion_lmg_attach.json", "class": "rifle", @@ -113,6 +130,7 @@ }, { "id": "wpn_launcher", + "item_ids": [3127], "glb": "custom/wpn_launcher_flare_net.glb", "attach": "custom/wpn_launcher_flare_net_attach.json", "class": "launcher", diff --git a/client-3d/public/assets/world-items/aircon_rooftop.glb b/client-3d/public/assets/world-items/aircon_rooftop.glb index cb20ccd4..a8c0a795 100644 --- a/client-3d/public/assets/world-items/aircon_rooftop.glb +++ b/client-3d/public/assets/world-items/aircon_rooftop.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ece6e1d5ef6ada9cf975130772ff0e2935363ab2de83cd8018c0b263c652effe -size 283264 +oid sha256:a72299f6f9153f8d2b65a14bbe23e76c8ae949536dd63da04f8f28fc6f20403a +size 283440 diff --git a/client-3d/public/assets/world-items/bank_terminal.provenance.json b/client-3d/public/assets/world-items/bank_terminal.provenance.json index 59c4d867..a5ad5079 100644 --- a/client-3d/public/assets/world-items/bank_terminal.provenance.json +++ b/client-3d/public/assets/world-items/bank_terminal.provenance.json @@ -38,14 +38,14 @@ "denylist_audit": "passed: no franchise/IP terms in names, materials, node names, or artifacts", "text": "Hand-authored parametric part program (no generative model): Dustgate outdoor bank terminal kiosk \u2014 corbel-supported armored steel head over a cast column, brass side rails, hooded amber ledger screen behind glass, 12-key keypad with a glowing accept key, credit-chip slot, receipt slit with a protruding stub, and an open dispenser tray. Grounded at y=0, 2.06 m tall at pawn scale." }, - "regeneration_command": "/snap/bin/blender -b --factory-startup --python-exit-code 1 -P tools/successor/assets/build_cloning_facility.py", + "regeneration_command": "/snap/bin/blender -b --factory-startup --python-exit-code 1 -P tools/successor/assets/build_bank_terminal.py", "rights": { "redistribution_status": "authorized for Successor runtime distribution only; no standalone reuse grant", "source_license": "Successor proprietary project asset; all rights reserved" }, "schema": "successor-asset-provenance/1", "seed": null, - "source_blend_or_script": "tools/successor/assets/build_cloning_facility.py (deterministic parametric part program; no .blend needed)", + "source_blend_or_script": "tools/successor/assets/build_bank_terminal.py (deterministic parametric part program; no .blend needed)", "tool": { "name": "blender-bpy-headless", "tool_snapshot_id": "blender-5.2.0", diff --git a/client-3d/public/assets/world-items/bank_terminal_civic.glb b/client-3d/public/assets/world-items/bank_terminal_civic.glb index 3fa993c3..4f2fcd86 100644 --- a/client-3d/public/assets/world-items/bank_terminal_civic.glb +++ b/client-3d/public/assets/world-items/bank_terminal_civic.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb825f81370b014660499bcb7fc67480895b3734683ba6647234203b996c2151 -size 1034480 +oid sha256:5871565fdaff468c89c395bcb8f813dedf6b78a1d4b7d5ac0d50d22839579c62 +size 1034704 diff --git a/client-3d/public/assets/world-items/barricade_concrete.glb b/client-3d/public/assets/world-items/barricade_concrete.glb index 770c4afd..2d9b0eb0 100644 --- a/client-3d/public/assets/world-items/barricade_concrete.glb +++ b/client-3d/public/assets/world-items/barricade_concrete.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dfcdec624894ed6b74652f39074e62fbffd633817453a66a1c8bf9f8c67777d7 -size 3213856 +oid sha256:1a6b3e1fe00a12c4dd04e3db1539fb6697b823a84814107024229cb716bcebf2 +size 3213900 diff --git a/client-3d/public/assets/world-items/battery_pack_industrial.glb b/client-3d/public/assets/world-items/battery_pack_industrial.glb index 717a2cbf..8d0e5842 100644 --- a/client-3d/public/assets/world-items/battery_pack_industrial.glb +++ b/client-3d/public/assets/world-items/battery_pack_industrial.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8cf786cc80df647fd3617749636d41edf76fc8ca736be6f02f1d54bf13c195f3 -size 319252 +oid sha256:2bbfbb6f662bad1495133f370f8f4559c1b7f6bbd4b47fbd784cf2f524e8713e +size 319516 diff --git a/client-3d/public/assets/world-items/campfire_frontier.glb b/client-3d/public/assets/world-items/campfire_frontier.glb index 1292b3e1..9495b813 100644 --- a/client-3d/public/assets/world-items/campfire_frontier.glb +++ b/client-3d/public/assets/world-items/campfire_frontier.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0e26fb5eadc7d1ebb869c7bc10f2d9dd1ac816e56aea5193985bbf0da6e553c1 -size 2711220 +oid sha256:83a56495eb3cb58ceca8769e8e04c34960ac44c60798ed31d784701449d56bc0 +size 2711264 diff --git a/client-3d/public/assets/world-items/campfire_scout.glb b/client-3d/public/assets/world-items/campfire_scout.glb index 2a4229e6..8398fccd 100644 --- a/client-3d/public/assets/world-items/campfire_scout.glb +++ b/client-3d/public/assets/world-items/campfire_scout.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8dccd5dd003b6d1544c27a49ee65266cb5204e99c3d7e01e80568e61b85672c6 -size 3396452 +oid sha256:f1a50bf3b3c319d3f908cb271294b4e4733408b0014ecf39291b9d10f48cab64 +size 3396496 diff --git a/client-3d/public/assets/world-items/chair_frontier_a.glb b/client-3d/public/assets/world-items/chair_frontier_a.glb index 9aa5f20a..b7b40de2 100644 --- a/client-3d/public/assets/world-items/chair_frontier_a.glb +++ b/client-3d/public/assets/world-items/chair_frontier_a.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:04575d7fe54e7762bedcf1622a15aa31f2b76decca8c2c6f6ce2305a3d03bcb6 -size 3423744 +oid sha256:2372bb1119f40f29da45249cdc32dfffb4d8d10c806f7eb2ea87cf0f73b22951 +size 3423788 diff --git a/client-3d/public/assets/world-items/chair_frontier_b.glb b/client-3d/public/assets/world-items/chair_frontier_b.glb index da786431..5f4cfec9 100644 --- a/client-3d/public/assets/world-items/chair_frontier_b.glb +++ b/client-3d/public/assets/world-items/chair_frontier_b.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ba6d6901bfde01a5c858fb80db89b29f5e18dd5cb42bb59946fe5d55893c15f2 -size 3384220 +oid sha256:1b3dd70fca9d3199290b8d55cb78bec82995925e9012becf47732d55a8de382e +size 3384264 diff --git a/client-3d/public/assets/world-items/clone_pod.glb b/client-3d/public/assets/world-items/clone_pod.glb index 0c10f5e7..c4701ac7 100644 --- a/client-3d/public/assets/world-items/clone_pod.glb +++ b/client-3d/public/assets/world-items/clone_pod.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:87a99d66eca1c0bf805bdc642d1b154384b360278badbe7e9d931a141a695fc4 -size 56532 +oid sha256:49a0ef85eb1059f6ae6c251028f6b6c146274101587080652e38389d453ac38e +size 961892 diff --git a/client-3d/public/assets/world-items/clone_pod.provenance.json b/client-3d/public/assets/world-items/clone_pod.provenance.json index 9c3f4d80..009247a2 100644 --- a/client-3d/public/assets/world-items/clone_pod.provenance.json +++ b/client-3d/public/assets/world-items/clone_pod.provenance.json @@ -3,53 +3,79 @@ "human_approvals": [], "produced_by": [ { - "agent_instance_id": "CloneFacilityAsset", - "model": "Fable 5", + "agent_instance_id": "CloneFacilityOpus5", + "model": "Opus 5", "provider": "anthropic", "role": "content-author", - "run_id": "cloning-facility-20260716" + "run_id": "cloning-facility-opus5-20260803" } ], "reviewed_by_agents": [ { - "agent_instance_id": "CloneFacilityAsset", - "notes": "numeric parsed-GLB gates + multi-angle render review in .game-lab/cloning-facility-20260716/renders/", + "agent_instance_id": "CloneFacilityOpus5", + "notes": "numeric gate: {\"footprint_matches_spec\": true, \"height_in_range\": true, \"root_node_present\": true, \"tri_budget\": true, \"textures_embedded\": true, \"atlas_material_present\": true, \"no_animations\": true, \"sits_on_grade\": true, \"chamber_fits_pawn\": true}", "role": "judge" } ] }, - "asset_hash": "sha256:87a99d66eca1c0bf805bdc642d1b154384b360278badbe7e9d931a141a695fc4", + "asset_hash": "sha256:615e9de43249217429a6994d31178ce0eeb908ce0c8a54c8c68b87ed94230eb7", "asset_id": "clone_pod", "asset_kind": "model_glb", "asset_path": "client-3d/public/assets/world-items/clone_pod.glb", - "gate_report": ".game-lab/cloning-facility-20260716/gate.json", + "gate_report": ".game-lab/cloning-facility-opus5-20260803/gate.json", "human_edits": [], "input_assets": [ { - "path": "client-3d/public/assets/world-items/house_h1_manifest.json", - "purpose": "structural_reference (shoulder-house language + door contract)" + "path": "/home/lycaon/dev/worktrees/successor-buildkit-20260730/tools/successor/assets/buildkit-opus5/src + style-reference/", + "purpose": "style authority: sinter/panel/screed/roofmetal/bronze/gunmetal material register and heavy-base-over-light-skin tectonics" }, { - "path": "tools/successor/extract-structure-collision.mjs", - "purpose": "collision_sidecar_contract" + "path": "/home/lycaon/dev/worktrees/successor-buildkit-20260730/tools/successor/assets/buildkit-opus5/runtime-buildings/modular/home_modular_starter.glb", + "purpose": "promoted modular runtime reference for silhouette density, facade articulation and payload budget", + "sha256": "sha256:167bc8d83ab745c1a888a755873eebae635e9660f8107272efbfeb19c7ddd8e2" + }, + { + "path": "/home/lycaon/dev/worktrees/successor-buildkit-20260730/tools/successor/assets/buildkit-opus5/runtime-buildings/modular/home_modular_wing.glb", + "purpose": "promoted modular runtime reference", + "sha256": "sha256:7410ced1f22bf48cd82b7490aa817f1eb5b9dbe170e1db81f96be98ca8beb675" + }, + { + "path": "/home/lycaon/dev/worktrees/successor-buildkit-20260730/tools/successor/assets/buildkit-opus5/runtime-buildings/modular/home_modular_court.glb", + "purpose": "promoted modular runtime reference", + "sha256": "sha256:49264de5adaf417556ac6df1ec937b4aee18bed2c6e88c976683e1eb34c3ecc0" + }, + { + "path": "/home/lycaon/dev/worktrees/successor-humanoid-runtime-refit-20260802/client-3d/public/assets/pawn-pack/pawn_male.glb", + "purpose": "approved humanoid source, posed and frozen into the occupied vat; armature and all 47 clips discarded before export", + "sha256": "sha256:cf9cdc574d0b5dc3caf91b42e4517e77dfd56b062d424a9df2ade7bb2f047dee" + }, + { + "path": "client-3d/public/assets/world-items/cloning_facility_collision.json", + "purpose": "frozen structural collision contract (nine named wall proxies plus the closed-door box), reproduced unchanged", + "sha256": "sha256:71362762c62fa698df5c1bb57ad2273a3434db732c4e53151be890343a4d3ec9" + }, + { + "path": "client-3d/src/render/props.ts", + "purpose": "runtime material contract: textured world props render unlit, so the base colour must carry the lit read", + "sha256": "sha256:a9c1a207d105c3f2f471ad2a1cd3e2924bfbea60c90df80bf8868bf1e8ed83a5" } ], "prompt": { - "denylist_audit": "passed: no franchise/IP terms in names, materials, node names, or artifacts", - "text": "Hand-authored parametric part program (no generative model): Dustgate standing clone pod \u2014 tall faceted enamel drum on an anchored octagonal plinth with fluid ports, broad 130-degree front glass canopy over a full-height cyan suspension column, crown ring and manifold, twin flanged service risers. Grounded at y=0, 2.44 m tall." + "denylist_audit": "passed: no franchise or trademark terms in node names, material names, textures or artifacts; the entry crest and console graphics are authored pictograms with no lettering", + "text": "Standalone clone vat: octagonal sintered plinth on levelling feet, enamelled machine base with a louvred service door and bronze data plate, a 300-degree glazed chamber on a gunmetal service spine holding a foot plate, heel cups, spine post and cranial cradle, charged with culture fluid to a bronze meniscus, crowned by twin accumulators, a valve tree and a lift eye, with an amber standby light stack and looming umbilicals." }, - "regeneration_command": "/snap/bin/blender -b --factory-startup --python-exit-code 1 -P tools/successor/assets/build_cloning_facility.py", + "regeneration_command": "/snap/bin/blender -b --factory-startup -noaudio --python-exit-code 1 -P tools/successor/assets/cloning-facility-opus5/src/build_clone_suite.py", "rights": { "redistribution_status": "authorized for Successor runtime distribution only; no standalone reuse grant", "source_license": "Successor proprietary project asset; all rights reserved" }, "schema": "successor-asset-provenance/1", "seed": null, - "source_blend_or_script": "tools/successor/assets/build_cloning_facility.py (deterministic parametric part program; no .blend needed)", + "source_blend_or_script": "tools/successor/assets/cloning-facility-opus5/src/ (deterministic parametric part program; checkpoint .blend in blend/)", "tool": { "name": "blender-bpy-headless", - "tool_snapshot_id": "blender-5.2.0", - "version": "5.2.0" + "tool_snapshot_id": "blender-5.2.0 LTS", + "version": "5.2.0 LTS" }, - "tri_count": 888 + "tri_count": 3690 } diff --git a/client-3d/public/assets/world-items/clone_pod_manifest.json b/client-3d/public/assets/world-items/clone_pod_manifest.json new file mode 100644 index 00000000..a01c7a57 --- /dev/null +++ b/client-3d/public/assets/world-items/clone_pod_manifest.json @@ -0,0 +1,61 @@ +{ + "asset": { + "bytes": 961848, + "file": "clone_pod.glb", + "images": [ + { + "bytes": 137419, + "embedded": true, + "mime": "image/png", + "name": "clone_pod_normal" + }, + { + "bytes": 342169, + "embedded": true, + "mime": "image/jpeg", + "name": "clone_pod_basecolor" + }, + { + "bytes": 244358, + "embedded": true, + "mime": "image/png", + "name": "clone_pod_orm" + } + ], + "sha256": "sha256:615e9de43249217429a6994d31178ce0eeb908ce0c8a54c8c68b87ed94230eb7" + }, + "bake": { + "atlasObjects": 1, + "baseColorCarriesLighting": true, + "runtimeSpecialObjects": 3, + "uvCoverage": 0.5163 + }, + "bbox_span_m": [ + 0.95, + 2.7239, + 0.95 + ], + "footprint_m": [ + 0.95, + 0.95 + ], + "front": "+Z", + "height_range_m": [ + 2.55, + 2.8 + ], + "materials": [ + "CF_Body", + "CF_Fluid", + "CF_GlassClear", + "CF_Glow", + "CF_GlowAmber", + "CF_GlowWarm" + ], + "prop": "clone_pod", + "root_node": "Gear_clone_pod", + "runtime_scale_at_1x1_cell": 1.052632, + "schema": "successor.runtime-prop-manifest.v1", + "tri_count": 3690, + "units": "m" +} diff --git a/client-3d/public/assets/world-items/clone_terminal.glb b/client-3d/public/assets/world-items/clone_terminal.glb index 9ab24acc..da5b5e52 100644 --- a/client-3d/public/assets/world-items/clone_terminal.glb +++ b/client-3d/public/assets/world-items/clone_terminal.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f7d03b0715d08dddfb18b1c37f7235cce1b7bb05aab5043e87f9c5ec3ca4809f -size 49904 +oid sha256:ab2c7625a71fdc0362927b20905a79ccbd2ef39605dbe5594228e233630e2dd2 +size 602428 diff --git a/client-3d/public/assets/world-items/clone_terminal.provenance.json b/client-3d/public/assets/world-items/clone_terminal.provenance.json index 8945364a..8a52b6f2 100644 --- a/client-3d/public/assets/world-items/clone_terminal.provenance.json +++ b/client-3d/public/assets/world-items/clone_terminal.provenance.json @@ -3,53 +3,79 @@ "human_approvals": [], "produced_by": [ { - "agent_instance_id": "CloneFacilityAsset", - "model": "Fable 5", + "agent_instance_id": "CloneFacilityOpus5", + "model": "Opus 5", "provider": "anthropic", "role": "content-author", - "run_id": "cloning-facility-20260716" + "run_id": "cloning-facility-opus5-20260803" } ], "reviewed_by_agents": [ { - "agent_instance_id": "CloneFacilityAsset", - "notes": "numeric parsed-GLB gates + multi-angle render review in .game-lab/cloning-facility-20260716/renders/", + "agent_instance_id": "CloneFacilityOpus5", + "notes": "numeric gate: {\"footprint_matches_spec\": true, \"height_in_range\": true, \"root_node_present\": true, \"tri_budget\": true, \"textures_embedded\": true, \"atlas_material_present\": true, \"no_animations\": true, \"sits_on_grade\": true}", "role": "judge" } ] }, - "asset_hash": "sha256:f7d03b0715d08dddfb18b1c37f7235cce1b7bb05aab5043e87f9c5ec3ca4809f", + "asset_hash": "sha256:a80270f416040b68a47b152d834bba4760ec7362546bc9b883e13a372878bbbd", "asset_id": "clone_terminal", "asset_kind": "model_glb", "asset_path": "client-3d/public/assets/world-items/clone_terminal.glb", - "gate_report": ".game-lab/cloning-facility-20260716/gate.json", + "gate_report": ".game-lab/cloning-facility-opus5-20260803/gate.json", "human_edits": [], "input_assets": [ { - "path": "client-3d/public/assets/world-items/house_h1_manifest.json", - "purpose": "structural_reference (shoulder-house language + door contract)" + "path": "/home/lycaon/dev/worktrees/successor-buildkit-20260730/tools/successor/assets/buildkit-opus5/src + style-reference/", + "purpose": "style authority: sinter/panel/screed/roofmetal/bronze/gunmetal material register and heavy-base-over-light-skin tectonics" }, { - "path": "tools/successor/extract-structure-collision.mjs", - "purpose": "collision_sidecar_contract" + "path": "/home/lycaon/dev/worktrees/successor-buildkit-20260730/tools/successor/assets/buildkit-opus5/runtime-buildings/modular/home_modular_starter.glb", + "purpose": "promoted modular runtime reference for silhouette density, facade articulation and payload budget", + "sha256": "sha256:167bc8d83ab745c1a888a755873eebae635e9660f8107272efbfeb19c7ddd8e2" + }, + { + "path": "/home/lycaon/dev/worktrees/successor-buildkit-20260730/tools/successor/assets/buildkit-opus5/runtime-buildings/modular/home_modular_wing.glb", + "purpose": "promoted modular runtime reference", + "sha256": "sha256:7410ced1f22bf48cd82b7490aa817f1eb5b9dbe170e1db81f96be98ca8beb675" + }, + { + "path": "/home/lycaon/dev/worktrees/successor-buildkit-20260730/tools/successor/assets/buildkit-opus5/runtime-buildings/modular/home_modular_court.glb", + "purpose": "promoted modular runtime reference", + "sha256": "sha256:49264de5adaf417556ac6df1ec937b4aee18bed2c6e88c976683e1eb34c3ecc0" + }, + { + "path": "/home/lycaon/dev/worktrees/successor-humanoid-runtime-refit-20260802/client-3d/public/assets/pawn-pack/pawn_male.glb", + "purpose": "approved humanoid source, posed and frozen into the occupied vat; armature and all 47 clips discarded before export", + "sha256": "sha256:cf9cdc574d0b5dc3caf91b42e4517e77dfd56b062d424a9df2ade7bb2f047dee" + }, + { + "path": "client-3d/public/assets/world-items/cloning_facility_collision.json", + "purpose": "frozen structural collision contract (nine named wall proxies plus the closed-door box), reproduced unchanged", + "sha256": "sha256:71362762c62fa698df5c1bb57ad2273a3434db732c4e53151be890343a4d3ec9" + }, + { + "path": "client-3d/src/render/props.ts", + "purpose": "runtime material contract: textured world props render unlit, so the base colour must carry the lit read", + "sha256": "sha256:a9c1a207d105c3f2f471ad2a1cd3e2924bfbea60c90df80bf8868bf1e8ed83a5" } ], "prompt": { - "denylist_audit": "passed: no franchise/IP terms in names, materials, node names, or artifacts", - "text": "Hand-authored parametric part program (no generative model): Dustgate indoor clone terminal \u2014 clinical enamel spine with a steel crown band, neck-mounted angled cyan readout, palm-rest tray with a tag-reader ring, status lights, anchored base pads, floor cable foot with a collar. Grounded at y=0, ~1.74 m tall at pawn scale." + "denylist_audit": "passed: no franchise or trademark terms in node names, material names, textures or artifacts; the entry crest and console graphics are authored pictograms with no lettering", + "text": "Clone control console: splayed gunmetal foot with a cable gland, enamel column with a bronze datum, bronze-edged deck carrying a raked switch bank, palm reader and sample dock, and a raked display head whose emissive face carries an authored pictogram readout - body chart with growth fill, viability ring, vitals traces and bay-status pips, with no lettering." }, - "regeneration_command": "/snap/bin/blender -b --factory-startup --python-exit-code 1 -P tools/successor/assets/build_cloning_facility.py", + "regeneration_command": "/snap/bin/blender -b --factory-startup -noaudio --python-exit-code 1 -P tools/successor/assets/cloning-facility-opus5/src/build_clone_suite.py", "rights": { "redistribution_status": "authorized for Successor runtime distribution only; no standalone reuse grant", "source_license": "Successor proprietary project asset; all rights reserved" }, "schema": "successor-asset-provenance/1", "seed": null, - "source_blend_or_script": "tools/successor/assets/build_cloning_facility.py (deterministic parametric part program; no .blend needed)", + "source_blend_or_script": "tools/successor/assets/cloning-facility-opus5/src/ (deterministic parametric part program; checkpoint .blend in blend/)", "tool": { "name": "blender-bpy-headless", - "tool_snapshot_id": "blender-5.2.0", - "version": "5.2.0" + "tool_snapshot_id": "blender-5.2.0 LTS", + "version": "5.2.0 LTS" }, - "tri_count": 748 + "tri_count": 832 } diff --git a/client-3d/public/assets/world-items/clone_terminal_manifest.json b/client-3d/public/assets/world-items/clone_terminal_manifest.json new file mode 100644 index 00000000..10f21173 --- /dev/null +++ b/client-3d/public/assets/world-items/clone_terminal_manifest.json @@ -0,0 +1,65 @@ +{ + "asset": { + "bytes": 602384, + "file": "clone_terminal.glb", + "images": [ + { + "bytes": 191962, + "embedded": true, + "mime": "image/png", + "name": "clone_terminal_normal" + }, + { + "bytes": 101193, + "embedded": true, + "mime": "image/jpeg", + "name": "clone_terminal_basecolor" + }, + { + "bytes": 235722, + "embedded": true, + "mime": "image/png", + "name": "clone_terminal_orm" + }, + { + "bytes": 14117, + "embedded": true, + "mime": "image/png", + "name": "clone_screen_emissive" + } + ], + "sha256": "sha256:a80270f416040b68a47b152d834bba4760ec7362546bc9b883e13a372878bbbd" + }, + "bake": { + "atlasObjects": 1, + "baseColorCarriesLighting": true, + "runtimeSpecialObjects": 2, + "uvCoverage": 0.8253 + }, + "bbox_span_m": [ + 0.956, + 1.78, + 0.7916 + ], + "footprint_m": [ + 0.956, + 0.792 + ], + "front": "+Z", + "height_range_m": [ + 1.62, + 1.86 + ], + "materials": [ + "CF_Body", + "CF_Glow", + "CF_GlowAmber", + "CF_Screen" + ], + "prop": "clone_terminal", + "root_node": "Gear_clone_terminal", + "runtime_scale_at_1x1_cell": 1.046025, + "schema": "successor.runtime-prop-manifest.v1", + "tri_count": 832, + "units": "m" +} diff --git a/client-3d/public/assets/world-items/cloning_facility.glb b/client-3d/public/assets/world-items/cloning_facility.glb index 982c80c3..c3b1f307 100644 --- a/client-3d/public/assets/world-items/cloning_facility.glb +++ b/client-3d/public/assets/world-items/cloning_facility.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:26f3fa6ed6ee1c01280537a4d4d56f9fce9b4092df0f281a7f704a7d209eb556 -size 318328 +oid sha256:7a2cac46e15119a089215b4178146505527645f44f37b134e6c1335906d6709f +size 4657076 diff --git a/client-3d/public/assets/world-items/cloning_facility.provenance.json b/client-3d/public/assets/world-items/cloning_facility.provenance.json index bcbe705a..48dea780 100644 --- a/client-3d/public/assets/world-items/cloning_facility.provenance.json +++ b/client-3d/public/assets/world-items/cloning_facility.provenance.json @@ -3,53 +3,79 @@ "human_approvals": [], "produced_by": [ { - "agent_instance_id": "CloneFacilityAsset", - "model": "Fable 5", + "agent_instance_id": "CloneFacilityOpus5", + "model": "Opus 5", "provider": "anthropic", "role": "content-author", - "run_id": "cloning-facility-20260716" + "run_id": "cloning-facility-opus5-20260803" } ], "reviewed_by_agents": [ { - "agent_instance_id": "CloneFacilityAsset", - "notes": "numeric parsed-GLB gates + multi-angle render review in .game-lab/cloning-facility-20260716/renders/", + "agent_instance_id": "CloneFacilityOpus5", + "notes": "numeric gates on the parsed GLB plus a multi-angle render review under .game-lab/cloning-facility-opus5-20260803/proofs/", "role": "judge" } ] }, - "asset_hash": "sha256:26f3fa6ed6ee1c01280537a4d4d56f9fce9b4092df0f281a7f704a7d209eb556", + "asset_hash": "sha256:d70eb11b646a6e210ea7af833627d6e504b3b1b4f810cbd350579d3524486619", "asset_id": "cloning_facility", "asset_kind": "model_glb", "asset_path": "client-3d/public/assets/world-items/cloning_facility.glb", - "gate_report": ".game-lab/cloning-facility-20260716/gate.json", + "gate_report": ".game-lab/cloning-facility-opus5-20260803/gate.json", "human_edits": [], "input_assets": [ { - "path": "client-3d/public/assets/world-items/house_h1_manifest.json", - "purpose": "structural_reference (shoulder-house language + door contract)" + "path": "/home/lycaon/dev/worktrees/successor-buildkit-20260730/tools/successor/assets/buildkit-opus5/src + style-reference/", + "purpose": "style authority: sinter/panel/screed/roofmetal/bronze/gunmetal material register and heavy-base-over-light-skin tectonics" }, { - "path": "tools/successor/extract-structure-collision.mjs", - "purpose": "collision_sidecar_contract" + "path": "/home/lycaon/dev/worktrees/successor-buildkit-20260730/tools/successor/assets/buildkit-opus5/runtime-buildings/modular/home_modular_starter.glb", + "purpose": "promoted modular runtime reference for silhouette density, facade articulation and payload budget", + "sha256": "sha256:167bc8d83ab745c1a888a755873eebae635e9660f8107272efbfeb19c7ddd8e2" + }, + { + "path": "/home/lycaon/dev/worktrees/successor-buildkit-20260730/tools/successor/assets/buildkit-opus5/runtime-buildings/modular/home_modular_wing.glb", + "purpose": "promoted modular runtime reference", + "sha256": "sha256:7410ced1f22bf48cd82b7490aa817f1eb5b9dbe170e1db81f96be98ca8beb675" + }, + { + "path": "/home/lycaon/dev/worktrees/successor-buildkit-20260730/tools/successor/assets/buildkit-opus5/runtime-buildings/modular/home_modular_court.glb", + "purpose": "promoted modular runtime reference", + "sha256": "sha256:49264de5adaf417556ac6df1ec937b4aee18bed2c6e88c976683e1eb34c3ecc0" + }, + { + "path": "/home/lycaon/dev/worktrees/successor-humanoid-runtime-refit-20260802/client-3d/public/assets/pawn-pack/pawn_male.glb", + "purpose": "approved humanoid source, posed and frozen into the occupied vat; armature and all 47 clips discarded before export", + "sha256": "sha256:cf9cdc574d0b5dc3caf91b42e4517e77dfd56b062d424a9df2ade7bb2f047dee" + }, + { + "path": "client-3d/public/assets/world-items/cloning_facility_collision.json", + "purpose": "frozen structural collision contract (nine named wall proxies plus the closed-door box), reproduced unchanged", + "sha256": "sha256:71362762c62fa698df5c1bb57ad2273a3434db732c4e53151be890343a4d3ec9" + }, + { + "path": "client-3d/src/render/props.ts", + "purpose": "runtime material contract: textured world props render unlit, so the base colour must carry the lit read", + "sha256": "sha256:a9c1a207d105c3f2f471ad2a1cd3e2924bfbea60c90df80bf8868bf1e8ed83a5" } ], "prompt": { - "denylist_audit": "passed: no franchise/IP terms in names, materials, node names, or artifacts", - "text": "Hand-authored parametric part program (no generative model): Dustgate cloning facility, 10x8-cell enterable civic landmark in the shipped shoulder-house language \u2014 chamfered cast shoulders over a grounded foundation curb, stepped parapets, offset protruding entry bay with a broad 2.30 m pressure-vessel pocket door (door_slide + door_open/door_close, house door contract widened) traced by an energized bioseal gasket, twin-figure resurrection crest, rooftop process hall ducted into the filtration tower, bio-reservoir with feed manifold, and a raised pod dais with three deep alcove bays. Floor top authored at y=0.02 m per the bank/clone contract; mesh 9.5x7.6 m so runtime scale at 10x8 cells is 1.052632 (house/podtent pawn parity)." + "denylist_audit": "passed: no franchise or trademark terms in node names, material names, textures or artifacts; the entry crest and console graphics are authored pictograms with no lettering", + "text": "Dustgate Clone Vault - a 10x8-cell enterable biomedical-industrial landmark in the buildkit-opus5 frontier register: sintered ground plate and plinth, bleached panel skin with deep gunmetal reveals, a bronze datum at the construction line, corner piers carried past a stepped parapet, a corbelled filtration tower over the back-left roof, a rooftop process hall, bio reservoir and condenser bank, and a raised portal mass with an energised bioseal gasket and a twin-figure resurrection crest. Inside: three full-size cloning vats on the left wall - one open and vacant, one charged and occupied by a posed 1.725 m specimen, one primed on amber standby - served by an aisle gantry with an articulated arm, a back-wall process bank of buffer vessels, pumps and a valve manifold, and a right-wall control bank with a monitor wall, reagent rack and autoclave." }, - "regeneration_command": "/snap/bin/blender -b --factory-startup --python-exit-code 1 -P tools/successor/assets/build_cloning_facility.py", + "regeneration_command": "/snap/bin/blender -b --factory-startup -noaudio --python-exit-code 1 -P tools/successor/assets/cloning-facility-opus5/src/build_clone_suite.py", "rights": { "redistribution_status": "authorized for Successor runtime distribution only; no standalone reuse grant", "source_license": "Successor proprietary project asset; all rights reserved" }, "schema": "successor-asset-provenance/1", "seed": null, - "source_blend_or_script": "tools/successor/assets/build_cloning_facility.py (deterministic parametric part program; no .blend needed)", + "source_blend_or_script": "tools/successor/assets/cloning-facility-opus5/src/ (deterministic parametric part program; checkpoint .blend in blend/)", "tool": { "name": "blender-bpy-headless", - "tool_snapshot_id": "blender-5.2.0", - "version": "5.2.0" + "tool_snapshot_id": "blender-5.2.0 LTS", + "version": "5.2.0 LTS" }, - "tri_count": 5170 + "tri_count": 40658 } diff --git a/client-3d/public/assets/world-items/cloning_facility_collision.json b/client-3d/public/assets/world-items/cloning_facility_collision.json index 1cd0981c..75ff10ce 100644 --- a/client-3d/public/assets/world-items/cloning_facility_collision.json +++ b/client-3d/public/assets/world-items/cloning_facility_collision.json @@ -7,9 +7,13 @@ "pipes", "windows", "floor_guides", - "roof_gear" + "roof_gear", + "vats", + "process_plant", + "gantry", + "control_bank" ], - "geometrySource": "P", + "geometrySource": "cf_spec.STRUCTURAL_WALLS", "structuralRoles": [ "outer_shell", "entry_bay_returns", @@ -41,7 +45,7 @@ "spanX": 9.5, "spanZ": 7.6 }, - "generatedBy": "tools/successor/assets/build_cloning_facility.py", + "generatedBy": "tools/successor/assets/cloning-facility-opus5/src/build_clone_suite.py", "interiorRegions": [ { "floorTopY": 0.02, diff --git a/client-3d/public/assets/world-items/cloning_facility_manifest.json b/client-3d/public/assets/world-items/cloning_facility_manifest.json index 07d621a2..3d348e73 100644 --- a/client-3d/public/assets/world-items/cloning_facility_manifest.json +++ b/client-3d/public/assets/world-items/cloning_facility_manifest.json @@ -1,7 +1,45 @@ { + "asset": { + "bytes": 4921376, + "file": "cloning_facility.glb", + "images": [ + { + "bytes": 21421, + "embedded": true, + "mime": "image/png", + "name": "cloning_facility_normal" + }, + { + "bytes": 1695616, + "embedded": true, + "mime": "image/jpeg", + "name": "cloning_facility_basecolor" + }, + { + "bytes": 222800, + "embedded": true, + "mime": "image/png", + "name": "cloning_facility_orm" + }, + { + "bytes": 14117, + "embedded": true, + "mime": "image/png", + "name": "clone_screen_emissive" + } + ], + "sha256": "sha256:d70eb11b646a6e210ea7af833627d6e504b3b1b4f810cbd350579d3524486619" + }, + "bake": { + "atlasObjects": 17, + "baseColorCarriesLighting": true, + "reason": "client-3d world props render textured materials with an unlit MeshBasicMaterial, so irradiance and AO have to be in the base colour or they do not exist in game", + "runtimeSpecialObjects": 20, + "uvCoverage": 0.5214 + }, "bbox_span_m": [ 9.5, - 5.26, + 6.18, 7.6 ], "building": "cloning_facility", @@ -9,9 +47,15 @@ 2.3, 2.42 ], + "collision": { + "boxes": 10, + "door_boxes": 1, + "sha256": "sha256:71362762c62fa698df5c1bb57ad2273a3434db732c4e53151be890343a4d3ec9", + "structure_boxes": 9 + }, "collisionProxy": { "sidecar": "cloning_facility_collision.json", - "source": "P", + "source": "cf_spec.STRUCTURAL_WALLS", "structuralOnly": true }, "cutaway": { @@ -55,6 +99,10 @@ ], "floorHeightM": 0.02, "floorTopY": 0.02, + "footprintCells": [ + 10, + 8 + ], "front": "+Z", "interiorRegions": [ { @@ -67,20 +115,25 @@ } ], "interior_clear_height_m": 3.05, + "label": "Dustgate Clone Vault", "materials": [ - "HK_Brass", - "HK_Clad", - "HK_Clad2", - "HK_Door", - "HK_Floor", - "HK_Frame", - "HK_Glow", - "HK_Roof", - "HK_Shell", - "HK_Steel", - "HK_Trim" + "CF_Body", + "CF_Fluid", + "CF_Glass", + "CF_GlassClear", + "CF_Glow", + "CF_GlowAmber", + "CF_GlowWarm", + "CF_Screen" ], + "occupant": { + "authored_height_m": 1.6387, + "runtime_height_m": 1.725, + "source": "pawn-pack/pawn_male.glb (read-only, rig discarded)" + }, + "runtimeKey": "cloning_facility", "runtime_scale_at_10x8_cells": 1.052632, - "tri_count": 5170, + "schema": "successor.runtime-building-manifest.v1", + "tri_count": 40658, "units": "m" } diff --git a/client-3d/public/assets/world-items/commerce_facility.glb b/client-3d/public/assets/world-items/commerce_facility.glb index 6d99be12..0f3747a0 100644 --- a/client-3d/public/assets/world-items/commerce_facility.glb +++ b/client-3d/public/assets/world-items/commerce_facility.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:19d1e17a6b345202a5cf5e6d1e81b878eacc5630678a34ef41f7805260090f96 -size 1647796 +oid sha256:b1700b6c9b5fb2a3a00143ac98668e18c6d736474b337bc726f1f579820b1b77 +size 1567612 diff --git a/client-3d/public/assets/world-items/crate_planked.glb b/client-3d/public/assets/world-items/crate_planked.glb index f377f6da..6c712231 100644 --- a/client-3d/public/assets/world-items/crate_planked.glb +++ b/client-3d/public/assets/world-items/crate_planked.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d5ac6f0d9a939d0f4cb77ced8594b013db1d40ebe895c0e1871c1543ea83dfa6 -size 2336804 +oid sha256:80e691f1833aff4465f3338a787a63cf025b99b146f8c54471ed431564bb26cd +size 2336848 diff --git a/client-3d/public/assets/world-items/footlocker_frontier.glb b/client-3d/public/assets/world-items/footlocker_frontier.glb index 4d954de4..1c76c465 100644 --- a/client-3d/public/assets/world-items/footlocker_frontier.glb +++ b/client-3d/public/assets/world-items/footlocker_frontier.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f8bbc53ff96b0020466da861e6cdce2ac43090db6384d14e8656fa83a71e75ad -size 3178584 +oid sha256:c32cf90ed2c3978f78c57b048601f31730a07cd6526832f05bb0509e26efa24e +size 3178628 diff --git a/client-3d/public/assets/world-items/grill_frontier.glb b/client-3d/public/assets/world-items/grill_frontier.glb index 311f60b9..9ccf4041 100644 --- a/client-3d/public/assets/world-items/grill_frontier.glb +++ b/client-3d/public/assets/world-items/grill_frontier.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:56af6e3a1db2ccd21d3ef9a5f9378242d7264710a7c0a578a41e41500c26f7f8 -size 3397424 +oid sha256:5ccf1cc3e536907de262ff42e01db7eaf1fc782ff60d508c009b523f5bbaaf12 +size 3397468 diff --git a/client-3d/public/assets/world-items/home_modular_court.glb b/client-3d/public/assets/world-items/home_modular_court.glb new file mode 100644 index 00000000..2616ed4d --- /dev/null +++ b/client-3d/public/assets/world-items/home_modular_court.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68c62897e0e8990b21cf49eced9576e6788d1f6f84e73c73790ad6f96853e8ff +size 8528060 diff --git a/client-3d/public/assets/world-items/home_modular_court.provenance.json b/client-3d/public/assets/world-items/home_modular_court.provenance.json new file mode 100644 index 00000000..ac4dd5e9 --- /dev/null +++ b/client-3d/public/assets/world-items/home_modular_court.provenance.json @@ -0,0 +1,730 @@ +{ + "artifacts": { + "collision": { + "bytes": 73649, + "path": "home_modular_court_collision.json", + "sha256": "b9a86970938c2d423e775f73962ba1cefd71f2a72f895a2c4f11991c42157d47" + }, + "glb": { + "bytes": 8337816, + "path": "home_modular_court.glb", + "sha256": "4fbc8e48b136846e32c28a3373dcca2f0ab515d2a1464e69ae25f83dd580458a" + }, + "manifest": { + "bytes": 3123, + "path": "home_modular_court_manifest.json", + "sha256": "582007c9b072fc2fa4e1166878ce27d03e3cc1d13719b5c6c3c7844b858d932a" + } + }, + "asset": { + "bytes": 8337816, + "path": "home_modular_court.glb", + "sha256": "sha256:4fbc8e48b136846e32c28a3373dcca2f0ab515d2a1464e69ae25f83dd580458a" + }, + "asset_id": "home_modular_court", + "contract": { + "collision": "home_modular_court_collision.json", + "entryDoor": "door_slide", + "floorTopY": 0.02, + "footprintCells": [ + 10, + 8 + ], + "manifest": "home_modular_court_manifest.json" + }, + "generator": { + "lod": 0, + "path": "src/package_runtime_buildings.py", + "sha256": "13be669e4cb44b83ee75f36402145d7f1b083b4e7f33ca62310f05c8a757105e" + }, + "modules": [ + { + "collision": { + "path": "build/collision/ceiling_1x1__plasterlined.collision.json", + "sha256": "934f4eeb7cef52c083635c42da3b564d205d389bfd682f14d05b0b25ae91b621" + }, + "glb": { + "bytes": 3432, + "path": "build/modules/ceiling_1x1__plasterlined_lod0.glb", + "sha256": "e2de1c575d6f48e78d715293665d2253ca30adbb985bfb02c35c0d31e862d523" + }, + "id": "ceiling_1x1__plasterlined", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/ceiling_cham_1x1__plasterlined.collision.json", + "sha256": "c4bcf98c97f41b74cca1067c19da7e75c8b7960515367df8d5aa57ff41c60e1c" + }, + "glb": { + "bytes": 3820, + "path": "build/modules/ceiling_cham_1x1__plasterlined_lod0.glb", + "sha256": "0216ac6533a8699bb6b3df5cb7fb5c842d8bde2dce08ddb129fedab8a29cc8d2" + }, + "id": "ceiling_cham_1x1__plasterlined", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/column__sintered.collision.json", + "sha256": "0288553a57eb141fd5e8a504e94817e96103d3367136fe1165517265ad2fc238" + }, + "glb": { + "bytes": 21784, + "path": "build/modules/column__sintered_lod0.glb", + "sha256": "1edcd27cab8347c2a422acdbd14b89552526f9a597fc7a4f584e9968ab72982e" + }, + "id": "column__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/door_inner_2c__panelclad.collision.json", + "sha256": "ee43ca0df0c4393bd91d03f2ca58d0339ca3cab8af06812e43374c43ceae28dc" + }, + "glb": { + "bytes": 236992, + "path": "build/modules/door_inner_2c__panelclad_lod0.glb", + "sha256": "c29e934e44e8c79f9d5949bdbfef82c0251e4d49469c1da17a15692265b84b64" + }, + "id": "door_inner_2c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/door_inner_2c__plasterlined.collision.json", + "sha256": "949b78b80ab6b37e375aa91aaf2d51f316fc4a6d29f8bcb9cedb7001746a29a9" + }, + "glb": { + "bytes": 211108, + "path": "build/modules/door_inner_2c__plasterlined_lod0.glb", + "sha256": "5b542ee993356c46576773b960b631cae768d9d5239c1c30f63b5b20cbec882f" + }, + "id": "door_inner_2c__plasterlined", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/door_slide_2c__sintered.collision.json", + "sha256": "d50dca109f9734c23284e9366cf15bac94e8bfd048e71fa62a99d9a51549c566" + }, + "glb": { + "bytes": 217504, + "path": "build/modules/door_slide_2c__sintered_lod0.glb", + "sha256": "963afa5b32546a7f46165a8b6161bc7067cd1b079ec72a060466c3b9dbaaeb00" + }, + "id": "door_slide_2c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/entry_apron_2c__sintered.collision.json", + "sha256": "97a361f393a672ff943b4206129acba62e0df4a3eded956b42c0415d6b54eabd" + }, + "glb": { + "bytes": 11884, + "path": "build/modules/entry_apron_2c__sintered_lod0.glb", + "sha256": "1e78b9130f2d5db6e5123df72f78677622c7d4ecf3177bf8672e1afbfd2cf1f4" + }, + "id": "entry_apron_2c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/floor_1x1__panelclad.collision.json", + "sha256": "f26cd0760ab21d0258518c5c81fc4a06c98c5b6225d4957a01a566e5300cefe5" + }, + "glb": { + "bytes": 7564, + "path": "build/modules/floor_1x1__panelclad_lod0.glb", + "sha256": "b49ddeb21df5605aa2262f5a6afaac70cbe814d11a7b748a08cb0ce20d453c46" + }, + "id": "floor_1x1__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/floor_1x1__sintered.collision.json", + "sha256": "97a7ecffce98eefeed72040395a71f8fbb0e116f53b03bace269cf37b294beb5" + }, + "glb": { + "bytes": 4416, + "path": "build/modules/floor_1x1__sintered_lod0.glb", + "sha256": "1afa58178344e518db24fc98103a1e3546b77ea6a78364cd83501b8546c6e23c" + }, + "id": "floor_1x1__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/floor_cham_1x1__panelclad.collision.json", + "sha256": "5c7d6bda02af13f1e2571bd1b5ac0d351d8c57ed4da064f40672c419fced225c" + }, + "glb": { + "bytes": 5080, + "path": "build/modules/floor_cham_1x1__panelclad_lod0.glb", + "sha256": "2d2e0c713f284283bb192d6219e5499a5ab83b5c0de45304a18563c967316c96" + }, + "id": "floor_cham_1x1__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/floor_cham_1x1__sintered.collision.json", + "sha256": "4a4429851419baaf95b8dbb914ca46ed18379267013838af328b1840c1e04c5e" + }, + "glb": { + "bytes": 5048, + "path": "build/modules/floor_cham_1x1__sintered_lod0.glb", + "sha256": "a1821d3f6e9d13954b580c3af8183feef14cbe05811d0bcb505dc3e50e41bd26" + }, + "id": "floor_cham_1x1__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/found_cham_1x1__sintered.collision.json", + "sha256": "1632ab20bbd50580ad4a629066df14b77594fc746338ebda13f42c52c8bb28fb" + }, + "glb": { + "bytes": 6312, + "path": "build/modules/found_cham_1x1__sintered_lod0.glb", + "sha256": "c0d835ebf5f6397e25b3b77531fa0b17f725fdc526987c1a07e6bd6cb3537a9d" + }, + "id": "found_cham_1x1__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/foundation_1x1__sintered.collision.json", + "sha256": "408b7b90b12a8bcd22eca02c36a5b4e37977e996e390773b153b155866b61a19" + }, + "glb": { + "bytes": 5424, + "path": "build/modules/foundation_1x1__sintered_lod0.glb", + "sha256": "a94a48157159322f5f50969ae6961953844b6e870ae6d8ec121e6125e8f93031" + }, + "id": "foundation_1x1__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_bed_2c__panelclad.collision.json", + "sha256": "a2a08484adb884fcedd1463f34461933d2ff13ae0d08546586cbd5d7dbb43574" + }, + "glb": { + "bytes": 88928, + "path": "build/modules/furn_bed_2c__panelclad_lod0.glb", + "sha256": "53afddbde833a2cb1470adec0bbc782e38c9abc4fb3ed4144389977309c33e46" + }, + "id": "furn_bed_2c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_bench_2c__panelclad.collision.json", + "sha256": "ead6e84004572dc8152971641c23d5fefade0b029e07a6f228fbe15757f6c85d" + }, + "glb": { + "bytes": 110980, + "path": "build/modules/furn_bench_2c__panelclad_lod0.glb", + "sha256": "018d5bc41ab6d8304a8466bcb0f42f91900007d84c01aa61193970bf3fa6c098" + }, + "id": "furn_bench_2c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_chair_1c__panelclad.collision.json", + "sha256": "eb6f1cdaea3f77943b05eca62939d038a49dbd4408440d6cd44571ffc0f8a025" + }, + "glb": { + "bytes": 52132, + "path": "build/modules/furn_chair_1c__panelclad_lod0.glb", + "sha256": "daaa2ea380975961928da634fd1285c29b48b55ebe7245aa60d7c8acc60cd6eb" + }, + "id": "furn_chair_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_chest_1c__panelclad.collision.json", + "sha256": "4e26dc2627b2c703360510341a0265d39dc7c01ca2f63a3241ef6ffa066d2de3" + }, + "glb": { + "bytes": 26692, + "path": "build/modules/furn_chest_1c__panelclad_lod0.glb", + "sha256": "2c4786561dd80c9c52d280880872c41f1074c5c2c3e1fe70662ce63c8512bd6a" + }, + "id": "furn_chest_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_corner_hearth__panelclad.collision.json", + "sha256": "cb530c19cd574dc661fc87908c6d8b09be07e2fd30f2234f35d8b3c0cc0d83b2" + }, + "glb": { + "bytes": 54244, + "path": "build/modules/furn_corner_hearth__panelclad_lod0.glb", + "sha256": "d71acb059f615689232c700c67785f6935b20875be569e27fb59a40058910dac" + }, + "id": "furn_corner_hearth__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_corner_shelf__panelclad.collision.json", + "sha256": "f33a4481c46711873cedd16ca40ac2703e37b70a376e0258c547f1f725dae78f" + }, + "glb": { + "bytes": 43276, + "path": "build/modules/furn_corner_shelf__panelclad_lod0.glb", + "sha256": "c1ea8c47063e8ad717b9c4836e6cd199b5c25d616d461466001449590ed7ac51" + }, + "id": "furn_corner_shelf__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_corner_store__panelclad.collision.json", + "sha256": "aa45949714aeb8b1786426c581b684a2b89b0e1ddee8bfe1c064088023f59a3c" + }, + "glb": { + "bytes": 36920, + "path": "build/modules/furn_corner_store__panelclad_lod0.glb", + "sha256": "cdd89639fb4a1700189fccf10175db50551e669677bea182907ffe2d5ece00b8" + }, + "id": "furn_corner_store__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_crate_stack__panelclad.collision.json", + "sha256": "79d36c1c70727abfb2d5e3369ca2ddf6c51fa548499e07c668ef46641578725d" + }, + "glb": { + "bytes": 46740, + "path": "build/modules/furn_crate_stack__panelclad_lod0.glb", + "sha256": "f3293455ee6b9bc70f5651bf4a3622f3f498ae1cf8e345275785ba72e569c2d1" + }, + "id": "furn_crate_stack__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_lamp_wall__panelclad.collision.json", + "sha256": "6cfecc94c25626a77cd0c015a9085f4b96639bb8d8172ced40951824273cf712" + }, + "glb": { + "bytes": 37884, + "path": "build/modules/furn_lamp_wall__panelclad_lod0.glb", + "sha256": "32df49e6cd8b1b29c9cc70e779f8354c46a248d5044a5e993e786da846f790b5" + }, + "id": "furn_lamp_wall__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_lamp_wall__sintered.collision.json", + "sha256": "c22d5a1ff6d38cb1a8b2342523b269648594644d44f8d25e8ec7c9b2cc901ee0" + }, + "glb": { + "bytes": 36216, + "path": "build/modules/furn_lamp_wall__sintered_lod0.glb", + "sha256": "21958be5cb9be0047e54f2160264269b44d3ca57c6ed63bfe9a9524c3300a50e" + }, + "id": "furn_lamp_wall__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_nightstand__panelclad.collision.json", + "sha256": "e54a64a0f8124cfa5002ba23e8286f5f3fbc917b418e566474a82a2efa114083" + }, + "glb": { + "bytes": 36568, + "path": "build/modules/furn_nightstand__panelclad_lod0.glb", + "sha256": "6081b7c35d47c22c69dd0675f1102d4f7582eeddf3e8216e94f6fb53b444f547" + }, + "id": "furn_nightstand__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_pendant_1c__panelclad.collision.json", + "sha256": "900b502c0bec5e00559e1165810886242d47e3e072ab8fc71613158119b225b9" + }, + "glb": { + "bytes": 19988, + "path": "build/modules/furn_pendant_1c__panelclad_lod0.glb", + "sha256": "30739ce199caf2fba5c89d633e6b7cfd63d32716bf9725874cf13cd45b5987ee" + }, + "id": "furn_pendant_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_planter_1c__panelclad.collision.json", + "sha256": "a9e6f4d565435cf0ccd792e77ab8c2f79ff5bc9f1534838fdfc133a1da8ee123" + }, + "glb": { + "bytes": 75924, + "path": "build/modules/furn_planter_1c__panelclad_lod0.glb", + "sha256": "f28bee45812cb85ff8b65f99ef9191f121cbe591bb53d805e8714057166d67e3" + }, + "id": "furn_planter_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_shelf_1c__panelclad.collision.json", + "sha256": "e4176b53db397cb627b0d395cebe6a9dba015c9d58d951bd5f9cee3d1abde461" + }, + "glb": { + "bytes": 22204, + "path": "build/modules/furn_shelf_1c__panelclad_lod0.glb", + "sha256": "af9ac4c977ecf05e7498e5ee3139140bfcebb4eddfa923671269be2e5713c3d1" + }, + "id": "furn_shelf_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_sideboard_2c__panelclad.collision.json", + "sha256": "cf6278917252fbba9509f870c684bb325da1f73a4b50f54bf4ec7fdc92f12335" + }, + "glb": { + "bytes": 103340, + "path": "build/modules/furn_sideboard_2c__panelclad_lod0.glb", + "sha256": "4af41b2291d924ad5cb5597cebcca836265fac80c336c1c4139c6fb09d01c65d" + }, + "id": "furn_sideboard_2c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_sink_1c__panelclad.collision.json", + "sha256": "60c38008176eb11ce38f3288556e5d02a9955224466a48a5d4622434b43789dc" + }, + "glb": { + "bytes": 80468, + "path": "build/modules/furn_sink_1c__panelclad_lod0.glb", + "sha256": "145f539597efcb375ffd574d7cc6a3e8c721363d61d404f3d36ec1cfcc963bf4" + }, + "id": "furn_sink_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_stool__panelclad.collision.json", + "sha256": "e255b57cae4c44ef1cd62a6853cd254b9c3c3004f391c75d16addaaf1dc186fd" + }, + "glb": { + "bytes": 25828, + "path": "build/modules/furn_stool__panelclad_lod0.glb", + "sha256": "8e3941c7bd9b392f23a5ddcbf5a852a67ac9ef8fe0d2aea6b0f49dfb0247b57b" + }, + "id": "furn_stool__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_stove_1c__panelclad.collision.json", + "sha256": "6383594f8bb1b657a8c67b96463d9648300dbcd88756d37adefbd6bb20d278f3" + }, + "glb": { + "bytes": 83028, + "path": "build/modules/furn_stove_1c__panelclad_lod0.glb", + "sha256": "286452b4c3b0318acf963739dc363b0a080c59ec7a65d023469fe9fef90dfecf" + }, + "id": "furn_stove_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_table_1c__panelclad.collision.json", + "sha256": "0c98020d398dd3019488bde502cbd44f4ee3f2b0bfdd6518edd271e4b9099ff6" + }, + "glb": { + "bytes": 87728, + "path": "build/modules/furn_table_1c__panelclad_lod0.glb", + "sha256": "eef631cb8f1080bd97ffc512dfc0259aa12278ff846bca71f51ee70b4a9e7ecf" + }, + "id": "furn_table_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_wall_cab_1c__panelclad.collision.json", + "sha256": "021f42dbad8f1c69f3ef84b24407fa05f5b5e7aca5570b0d83beea04b05877c2" + }, + "glb": { + "bytes": 30368, + "path": "build/modules/furn_wall_cab_1c__panelclad_lod0.glb", + "sha256": "c002028786a2f82e7e7fb89028c27dea243a427e09b9d013afce4f13f1108603" + }, + "id": "furn_wall_cab_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/roof_deck_1x1__sintered.collision.json", + "sha256": "378a0eb82196f9c601bf8a208d56a3985dd9721cec2b3bf30a5ecff07e08b14c" + }, + "glb": { + "bytes": 7480, + "path": "build/modules/roof_deck_1x1__sintered_lod0.glb", + "sha256": "c52c7c2396f96a500ce71552b9bffb8f6e6d0e08d4b37a9c6b1bdf864198244d" + }, + "id": "roof_deck_1x1__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/roof_light_flat_1x1__sintered.collision.json", + "sha256": "ec25c1276ce93da0548d69ddd2b59a7569faadf22da3e9ef50491129dc1ca5be" + }, + "glb": { + "bytes": 31300, + "path": "build/modules/roof_light_flat_1x1__sintered_lod0.glb", + "sha256": "a4402d54f0c4210ff56bd199e585b8d0fa91d8aec99399e82fd4ecc318cdd771" + }, + "id": "roof_light_flat_1x1__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/roof_skirt_1c__sintered.collision.json", + "sha256": "88b16f5b877401e738b221f3406d7ea9d962d61e8fcd959698c3e23d76927d88" + }, + "glb": { + "bytes": 11132, + "path": "build/modules/roof_skirt_1c__sintered_lod0.glb", + "sha256": "ab54cdde48a0d3453acf612414f1eef38970ce54be4ff6ce9fd59cdba9d2b06a" + }, + "id": "roof_skirt_1c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/roof_skirt_cham__sintered.collision.json", + "sha256": "a5b1739d599c2b32eaab19419b5ac88853b27dcd778a089b4f0dfe29ea43562b" + }, + "glb": { + "bytes": 17676, + "path": "build/modules/roof_skirt_cham__sintered_lod0.glb", + "sha256": "048272d57c080f42fc373745c920a213e43821a43d5c0a5754ac239ace7b87f9" + }, + "id": "roof_skirt_cham__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/wall_1c__panelclad.collision.json", + "sha256": "8568e361acc8909ea07c8fda190f550bee2f20dcf81855b098cda40d13f8eea4" + }, + "glb": { + "bytes": 37044, + "path": "build/modules/wall_1c__panelclad_lod0.glb", + "sha256": "8afeaa32b2eacec3691659a33247ad4c9f1fe97b32c100f3b38ebee5d7703170" + }, + "id": "wall_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/wall_1c__plasterlined.collision.json", + "sha256": "0c2d61b50b2e946dc5255dd7501f77c1d8e74d9bfce2bc80968ff02bb5fc2895" + }, + "glb": { + "bytes": 30856, + "path": "build/modules/wall_1c__plasterlined_lod0.glb", + "sha256": "74410e082f5daeeb6287cc82612f16222832949711127df55194344a3a0ff769" + }, + "id": "wall_1c__plasterlined", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/wall_1c__sintered.collision.json", + "sha256": "4d4c403a10a18d2f86d2130057381419352cb4e72e3b55f42ad9859ffe298154" + }, + "glb": { + "bytes": 30872, + "path": "build/modules/wall_1c__sintered_lod0.glb", + "sha256": "8cc27185001276e324983f8951a02d082f46cc8ef49860f9e2171021c0410557" + }, + "id": "wall_1c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/wall_cham_1c__sintered.collision.json", + "sha256": "7cdccafeb63a1c45bb0737fee03b0f12d7ce6d6c39b7afbee681289f135759bb" + }, + "glb": { + "bytes": 73472, + "path": "build/modules/wall_cham_1c__sintered_lod0.glb", + "sha256": "db0069922b7bf3df76e5f0af1dabe1abde7df3e47c581ce74585360147ed260f" + }, + "id": "wall_cham_1c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/wall_half_1c__sintered.collision.json", + "sha256": "75f7ffda5d40d12c0b8bc39b5426447e362741824477ad839adb201af372a028" + }, + "glb": { + "bytes": 15636, + "path": "build/modules/wall_half_1c__sintered_lod0.glb", + "sha256": "fd80343697f16a2b65191368364b93d92a1f0fb12ec7494562cea273e92b9ad9" + }, + "id": "wall_half_1c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/wall_tee__sintered.collision.json", + "sha256": "fca493df6c485d954658e760ab2582b816356a145343f1d8b91a8528933325f8" + }, + "glb": { + "bytes": 29360, + "path": "build/modules/wall_tee__sintered_lod0.glb", + "sha256": "4e1b83fcf02db2a585378efb4fcc1219795d14d2adc5acadb6c5ae24368fb851" + }, + "id": "wall_tee__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/window_cler_1c__sintered.collision.json", + "sha256": "4831fba90fc06359e7af9818b7eb9d8ecf085f9aada2b5046f2359277957cb84" + }, + "glb": { + "bytes": 51592, + "path": "build/modules/window_cler_1c__sintered_lod0.glb", + "sha256": "00fd72248187567a4329821e537112eef071c11a401aa740fa28cb4669c299d0" + }, + "id": "window_cler_1c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/window_slot_1c__sintered.collision.json", + "sha256": "26eae56a750e72a8d2819bade6efed096c9e69b40848e112130e7f95bef4da2d" + }, + "glb": { + "bytes": 103048, + "path": "build/modules/window_slot_1c__sintered_lod0.glb", + "sha256": "a96856f26a86bcb2fd68c2fd862e02685076845ab2f7cbac1a62de6574e3b5b5" + }, + "id": "window_slot_1c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/window_tall_2c__sintered.collision.json", + "sha256": "74fdbba411eb8d543e47f83731a29781d4ef8cfa97d9e7611742f65dd334bafd" + }, + "glb": { + "bytes": 111208, + "path": "build/modules/window_tall_2c__sintered_lod0.glb", + "sha256": "97c17b52f2cc0a5db6858ab2d085dace6f9751914281612c208e1b7f03c498d4" + }, + "id": "window_tall_2c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/expansion_furniture/collision/xs_san_basin_1c__panelclad.collision.json", + "sha256": "a5130d88eae351535a5414cef043a204efc38cc428345131149d96de69d4aea4" + }, + "glb": { + "bytes": 137756, + "path": "build/expansion_furniture/modules/xs_san_basin_1c__panelclad_lod0.glb", + "sha256": "7df47d292086142ff1574aa377b6b28e3f345fd3fc6dd9e62e02b0d2779c0ee4" + }, + "id": "xs_san_basin_1c__panelclad", + "lane": "expansion_furniture" + }, + { + "collision": { + "path": "build/expansion_furniture/collision/xs_san_drain_1c__panelclad.collision.json", + "sha256": "45c79f2757c767fe5da8e7ccf500cb9396205d38f429fca8df0e330a159dd8b8" + }, + "glb": { + "bytes": 71668, + "path": "build/expansion_furniture/modules/xs_san_drain_1c__panelclad_lod0.glb", + "sha256": "019c7e40030583d120174e9d7fcb52f3b9625a1a9b7d127f67b2a21fedb1c444" + }, + "id": "xs_san_drain_1c__panelclad", + "lane": "expansion_furniture" + }, + { + "collision": { + "path": "build/expansion_furniture/collision/xs_san_mirrorcab_1c__panelclad.collision.json", + "sha256": "7bc5220b55994e50ad3e30ededc0caa129a73ed5fd677f0ab5848ff3e1376b7c" + }, + "glb": { + "bytes": 87640, + "path": "build/expansion_furniture/modules/xs_san_mirrorcab_1c__panelclad_lod0.glb", + "sha256": "d274a541dc499ea364d2fee9485ef4fab954220c7462bf6f4b4609f297ca3c3f" + }, + "id": "xs_san_mirrorcab_1c__panelclad", + "lane": "expansion_furniture" + }, + { + "collision": { + "path": "build/expansion_furniture/collision/xs_san_reclaimer_1c__panelclad.collision.json", + "sha256": "bcc9857f9bb6427b69146ca311ded29445666985e08105d85fca399315c7ecc9" + }, + "glb": { + "bytes": 141768, + "path": "build/expansion_furniture/modules/xs_san_reclaimer_1c__panelclad_lod0.glb", + "sha256": "ece43fd2505a07e7ff88354cc7855bc2da2538cf519ffb13e03de8591f1e7501" + }, + "id": "xs_san_reclaimer_1c__panelclad", + "lane": "expansion_furniture" + } + ], + "pack": { + "colour_accessors_quantized": 592, + "images_embedded": 0, + "unique_image_bytes": 0 + }, + "runtime_key": "home_modular_court", + "schema": "successor.runtime-building-provenance.v1", + "source_plan": "home_court", + "sources": [ + { + "path": "src/package_runtime_buildings.py", + "sha256": "13be669e4cb44b83ee75f36402145d7f1b083b4e7f33ca62310f05c8a757105e" + }, + { + "path": "src/assemble_home.py", + "sha256": "236cc54de80f2e1cd0ab572fa5fdc0731e76de10617062fe8d6c227e50ab8692" + }, + { + "path": "src/home_plan.py", + "sha256": "3f0bc706756a76fa5182b1b8f1e008d71fba087c25e6faeda66c1a131a02544b" + }, + { + "path": "src/kit_grid.py", + "sha256": "b1cda4f166174d37edf25d64dd35d9fc108571676a8a1b5a417e29b0aa61fb19" + }, + { + "path": "src/kit_spec.py", + "sha256": "04456196e9e3cbe5554f576fd61582ec03bee6e02db9c7212a870f267da53fae" + }, + { + "path": "src/pack_runtime_glb.py", + "sha256": "694cfff9a23674c4be7846005f8b1b090ff5667cb554c632f2b5339abf232f6d" + }, + { + "path": "build/buildkit.manifest.json", + "sha256": "3dad4470f218c43c7c3812c2d64a4a0cb4b17f56299a9fc0a3ed479fe406e59d" + } + ] +} diff --git a/client-3d/public/assets/world-items/home_modular_court_collision.json b/client-3d/public/assets/world-items/home_modular_court_collision.json new file mode 100644 index 00000000..3298416c --- /dev/null +++ b/client-3d/public/assets/world-items/home_modular_court_collision.json @@ -0,0 +1,5122 @@ +{ + "boxes": [ + { + "center": [ + 0.475, + -0.16, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 0.475, + 0.01, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 0.475, + -0.16, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 0.475, + 0.01, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 0.475, + -0.16, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 0.475, + 0.01, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 0.475, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 0.475, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 0.475, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 0.475, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 0.475, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 0.475, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 0.475, + -0.16, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 0.475, + 0.01, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 0.475, + -0.16, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 0.475, + 0.01, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 1.425, + -0.16, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 1.425, + 0.01, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 1.425, + -0.16, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 1.425, + 0.01, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 1.425, + -0.16, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 1.425, + 0.01, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 1.425, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 1.425, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 1.425, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 1.425, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 1.425, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 1.425, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 1.425, + -0.16, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 1.425, + 0.01, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 1.425, + -0.16, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 1.425, + 0.01, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 2.375, + -0.16, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 2.375, + 0.01, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 2.375, + -0.16, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 2.375, + 0.01, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 2.375, + -0.16, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 2.375, + 0.01, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 2.375, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 2.375, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 2.375, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 2.375, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 2.375, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 2.375, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 2.375, + -0.16, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 2.375, + 0.01, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 2.375, + -0.16, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 2.375, + 0.01, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 3.325, + -0.16, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 3.325, + 0.01, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 3.325, + -0.16, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 3.325, + 0.01, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 3.325, + -0.16, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 3.325, + 0.01, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 3.325, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 3.325, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 3.325, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 3.325, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 3.325, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 3.325, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 3.325, + -0.16, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 3.325, + 0.01, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 3.325, + -0.16, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 3.325, + 0.01, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 8.075, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 8.075, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 8.075, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 8.075, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 8.075, + -0.16, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 8.075, + 0.01, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 8.075, + -0.16, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 8.075, + 0.01, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 9.025, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 9.025, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 9.025, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 9.025, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 9.025, + -0.16, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 9.025, + 0.01, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 9.025, + -0.16, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 9.025, + 0.01, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 0.7125, + 1.5, + 0.0 + ], + "kind": "structure", + "size": [ + 0.475, + 3.0, + 0.16 + ] + }, + { + "center": [ + 0.0, + 1.5, + 0.7125 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.475 + ] + }, + { + "center": [ + 0.2375, + 1.5, + 0.2375 + ], + "kind": "structure", + "size": [ + 0.635, + 3.0, + 0.635 + ] + }, + { + "center": [ + 0.0, + 1.5, + 6.8875 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.475 + ] + }, + { + "center": [ + 0.7125, + 1.5, + 7.6 + ], + "kind": "structure", + "size": [ + 0.475, + 3.0, + 0.16 + ] + }, + { + "center": [ + 0.2375, + 1.5, + 7.3625 + ], + "kind": "structure", + "size": [ + 0.635, + 3.0, + 0.635 + ] + }, + { + "center": [ + 3.8, + 1.5, + 0.7125 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.475 + ] + }, + { + "center": [ + 3.0875, + 1.5, + 0.0 + ], + "kind": "structure", + "size": [ + 0.475, + 3.0, + 0.16 + ] + }, + { + "center": [ + 3.5625, + 1.5, + 0.2375 + ], + "kind": "structure", + "size": [ + 0.635, + 3.0, + 0.635 + ] + }, + { + "center": [ + 9.5, + 1.5, + 4.5125 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.475 + ] + }, + { + "center": [ + 8.7875, + 1.5, + 3.8 + ], + "kind": "structure", + "size": [ + 0.475, + 3.0, + 0.16 + ] + }, + { + "center": [ + 9.2625, + 1.5, + 4.0375 + ], + "kind": "structure", + "size": [ + 0.635, + 3.0, + 0.635 + ] + }, + { + "center": [ + 8.7875, + 1.5, + 7.6 + ], + "kind": "structure", + "size": [ + 0.475, + 3.0, + 0.16 + ] + }, + { + "center": [ + 9.5, + 1.5, + 6.8875 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.475 + ] + }, + { + "center": [ + 9.2625, + 1.5, + 7.3625 + ], + "kind": "structure", + "size": [ + 0.635, + 3.0, + 0.635 + ] + }, + { + "center": [ + 1.425, + 1.5, + 0.0 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 2.375, + 1.5, + 0.0 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 0.0, + 1.5, + 1.425 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 0.0, + 1.5, + 2.375 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 0.0, + 1.5, + 3.325 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 0.0, + 1.5, + 4.275 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 0.0, + 1.5, + 5.225 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 0.0, + 1.5, + 6.175 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 1.425, + 1.5, + 7.6 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 4.275, + 1.5, + 7.6 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 5.225, + 1.5, + 7.6 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 6.175, + 1.5, + 7.6 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 7.125, + 1.5, + 7.6 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 8.075, + 1.5, + 7.6 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 2.85, + 1.5, + 7.6 + ], + "kind": "structure", + "size": [ + 1.9, + 3.0, + 0.16 + ] + }, + { + "center": [ + 9.5, + 1.5, + 5.225 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 9.5, + 1.5, + 6.175 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 4.275, + 1.5, + 3.8 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 7.125, + 1.5, + 3.8 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 8.075, + 1.5, + 3.8 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 6.585, + 1.5, + 3.8 + ], + "kind": "structure", + "size": [ + 0.13, + 3.0, + 0.16 + ] + }, + { + "center": [ + 5.225, + 1.5, + 3.8 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 6.11, + 2.53, + 3.8 + ], + "kind": "structure", + "size": [ + 0.82, + 0.94, + 0.16 + ] + }, + { + "center": [ + 6.096, + 1.044, + 3.8 + ], + "kind": "door", + "size": [ + 0.9, + 1.992, + 0.05 + ] + }, + { + "center": [ + 3.8, + 1.5, + 1.425 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 3.8, + 1.5, + 2.375 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 3.8, + 1.5, + 3.325 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 5.7, + -0.02482, + 3.11 + ], + "kind": "structure", + "size": [ + 1.9, + 0.075, + 1.22 + ] + }, + { + "center": [ + 3.8, + 1.5, + 3.8 + ], + "kind": "structure", + "size": [ + 0.296, + 3.0, + 0.296 + ] + }, + { + "center": [ + 0.475, + 1.5, + 3.8 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 3.325, + 1.5, + 3.8 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 1.035, + 1.5, + 3.8 + ], + "kind": "structure", + "size": [ + 0.17, + 3.0, + 0.16 + ] + }, + { + "center": [ + 2.375, + 1.5, + 3.8 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 1.51, + 2.53, + 3.8 + ], + "kind": "structure", + "size": [ + 0.78, + 0.94, + 0.16 + ] + }, + { + "center": [ + 1.524, + 1.044, + 3.8 + ], + "kind": "door", + "size": [ + 0.86, + 1.992, + 0.05 + ] + }, + { + "center": [ + 0.0, + 1.5, + 3.8 + ], + "kind": "structure", + "size": [ + 0.296, + 3.0, + 0.296 + ] + }, + { + "center": [ + 0.475, + 1.5, + 1.9 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 3.325, + 1.5, + 1.9 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 1.035, + 1.5, + 1.9 + ], + "kind": "structure", + "size": [ + 0.17, + 3.0, + 0.16 + ] + }, + { + "center": [ + 2.375, + 1.5, + 1.9 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 1.51, + 2.53, + 1.9 + ], + "kind": "structure", + "size": [ + 0.78, + 0.94, + 0.16 + ] + }, + { + "center": [ + 1.524, + 1.044, + 1.9 + ], + "kind": "door", + "size": [ + 0.86, + 1.992, + 0.05 + ] + }, + { + "center": [ + 0.0, + 1.5, + 1.9 + ], + "kind": "structure", + "size": [ + 0.296, + 3.0, + 0.296 + ] + }, + { + "center": [ + 3.8, + 1.5, + 1.9 + ], + "kind": "structure", + "size": [ + 0.296, + 3.0, + 0.296 + ] + }, + { + "center": [ + 7.6, + 1.5, + 4.275 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 7.6, + 1.5, + 7.125 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 7.6, + 1.5, + 6.565 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.17 + ] + }, + { + "center": [ + 7.6, + 1.5, + 5.225 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 7.6, + 2.53, + 6.09 + ], + "kind": "structure", + "size": [ + 0.16, + 0.94, + 0.78 + ] + }, + { + "center": [ + 7.6, + 1.044, + 6.076 + ], + "kind": "door", + "size": [ + 0.05, + 1.992, + 0.86 + ] + }, + { + "center": [ + 7.6, + 1.5, + 3.8 + ], + "kind": "structure", + "size": [ + 0.296, + 3.0, + 0.296 + ] + }, + { + "center": [ + 7.6, + 1.5, + 7.6 + ], + "kind": "structure", + "size": [ + 0.296, + 3.0, + 0.296 + ] + }, + { + "center": [ + 4.275, + 0.75, + 0.0 + ], + "kind": "structure", + "size": [ + 0.95, + 1.5, + 0.16 + ] + }, + { + "center": [ + 5.225, + 0.75, + 0.0 + ], + "kind": "structure", + "size": [ + 0.95, + 1.5, + 0.16 + ] + }, + { + "center": [ + 8.075, + 0.75, + 0.0 + ], + "kind": "structure", + "size": [ + 0.95, + 1.5, + 0.16 + ] + }, + { + "center": [ + 9.025, + 0.75, + 0.0 + ], + "kind": "structure", + "size": [ + 0.95, + 1.5, + 0.16 + ] + }, + { + "center": [ + 9.5, + 0.75, + 0.475 + ], + "kind": "structure", + "size": [ + 0.16, + 1.5, + 0.95 + ] + }, + { + "center": [ + 9.5, + 0.75, + 1.425 + ], + "kind": "structure", + "size": [ + 0.16, + 1.5, + 0.95 + ] + }, + { + "center": [ + 9.5, + 0.75, + 2.375 + ], + "kind": "structure", + "size": [ + 0.16, + 1.5, + 0.95 + ] + }, + { + "center": [ + 9.5, + 0.75, + 3.325 + ], + "kind": "structure", + "size": [ + 0.16, + 1.5, + 0.95 + ] + }, + { + "center": [ + 5.7, + 1.15, + 0.0 + ], + "kind": "structure", + "size": [ + 0.35, + 2.94, + 0.35 + ] + }, + { + "center": [ + 7.6, + 1.15, + 0.0 + ], + "kind": "structure", + "size": [ + 0.35, + 2.94, + 0.35 + ] + }, + { + "center": [ + 9.5, + 1.15, + 0.0 + ], + "kind": "structure", + "size": [ + 0.35, + 2.94, + 0.35 + ] + }, + { + "center": [ + 0.565, + 0.5, + 7.035 + ], + "kind": "structure", + "size": [ + 0.53475, + 0.96, + 0.53475 + ] + }, + { + "center": [ + 0.27, + 1.635, + 7.33 + ], + "kind": "structure", + "size": [ + 0.1955, + 1.31, + 0.1955 + ] + }, + { + "center": [ + 2.375, + 0.38, + 6.175 + ], + "kind": "structure", + "size": [ + 0.78, + 0.72, + 0.66 + ] + }, + { + "center": [ + 1.425, + 0.435, + 6.175 + ], + "kind": "structure", + "size": [ + 0.5, + 0.83, + 0.43 + ] + }, + { + "center": [ + 3.325, + 0.435, + 6.175 + ], + "kind": "structure", + "size": [ + 0.5, + 0.83, + 0.43 + ] + }, + { + "center": [ + 2.375, + 1.905, + 6.175 + ], + "kind": "structure", + "size": [ + 0.46, + 0.16, + 0.46 + ] + }, + { + "center": [ + 2.375, + 0.225, + 5.225 + ], + "kind": "structure", + "size": [ + 0.33, + 0.41, + 0.33 + ] + }, + { + "center": [ + 4.275, + 0.225, + 7.6 + ], + "kind": "structure", + "size": [ + 1.8, + 0.41, + 0.44 + ] + }, + { + "center": [ + 6.175, + 0.42, + 7.6 + ], + "kind": "structure", + "size": [ + 1.76, + 0.8, + 0.48 + ] + }, + { + "center": [ + 5.62, + 1.17, + 7.8 + ], + "kind": "structure", + "size": [ + 0.63, + 0.7, + 0.14 + ] + }, + { + "center": [ + 0.0, + 1.4, + 4.275 + ], + "kind": "structure", + "size": [ + 0.28, + 0.5, + 0.8 + ] + }, + { + "center": [ + 5.225, + 1.875, + 7.481 + ], + "kind": "structure", + "size": [ + 0.194, + 0.514, + 0.238 + ] + }, + { + "center": [ + 6.175, + 0.24, + 6.175 + ], + "kind": "structure", + "size": [ + 0.632, + 0.44, + 0.468 + ] + }, + { + "center": [ + 8.935, + 0.95, + 7.035 + ], + "kind": "structure", + "size": [ + 0.53475, + 1.86, + 0.53475 + ] + }, + { + "center": [ + 8.935, + 0.842, + 4.365 + ], + "kind": "structure", + "size": [ + 0.53475, + 1.644, + 0.53475 + ] + }, + { + "center": [ + 9.5, + 0.46, + 5.225 + ], + "kind": "structure", + "size": [ + 0.6, + 0.88, + 0.9 + ] + }, + { + "center": [ + 9.5, + 1.38, + 5.475 + ], + "kind": "structure", + "size": [ + 0.6, + 0.96, + 0.37 + ] + }, + { + "center": [ + 9.5, + 0.46, + 6.175 + ], + "kind": "structure", + "size": [ + 0.6, + 0.88, + 0.9 + ] + }, + { + "center": [ + 7.6, + 1.69, + 7.125 + ], + "kind": "structure", + "size": [ + 0.32, + 0.54, + 0.9 + ] + }, + { + "center": [ + 7.719, + 1.875, + 4.275 + ], + "kind": "structure", + "size": [ + 0.238, + 0.514, + 0.194 + ] + }, + { + "center": [ + 9.025, + 1.905, + 6.175 + ], + "kind": "structure", + "size": [ + 0.46, + 0.16, + 0.46 + ] + }, + { + "center": [ + 0.0, + 0.2, + 3.325 + ], + "kind": "structure", + "size": [ + 0.88, + 0.36, + 1.8 + ] + }, + { + "center": [ + 0.475, + 0.29, + 3.8 + ], + "kind": "structure", + "size": [ + 0.37, + 0.54, + 0.36 + ] + }, + { + "center": [ + 3.8, + 1.4, + 3.325 + ], + "kind": "structure", + "size": [ + 0.28, + 0.5, + 0.8 + ] + }, + { + "center": [ + 0.475, + 0.474, + 1.9 + ], + "kind": "structure", + "size": [ + 0.8, + 0.908, + 0.42 + ] + }, + { + "center": [ + 3.325, + 1.875, + 2.019 + ], + "kind": "structure", + "size": [ + 0.194, + 0.514, + 0.238 + ] + }, + { + "center": [ + 3.235, + 0.95, + 0.565 + ], + "kind": "structure", + "size": [ + 0.53475, + 1.86, + 0.53475 + ] + }, + { + "center": [ + 1.425, + 0.459, + 0.0 + ], + "kind": "structure", + "size": [ + 0.64, + 0.878, + 0.56 + ] + }, + { + "center": [ + 0.475, + 0.46, + 1.67 + ], + "kind": "structure", + "size": [ + 0.68, + 0.88, + 0.46 + ] + }, + { + "center": [ + 0.475, + 1.665, + 1.741 + ], + "kind": "structure", + "size": [ + 0.88, + 0.53, + 0.13 + ] + }, + { + "center": [ + 0.475, + 0.249, + 0.27 + ], + "kind": "structure", + "size": [ + 0.44, + 0.458, + 0.48 + ] + }, + { + "center": [ + 0.475, + 0.77, + 0.085 + ], + "kind": "structure", + "size": [ + 0.36, + 0.42, + 0.17 + ] + }, + { + "center": [ + 1.425, + 0.059, + 1.425 + ], + "kind": "structure", + "size": [ + 0.6, + 0.078, + 0.28 + ] + }, + { + "center": [ + 2.375, + 1.875, + 0.119 + ], + "kind": "structure", + "size": [ + 0.194, + 0.514, + 0.238 + ] + }, + { + "center": [ + 4.275, + 1.875, + 3.681 + ], + "kind": "structure", + "size": [ + 0.194, + 0.514, + 0.238 + ] + }, + { + "center": [ + 7.125, + 1.875, + 3.681 + ], + "kind": "structure", + "size": [ + 0.194, + 0.514, + 0.238 + ] + }, + { + "center": [ + 3.919, + 1.875, + 1.425 + ], + "kind": "structure", + "size": [ + 0.238, + 0.514, + 0.194 + ] + }, + { + "center": [ + 0.475, + 2.57, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 0.475, + 2.57, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 0.475, + 2.57, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 0.475, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 0.475, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 0.475, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 0.475, + 2.57, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 0.475, + 2.57, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 1.425, + 2.57, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 1.425, + 2.57, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 1.425, + 2.57, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 1.425, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 1.425, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 1.425, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 1.425, + 2.57, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 1.425, + 2.57, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 2.375, + 2.57, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 2.375, + 2.57, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 2.375, + 2.57, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 2.375, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 2.375, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 2.375, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 2.375, + 2.57, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 2.375, + 2.57, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 3.325, + 2.57, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 3.325, + 2.57, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 3.325, + 2.57, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 3.325, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 3.325, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 3.325, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 3.325, + 2.57, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 3.325, + 2.57, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 5.225, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 5.225, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 5.225, + 2.57, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 5.225, + 2.57, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 8.075, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 8.075, + 2.57, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 9.025, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 9.025, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 9.025, + 2.57, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 9.025, + 2.57, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 0.475, + 3.06875, + 0.475 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 0.405, + 3.06875, + 1.425 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 0.405, + 3.06875, + 2.375 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 0.405, + 3.06875, + 3.325 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 0.405, + 3.06875, + 4.275 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 0.405, + 3.06875, + 5.225 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 0.405, + 3.06875, + 6.175 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 0.475, + 3.06875, + 7.125 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 1.425, + 3.06875, + 0.405 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 1.425, + 3.1725, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 1.425, + 3.1725, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 1.425, + 3.1725, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 1.425, + 3.1725, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 1.425, + 3.1725, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 1.425, + 3.1725, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 1.425, + 3.06875, + 7.195 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 2.375, + 3.06875, + 0.405 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 2.375, + 3.1725, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 2.375, + 3.1725, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 2.375, + 3.1725, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 2.375, + 3.1725, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 2.375, + 3.1725, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 2.375, + 3.1725, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 2.375, + 3.06875, + 7.195 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 3.325, + 3.06875, + 0.475 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 3.395, + 3.06875, + 1.425 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 3.395, + 3.06875, + 2.375 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 3.395, + 3.06875, + 3.325 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 3.325, + 3.1725, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 3.325, + 3.1725, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 3.325, + 3.1725, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 3.325, + 3.06875, + 7.195 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 4.275, + 3.06875, + 4.205 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 4.275, + 3.1725, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 4.275, + 3.1725, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 4.275, + 3.06875, + 7.195 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 5.225, + 3.06875, + 4.205 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 5.225, + 3.1725, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 5.225, + 3.1725, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 5.225, + 3.06875, + 7.195 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 6.175, + 3.06875, + 4.205 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 6.175, + 3.1725, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 6.175, + 3.1725, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 6.175, + 3.06875, + 7.195 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 7.125, + 3.06875, + 4.205 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 7.125, + 3.1725, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 7.125, + 3.1725, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 7.125, + 3.06875, + 7.195 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 8.075, + 3.06875, + 4.205 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 8.075, + 3.1725, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 8.075, + 3.3325, + 5.225 + ], + "kind": "structure", + "size": [ + 0.58, + 0.19, + 0.58 + ] + }, + { + "center": [ + 8.075, + 3.1725, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 8.075, + 3.3325, + 6.175 + ], + "kind": "structure", + "size": [ + 0.58, + 0.19, + 0.58 + ] + }, + { + "center": [ + 8.075, + 3.06875, + 7.195 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 9.025, + 3.06875, + 4.275 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 9.095, + 3.06875, + 5.225 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 9.095, + 3.06875, + 6.175 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 9.025, + 3.06875, + 7.125 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 1.09 + ] + } + ], + "contract": { + "decorativeExcluded": [ + "foundation", + "floor", + "ceiling", + "roof", + "entry_apron", + "entry_hood" + ], + "furnitureRoles": [ + "furn_*", + "xs_san_*" + ], + "geometrySource": "placed_module_collision_records", + "interiorDoorPolicy": "closed_static", + "structuralRoles": [ + "walls", + "windows", + "columns", + "static_internal_doors" + ] + }, + "door": { + "closed": { + "id": "closed_exterior_entry", + "maxX": 6.546, + "maxZ": 3.824, + "minX": 5.646, + "minZ": 3.774 + }, + "node": "door_slide" + }, + "floor": { + "bounds": { + "maxX": 9.5, + "maxZ": 7.6, + "minX": 0.0, + "minZ": 0.0 + }, + "slabThicknessM": 0.02, + "topY": 0.02 + }, + "footprint": { + "centerX": 4.75, + "centerZ": 3.8, + "maxX": 9.5, + "maxZ": 7.6, + "minX": 0.0, + "minZ": 0.0, + "spanX": 9.5, + "spanZ": 7.6 + }, + "furniture": [ + { + "id": "furniture_168_furn_corner_hearth__panelclad", + "maxX": 0.832375, + "maxZ": 7.42775, + "minX": 0.17225, + "minZ": 6.767625 + }, + { + "id": "furniture_169_furn_table_1c__panelclad", + "maxX": 2.765, + "maxZ": 6.505, + "minX": 1.985, + "minZ": 5.845 + }, + { + "id": "furniture_170_furn_chair_1c__panelclad", + "maxX": 1.675, + "maxZ": 6.39, + "minX": 1.175, + "minZ": 5.96 + }, + { + "id": "furniture_171_furn_chair_1c__panelclad", + "maxX": 3.575, + "maxZ": 6.39, + "minX": 3.075, + "minZ": 5.96 + }, + { + "id": "furniture_172_furn_pendant_1c__panelclad", + "maxX": 2.605, + "maxZ": 6.405, + "minX": 2.145, + "minZ": 5.945 + }, + { + "id": "furniture_173_furn_stool__panelclad", + "maxX": 2.54, + "maxZ": 5.39, + "minX": 2.21, + "minZ": 5.06 + }, + { + "id": "furniture_174_furn_bench_2c__panelclad", + "maxX": 5.175, + "maxZ": 7.6, + "minX": 3.375, + "minZ": 7.38 + }, + { + "id": "furniture_175_furn_sideboard_2c__panelclad", + "maxX": 7.055, + "maxZ": 7.6, + "minX": 5.295, + "minZ": 7.36 + }, + { + "id": "furniture_176_furn_shelf_1c__panelclad", + "maxX": 0.14, + "maxZ": 4.675, + "minX": 0.0, + "minZ": 3.875 + }, + { + "id": "furniture_177_furn_lamp_wall__panelclad", + "maxX": 5.322, + "maxZ": 7.6, + "minX": 5.128, + "minZ": 7.362 + }, + { + "id": "furniture_178_furn_planter_1c__panelclad", + "maxX": 6.491, + "maxZ": 6.409, + "minX": 5.859, + "minZ": 5.941 + }, + { + "id": "furniture_179_furn_corner_store__panelclad", + "maxX": 9.202375, + "maxZ": 7.302375, + "minX": 8.667625, + "minZ": 6.767625 + }, + { + "id": "furniture_180_furn_corner_shelf__panelclad", + "maxX": 9.202375, + "maxZ": 4.632375, + "minX": 8.667625, + "minZ": 4.097625 + }, + { + "id": "furniture_181_furn_stove_1c__panelclad", + "maxX": 9.5, + "maxZ": 5.675, + "minX": 9.2, + "minZ": 4.775 + }, + { + "id": "furniture_182_furn_sink_1c__panelclad", + "maxX": 9.5, + "maxZ": 6.625, + "minX": 9.2, + "minZ": 5.725 + }, + { + "id": "furniture_183_furn_wall_cab_1c__panelclad", + "maxX": 7.76, + "maxZ": 7.575, + "minX": 7.44, + "minZ": 6.675 + }, + { + "id": "furniture_184_furn_lamp_wall__panelclad", + "maxX": 7.838, + "maxZ": 4.372, + "minX": 7.6, + "minZ": 4.178 + }, + { + "id": "furniture_185_furn_pendant_1c__panelclad", + "maxX": 9.255, + "maxZ": 6.405, + "minX": 8.795, + "minZ": 5.945 + }, + { + "id": "furniture_186_furn_bed_2c__panelclad", + "maxX": 0.44, + "maxZ": 4.225, + "minX": 0.0, + "minZ": 2.425 + }, + { + "id": "furniture_187_furn_nightstand__panelclad", + "maxX": 0.66, + "maxZ": 3.98, + "minX": 0.29, + "minZ": 3.62 + }, + { + "id": "furniture_188_furn_shelf_1c__panelclad", + "maxX": 3.94, + "maxZ": 3.725, + "minX": 3.66, + "minZ": 2.925 + }, + { + "id": "furniture_189_furn_chest_1c__panelclad", + "maxX": 0.875, + "maxZ": 2.11, + "minX": 0.075, + "minZ": 1.69 + }, + { + "id": "furniture_190_furn_lamp_wall__panelclad", + "maxX": 3.422, + "maxZ": 2.138, + "minX": 3.228, + "minZ": 1.9 + }, + { + "id": "furniture_191_furn_corner_store__panelclad", + "maxX": 3.502375, + "maxZ": 0.832375, + "minX": 2.967625, + "minZ": 0.297625 + }, + { + "id": "furniture_192_furn_crate_stack__panelclad", + "maxX": 1.745, + "maxZ": 0.28, + "minX": 1.105, + "minZ": 0.0 + }, + { + "id": "furniture_193_xs_san_basin_1c__panelclad", + "maxX": 0.815, + "maxZ": 1.9, + "minX": 0.135, + "minZ": 1.44 + }, + { + "id": "furniture_194_xs_san_mirrorcab_1c__panelclad", + "maxX": 0.915, + "maxZ": 1.806, + "minX": 0.035, + "minZ": 1.676 + }, + { + "id": "furniture_195_xs_san_reclaimer_1c__panelclad", + "maxX": 0.695, + "maxZ": 0.51, + "minX": 0.255, + "minZ": 0.0 + }, + { + "id": "furniture_196_xs_san_drain_1c__panelclad", + "maxX": 1.725, + "maxZ": 1.565, + "minX": 1.125, + "minZ": 1.285 + }, + { + "id": "furniture_197_furn_lamp_wall__panelclad", + "maxX": 2.472, + "maxZ": 0.238, + "minX": 2.278, + "minZ": 0.0 + }, + { + "id": "furniture_198_furn_lamp_wall__sintered", + "maxX": 4.372, + "maxZ": 3.8, + "minX": 4.178, + "minZ": 3.562 + }, + { + "id": "furniture_199_furn_lamp_wall__sintered", + "maxX": 7.222, + "maxZ": 3.8, + "minX": 7.028, + "minZ": 3.562 + }, + { + "id": "furniture_200_furn_lamp_wall__sintered", + "maxX": 4.038, + "maxZ": 1.522, + "minX": 3.8, + "minZ": 1.328 + } + ], + "generatedBy": "src/package_runtime_buildings.py", + "interiorRegions": [ + { + "floorTopY": 0.02, + "id": "living", + "maxX": 7.6, + "maxZ": 7.6, + "minX": 0.0, + "minZ": 3.8 + }, + { + "floorTopY": 0.02, + "id": "kitchen", + "maxX": 9.5, + "maxZ": 7.6, + "minX": 7.6, + "minZ": 3.8 + }, + { + "floorTopY": 0.02, + "id": "bed", + "maxX": 3.8, + "maxZ": 3.8, + "minX": 0.0, + "minZ": 1.9 + }, + { + "floorTopY": 0.02, + "id": "bath", + "maxX": 3.8, + "maxZ": 1.9, + "minX": 0.0, + "minZ": 0.0 + } + ], + "module_id": "home_modular_court", + "schema": "successor.structure-collision.v3", + "source": "home_modular_court.glb", + "version": "successor.structure-collision.v3", + "walls": [ + { + "id": "wall_112_wall_cham_1c__sintered", + "maxX": 0.95, + "maxZ": 0.95, + "minX": 0.0, + "minZ": 0.0 + }, + { + "id": "wall_113_wall_cham_1c__sintered", + "maxX": 0.95, + "maxZ": 7.6, + "minX": 0.0, + "minZ": 6.65 + }, + { + "id": "wall_114_wall_cham_1c__sintered", + "maxX": 3.88, + "maxZ": 0.95, + "minX": 2.85, + "minZ": 0.0 + }, + { + "id": "wall_115_wall_cham_1c__sintered", + "maxX": 9.5, + "maxZ": 4.75, + "minX": 8.55, + "minZ": 3.72 + }, + { + "id": "wall_116_wall_cham_1c__sintered", + "maxX": 9.5, + "maxZ": 7.6, + "minX": 8.55, + "minZ": 6.65 + }, + { + "id": "wall_117_window_slot_1c__sintered", + "maxX": 1.9, + "maxZ": 0.08, + "minX": 0.95, + "minZ": 0.0 + }, + { + "id": "wall_118_wall_1c__sintered", + "maxX": 2.85, + "maxZ": 0.08, + "minX": 1.9, + "minZ": 0.0 + }, + { + "id": "wall_119_window_slot_1c__sintered", + "maxX": 0.08, + "maxZ": 1.9, + "minX": 0.0, + "minZ": 0.95 + }, + { + "id": "wall_120_wall_1c__sintered", + "maxX": 0.08, + "maxZ": 2.85, + "minX": 0.0, + "minZ": 1.9 + }, + { + "id": "wall_121_window_cler_1c__sintered", + "maxX": 0.08, + "maxZ": 3.8, + "minX": 0.0, + "minZ": 2.85 + }, + { + "id": "wall_122_wall_1c__sintered", + "maxX": 0.08, + "maxZ": 4.75, + "minX": 0.0, + "minZ": 3.8 + }, + { + "id": "wall_123_window_slot_1c__sintered", + "maxX": 0.08, + "maxZ": 5.7, + "minX": 0.0, + "minZ": 4.75 + }, + { + "id": "wall_124_window_cler_1c__sintered", + "maxX": 0.08, + "maxZ": 6.65, + "minX": 0.0, + "minZ": 5.7 + }, + { + "id": "wall_125_wall_1c__sintered", + "maxX": 1.9, + "maxZ": 7.6, + "minX": 0.95, + "minZ": 7.52 + }, + { + "id": "wall_126_wall_1c__sintered", + "maxX": 4.75, + "maxZ": 7.6, + "minX": 3.8, + "minZ": 7.52 + }, + { + "id": "wall_127_wall_1c__sintered", + "maxX": 5.7, + "maxZ": 7.6, + "minX": 4.75, + "minZ": 7.52 + }, + { + "id": "wall_128_wall_1c__sintered", + "maxX": 6.65, + "maxZ": 7.6, + "minX": 5.7, + "minZ": 7.52 + }, + { + "id": "wall_129_wall_1c__sintered", + "maxX": 7.6, + "maxZ": 7.6, + "minX": 6.65, + "minZ": 7.52 + }, + { + "id": "wall_130_window_slot_1c__sintered", + "maxX": 8.55, + "maxZ": 7.6, + "minX": 7.6, + "minZ": 7.52 + }, + { + "id": "wall_131_window_tall_2c__sintered", + "maxX": 3.8, + "maxZ": 7.6, + "minX": 1.9, + "minZ": 7.52 + }, + { + "id": "wall_132_window_cler_1c__sintered", + "maxX": 9.5, + "maxZ": 5.7, + "minX": 9.42, + "minZ": 4.75 + }, + { + "id": "wall_133_wall_1c__sintered", + "maxX": 9.5, + "maxZ": 6.65, + "minX": 9.42, + "minZ": 5.7 + }, + { + "id": "wall_134_wall_1c__sintered", + "maxX": 4.75, + "maxZ": 3.88, + "minX": 3.8, + "minZ": 3.72 + }, + { + "id": "wall_135_wall_1c__sintered", + "maxX": 7.6, + "maxZ": 3.88, + "minX": 6.65, + "minZ": 3.72 + }, + { + "id": "wall_136_window_cler_1c__sintered", + "maxX": 8.55, + "maxZ": 3.88, + "minX": 7.6, + "minZ": 3.72 + }, + { + "id": "wall_137_door_slide_2c__sintered", + "maxX": 6.65, + "maxZ": 3.88, + "minX": 4.75, + "minZ": 3.72 + }, + { + "id": "wall_138_wall_1c__sintered", + "maxX": 3.88, + "maxZ": 1.9, + "minX": 3.72, + "minZ": 0.95 + }, + { + "id": "wall_139_window_slot_1c__sintered", + "maxX": 3.88, + "maxZ": 2.85, + "minX": 3.72, + "minZ": 1.9 + }, + { + "id": "wall_140_wall_1c__sintered", + "maxX": 3.88, + "maxZ": 3.8, + "minX": 3.72, + "minZ": 2.85 + }, + { + "id": "wall_142_wall_tee__sintered", + "maxX": 3.948, + "maxZ": 3.948, + "minX": 3.652, + "minZ": 3.652 + }, + { + "id": "wall_143_wall_1c__plasterlined", + "maxX": 0.95, + "maxZ": 3.88, + "minX": 0.0, + "minZ": 3.72 + }, + { + "id": "wall_144_wall_1c__plasterlined", + "maxX": 3.8, + "maxZ": 3.88, + "minX": 2.85, + "minZ": 3.72 + }, + { + "id": "wall_145_door_inner_2c__plasterlined", + "maxX": 2.85, + "maxZ": 3.88, + "minX": 0.95, + "minZ": 3.72 + }, + { + "id": "wall_146_wall_tee__sintered", + "maxX": 0.148, + "maxZ": 3.948, + "minX": 0.0, + "minZ": 3.652 + }, + { + "id": "wall_147_wall_1c__plasterlined", + "maxX": 0.95, + "maxZ": 1.98, + "minX": 0.0, + "minZ": 1.82 + }, + { + "id": "wall_148_wall_1c__plasterlined", + "maxX": 3.8, + "maxZ": 1.98, + "minX": 2.85, + "minZ": 1.82 + }, + { + "id": "wall_149_door_inner_2c__plasterlined", + "maxX": 2.85, + "maxZ": 1.98, + "minX": 0.95, + "minZ": 1.82 + }, + { + "id": "wall_150_wall_tee__sintered", + "maxX": 0.148, + "maxZ": 2.048, + "minX": 0.0, + "minZ": 1.752 + }, + { + "id": "wall_151_wall_tee__sintered", + "maxX": 3.948, + "maxZ": 2.048, + "minX": 3.652, + "minZ": 1.752 + }, + { + "id": "wall_152_wall_1c__panelclad", + "maxX": 7.68, + "maxZ": 4.75, + "minX": 7.52, + "minZ": 3.8 + }, + { + "id": "wall_153_wall_1c__panelclad", + "maxX": 7.68, + "maxZ": 7.6, + "minX": 7.52, + "minZ": 6.65 + }, + { + "id": "wall_154_door_inner_2c__panelclad", + "maxX": 7.68, + "maxZ": 6.65, + "minX": 7.52, + "minZ": 4.75 + }, + { + "id": "wall_155_wall_tee__sintered", + "maxX": 7.748, + "maxZ": 3.948, + "minX": 7.452, + "minZ": 3.652 + }, + { + "id": "wall_156_wall_tee__sintered", + "maxX": 7.748, + "maxZ": 7.6, + "minX": 7.452, + "minZ": 7.452 + }, + { + "id": "wall_157_wall_half_1c__sintered", + "maxX": 4.75, + "maxZ": 0.08, + "minX": 3.8, + "minZ": 0.0 + }, + { + "id": "wall_158_wall_half_1c__sintered", + "maxX": 5.7, + "maxZ": 0.08, + "minX": 4.75, + "minZ": 0.0 + }, + { + "id": "wall_159_wall_half_1c__sintered", + "maxX": 8.55, + "maxZ": 0.08, + "minX": 7.6, + "minZ": 0.0 + }, + { + "id": "wall_160_wall_half_1c__sintered", + "maxX": 9.5, + "maxZ": 0.08, + "minX": 8.55, + "minZ": 0.0 + }, + { + "id": "wall_161_wall_half_1c__sintered", + "maxX": 9.5, + "maxZ": 0.95, + "minX": 9.42, + "minZ": 0.0 + }, + { + "id": "wall_162_wall_half_1c__sintered", + "maxX": 9.5, + "maxZ": 1.9, + "minX": 9.42, + "minZ": 0.95 + }, + { + "id": "wall_163_wall_half_1c__sintered", + "maxX": 9.5, + "maxZ": 2.85, + "minX": 9.42, + "minZ": 1.9 + }, + { + "id": "wall_164_wall_half_1c__sintered", + "maxX": 9.5, + "maxZ": 3.8, + "minX": 9.42, + "minZ": 2.85 + }, + { + "id": "wall_165_column__sintered", + "maxX": 5.875, + "maxZ": 0.175, + "minX": 5.525, + "minZ": 0.0 + }, + { + "id": "wall_166_column__sintered", + "maxX": 7.775, + "maxZ": 0.175, + "minX": 7.425, + "minZ": 0.0 + }, + { + "id": "wall_167_column__sintered", + "maxX": 9.5, + "maxZ": 0.175, + "minX": 9.325, + "minZ": 0.0 + } + ] +} diff --git a/client-3d/public/assets/world-items/home_modular_court_manifest.json b/client-3d/public/assets/world-items/home_modular_court_manifest.json new file mode 100644 index 00000000..10667a96 --- /dev/null +++ b/client-3d/public/assets/world-items/home_modular_court_manifest.json @@ -0,0 +1,175 @@ +{ + "asset": { + "bytes": 8337816, + "file": "home_modular_court.glb", + "images": { + "external_image_uri_count": 0, + "image_count": 27 + }, + "sha256": "sha256:4fbc8e48b136846e32c28a3373dcca2f0ab515d2a1464e69ae25f83dd580458a" + }, + "bbox_span_m": [ + 9.968, + 3.7655, + 8.078 + ], + "building": "home_modular_court", + "collision": { + "boxes": 339, + "door_boxes": 4, + "sha256": "sha256:b9a86970938c2d423e775f73962ba1cefd71f2a72f895a2c4f11991c42157d47", + "structure_boxes": 335 + }, + "collisionProxy": { + "furnitureCount": 37, + "includesFurniture": true, + "sidecar": "home_modular_court_collision.json", + "source": "home_modular_court", + "structuralOnly": false, + "wallCount": 74 + }, + "cutaway": { + "faces": { + "back": "wall_back__", + "front": "wall_front__", + "left": "wall_left__", + "right": "wall_right__" + }, + "hide": [ + "roof__", + "wall_front__", + "wall_back__", + "wall_left__", + "wall_right__", + "wall_court__" + ], + "keep": [ + "floor__", + "interior__" + ], + "revealPrefixes": [ + "roof__", + "wall_front__", + "wall_back__", + "wall_left__", + "wall_right__", + "wall_court__" + ], + "stub_by_face": true + }, + "door": { + "axisLocal": [ + 1, + 0, + 0 + ], + "clips": [ + "door_close", + "door_open" + ], + "closedInGlb": true, + "local_axis": "X", + "node": "door_slide", + "slide_axis_local": [ + 1, + 0, + 0 + ], + "slide_distance_m": 0.846 + }, + "door_cells": [ + [ + 5, + 4 + ], + [ + 6, + 4 + ] + ], + "exteriorEntry": { + "doorCells": [ + [ + 5, + 4 + ], + [ + 6, + 4 + ] + ], + "doorNode": "door_slide", + "facing": "south", + "frontAxis": "+Z" + }, + "floorHeightM": 0.02, + "floorTopY": 0.02, + "footprintCells": [ + 10, + 8 + ], + "footprint_max_m": [ + 9.5, + 7.6 + ], + "front": "+Z", + "interiorRegions": [ + { + "floorTopY": 0.02, + "id": "living", + "maxX": 7.6, + "maxZ": 7.6, + "minX": 0.0, + "minZ": 3.8 + }, + { + "floorTopY": 0.02, + "id": "kitchen", + "maxX": 9.5, + "maxZ": 7.6, + "minX": 7.6, + "minZ": 3.8 + }, + { + "floorTopY": 0.02, + "id": "bed", + "maxX": 3.8, + "maxZ": 3.8, + "minX": 0.0, + "minZ": 1.9 + }, + { + "floorTopY": 0.02, + "id": "bath", + "maxX": 3.8, + "maxZ": 1.9, + "minX": 0.0, + "minZ": 0.0 + } + ], + "label": "Modular Courtyard Home", + "materials": 547, + "promised_clear_cells": {}, + "runtimeKey": "home_modular_court", + "runtime_scale_at_10x8_cells": 1.0, + "schema": "successor.runtime-building-manifest.v1", + "service_anchor_cells": {}, + "source": { + "floorBoundsM": [ + [ + 0.0, + 0.0 + ], + [ + 9.5, + 7.6 + ] + ], + "furnishedPlacements": 33, + "placements": 311, + "plan": "home_court", + "provenance": "home_modular_court.provenance.json" + }, + "tri_count": 43532, + "units": "m" +} diff --git a/client-3d/public/assets/world-items/home_modular_starter.glb b/client-3d/public/assets/world-items/home_modular_starter.glb new file mode 100644 index 00000000..0194f243 --- /dev/null +++ b/client-3d/public/assets/world-items/home_modular_starter.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b18efb23519ed3e118ffb985a116c7223c0fd0e74cc6f30c16be32e3f8157eb0 +size 8099516 diff --git a/client-3d/public/assets/world-items/home_modular_starter.provenance.json b/client-3d/public/assets/world-items/home_modular_starter.provenance.json new file mode 100644 index 00000000..bebd84f2 --- /dev/null +++ b/client-3d/public/assets/world-items/home_modular_starter.provenance.json @@ -0,0 +1,691 @@ +{ + "artifacts": { + "collision": { + "bytes": 60820, + "path": "home_modular_starter_collision.json", + "sha256": "1e37247d787b0e2e17cbb9ab142b1e776f7cc4e034f23069b581994ccef9af78" + }, + "glb": { + "bytes": 7874120, + "path": "home_modular_starter.glb", + "sha256": "032363b5e4558f279bd71f4ad0d94e44f65f87cff21cee963184546f6a35588f" + }, + "manifest": { + "bytes": 3139, + "path": "home_modular_starter_manifest.json", + "sha256": "05785e8b9c217b93dc56e0f058da8ecc6e51b4a3834398c0bd479ab211bca9a7" + } + }, + "asset": { + "bytes": 7874120, + "path": "home_modular_starter.glb", + "sha256": "sha256:032363b5e4558f279bd71f4ad0d94e44f65f87cff21cee963184546f6a35588f" + }, + "asset_id": "home_modular_starter", + "contract": { + "collision": "home_modular_starter_collision.json", + "entryDoor": "door_slide", + "floorTopY": 0.02, + "footprintCells": [ + 8, + 6 + ], + "manifest": "home_modular_starter_manifest.json" + }, + "generator": { + "lod": 0, + "path": "src/package_runtime_buildings.py", + "sha256": "13be669e4cb44b83ee75f36402145d7f1b083b4e7f33ca62310f05c8a757105e" + }, + "modules": [ + { + "collision": { + "path": "build/collision/ceiling_1x1__plasterlined.collision.json", + "sha256": "934f4eeb7cef52c083635c42da3b564d205d389bfd682f14d05b0b25ae91b621" + }, + "glb": { + "bytes": 3432, + "path": "build/modules/ceiling_1x1__plasterlined_lod0.glb", + "sha256": "e2de1c575d6f48e78d715293665d2253ca30adbb985bfb02c35c0d31e862d523" + }, + "id": "ceiling_1x1__plasterlined", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/ceiling_cham_1x1__plasterlined.collision.json", + "sha256": "c4bcf98c97f41b74cca1067c19da7e75c8b7960515367df8d5aa57ff41c60e1c" + }, + "glb": { + "bytes": 3820, + "path": "build/modules/ceiling_cham_1x1__plasterlined_lod0.glb", + "sha256": "0216ac6533a8699bb6b3df5cb7fb5c842d8bde2dce08ddb129fedab8a29cc8d2" + }, + "id": "ceiling_cham_1x1__plasterlined", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/door_inner_2c__plasterlined.collision.json", + "sha256": "949b78b80ab6b37e375aa91aaf2d51f316fc4a6d29f8bcb9cedb7001746a29a9" + }, + "glb": { + "bytes": 211108, + "path": "build/modules/door_inner_2c__plasterlined_lod0.glb", + "sha256": "5b542ee993356c46576773b960b631cae768d9d5239c1c30f63b5b20cbec882f" + }, + "id": "door_inner_2c__plasterlined", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/door_slide_2c__sintered.collision.json", + "sha256": "d50dca109f9734c23284e9366cf15bac94e8bfd048e71fa62a99d9a51549c566" + }, + "glb": { + "bytes": 217504, + "path": "build/modules/door_slide_2c__sintered_lod0.glb", + "sha256": "963afa5b32546a7f46165a8b6161bc7067cd1b079ec72a060466c3b9dbaaeb00" + }, + "id": "door_slide_2c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/entry_apron_2c__sintered.collision.json", + "sha256": "97a361f393a672ff943b4206129acba62e0df4a3eded956b42c0415d6b54eabd" + }, + "glb": { + "bytes": 11884, + "path": "build/modules/entry_apron_2c__sintered_lod0.glb", + "sha256": "1e78b9130f2d5db6e5123df72f78677622c7d4ecf3177bf8672e1afbfd2cf1f4" + }, + "id": "entry_apron_2c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/entry_hood_2c__sintered.collision.json", + "sha256": "f77bf5cfa53c81e2684642601f43d70f0b4b0dc48953d1ed8b2efe7dfa455ff3" + }, + "glb": { + "bytes": 22528, + "path": "build/modules/entry_hood_2c__sintered_lod0.glb", + "sha256": "761243c447b51b2ccc81fbf00e7b131679620a439618273f340208754b5ee036" + }, + "id": "entry_hood_2c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/floor_1x1__panelclad.collision.json", + "sha256": "f26cd0760ab21d0258518c5c81fc4a06c98c5b6225d4957a01a566e5300cefe5" + }, + "glb": { + "bytes": 7564, + "path": "build/modules/floor_1x1__panelclad_lod0.glb", + "sha256": "b49ddeb21df5605aa2262f5a6afaac70cbe814d11a7b748a08cb0ce20d453c46" + }, + "id": "floor_1x1__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/floor_1x1__sintered.collision.json", + "sha256": "97a7ecffce98eefeed72040395a71f8fbb0e116f53b03bace269cf37b294beb5" + }, + "glb": { + "bytes": 4416, + "path": "build/modules/floor_1x1__sintered_lod0.glb", + "sha256": "1afa58178344e518db24fc98103a1e3546b77ea6a78364cd83501b8546c6e23c" + }, + "id": "floor_1x1__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/floor_cham_1x1__panelclad.collision.json", + "sha256": "5c7d6bda02af13f1e2571bd1b5ac0d351d8c57ed4da064f40672c419fced225c" + }, + "glb": { + "bytes": 5080, + "path": "build/modules/floor_cham_1x1__panelclad_lod0.glb", + "sha256": "2d2e0c713f284283bb192d6219e5499a5ab83b5c0de45304a18563c967316c96" + }, + "id": "floor_cham_1x1__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/floor_cham_1x1__sintered.collision.json", + "sha256": "4a4429851419baaf95b8dbb914ca46ed18379267013838af328b1840c1e04c5e" + }, + "glb": { + "bytes": 5048, + "path": "build/modules/floor_cham_1x1__sintered_lod0.glb", + "sha256": "a1821d3f6e9d13954b580c3af8183feef14cbe05811d0bcb505dc3e50e41bd26" + }, + "id": "floor_cham_1x1__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/found_cham_1x1__sintered.collision.json", + "sha256": "1632ab20bbd50580ad4a629066df14b77594fc746338ebda13f42c52c8bb28fb" + }, + "glb": { + "bytes": 6312, + "path": "build/modules/found_cham_1x1__sintered_lod0.glb", + "sha256": "c0d835ebf5f6397e25b3b77531fa0b17f725fdc526987c1a07e6bd6cb3537a9d" + }, + "id": "found_cham_1x1__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/foundation_1x1__sintered.collision.json", + "sha256": "408b7b90b12a8bcd22eca02c36a5b4e37977e996e390773b153b155866b61a19" + }, + "glb": { + "bytes": 5424, + "path": "build/modules/foundation_1x1__sintered_lod0.glb", + "sha256": "a94a48157159322f5f50969ae6961953844b6e870ae6d8ec121e6125e8f93031" + }, + "id": "foundation_1x1__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_bed_2c__panelclad.collision.json", + "sha256": "a2a08484adb884fcedd1463f34461933d2ff13ae0d08546586cbd5d7dbb43574" + }, + "glb": { + "bytes": 88928, + "path": "build/modules/furn_bed_2c__panelclad_lod0.glb", + "sha256": "53afddbde833a2cb1470adec0bbc782e38c9abc4fb3ed4144389977309c33e46" + }, + "id": "furn_bed_2c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_chair_1c__panelclad.collision.json", + "sha256": "eb6f1cdaea3f77943b05eca62939d038a49dbd4408440d6cd44571ffc0f8a025" + }, + "glb": { + "bytes": 52132, + "path": "build/modules/furn_chair_1c__panelclad_lod0.glb", + "sha256": "daaa2ea380975961928da634fd1285c29b48b55ebe7245aa60d7c8acc60cd6eb" + }, + "id": "furn_chair_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_chest_1c__panelclad.collision.json", + "sha256": "4e26dc2627b2c703360510341a0265d39dc7c01ca2f63a3241ef6ffa066d2de3" + }, + "glb": { + "bytes": 26692, + "path": "build/modules/furn_chest_1c__panelclad_lod0.glb", + "sha256": "2c4786561dd80c9c52d280880872c41f1074c5c2c3e1fe70662ce63c8512bd6a" + }, + "id": "furn_chest_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_corner_bench__panelclad.collision.json", + "sha256": "d1f54e071452e64e1043b19a906ff12638b3bc6be72e08284eb0f027f07fe78d" + }, + "glb": { + "bytes": 28420, + "path": "build/modules/furn_corner_bench__panelclad_lod0.glb", + "sha256": "279faf446c853ae734443c0b54d6a826a83b7532589247be32257048620109f4" + }, + "id": "furn_corner_bench__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_corner_hearth__panelclad.collision.json", + "sha256": "cb530c19cd574dc661fc87908c6d8b09be07e2fd30f2234f35d8b3c0cc0d83b2" + }, + "glb": { + "bytes": 54244, + "path": "build/modules/furn_corner_hearth__panelclad_lod0.glb", + "sha256": "d71acb059f615689232c700c67785f6935b20875be569e27fb59a40058910dac" + }, + "id": "furn_corner_hearth__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_corner_shelf__panelclad.collision.json", + "sha256": "f33a4481c46711873cedd16ca40ac2703e37b70a376e0258c547f1f725dae78f" + }, + "glb": { + "bytes": 43276, + "path": "build/modules/furn_corner_shelf__panelclad_lod0.glb", + "sha256": "c1ea8c47063e8ad717b9c4836e6cd199b5c25d616d461466001449590ed7ac51" + }, + "id": "furn_corner_shelf__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_corner_store__panelclad.collision.json", + "sha256": "aa45949714aeb8b1786426c581b684a2b89b0e1ddee8bfe1c064088023f59a3c" + }, + "glb": { + "bytes": 36920, + "path": "build/modules/furn_corner_store__panelclad_lod0.glb", + "sha256": "cdd89639fb4a1700189fccf10175db50551e669677bea182907ffe2d5ece00b8" + }, + "id": "furn_corner_store__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_counter_2c__panelclad.collision.json", + "sha256": "9efdd7f4b0a38b12d8bf1bd7da64d33032139e31924c6c36c4a5eb97e6d88908" + }, + "glb": { + "bytes": 78956, + "path": "build/modules/furn_counter_2c__panelclad_lod0.glb", + "sha256": "fa66f6ca9652981a7d42070e024507c8d0dd041abb3075b4c847dfa0ed8c0918" + }, + "id": "furn_counter_2c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_crate_stack__panelclad.collision.json", + "sha256": "79d36c1c70727abfb2d5e3369ca2ddf6c51fa548499e07c668ef46641578725d" + }, + "glb": { + "bytes": 46740, + "path": "build/modules/furn_crate_stack__panelclad_lod0.glb", + "sha256": "f3293455ee6b9bc70f5651bf4a3622f3f498ae1cf8e345275785ba72e569c2d1" + }, + "id": "furn_crate_stack__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_desk_2c__panelclad.collision.json", + "sha256": "62f2d87359ad4d9bb02bfe456ebe1d4be8d1edfe6aace0b3cef6ca49664319d0" + }, + "glb": { + "bytes": 63788, + "path": "build/modules/furn_desk_2c__panelclad_lod0.glb", + "sha256": "25b77348d25816f719f8423c01eeefa32bd12ef979de894812c71279bd75b0e2" + }, + "id": "furn_desk_2c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_lamp_wall__panelclad.collision.json", + "sha256": "6cfecc94c25626a77cd0c015a9085f4b96639bb8d8172ced40951824273cf712" + }, + "glb": { + "bytes": 37884, + "path": "build/modules/furn_lamp_wall__panelclad_lod0.glb", + "sha256": "32df49e6cd8b1b29c9cc70e779f8354c46a248d5044a5e993e786da846f790b5" + }, + "id": "furn_lamp_wall__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_nightstand__panelclad.collision.json", + "sha256": "e54a64a0f8124cfa5002ba23e8286f5f3fbc917b418e566474a82a2efa114083" + }, + "glb": { + "bytes": 36568, + "path": "build/modules/furn_nightstand__panelclad_lod0.glb", + "sha256": "6081b7c35d47c22c69dd0675f1102d4f7582eeddf3e8216e94f6fb53b444f547" + }, + "id": "furn_nightstand__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_pendant_1c__panelclad.collision.json", + "sha256": "900b502c0bec5e00559e1165810886242d47e3e072ab8fc71613158119b225b9" + }, + "glb": { + "bytes": 19988, + "path": "build/modules/furn_pendant_1c__panelclad_lod0.glb", + "sha256": "30739ce199caf2fba5c89d633e6b7cfd63d32716bf9725874cf13cd45b5987ee" + }, + "id": "furn_pendant_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_sink_1c__sintered.collision.json", + "sha256": "d3726a6ba3a07cbff365b2d5fa253dbc2c3c07f2793a35324b4cf3b92d27dcae" + }, + "glb": { + "bytes": 80428, + "path": "build/modules/furn_sink_1c__sintered_lod0.glb", + "sha256": "7d1f52127fb3a788aa38ec07317238e93ca97c7134fa1e40203d6a3f715ee5b2" + }, + "id": "furn_sink_1c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_stool__panelclad.collision.json", + "sha256": "e255b57cae4c44ef1cd62a6853cd254b9c3c3004f391c75d16addaaf1dc186fd" + }, + "glb": { + "bytes": 25828, + "path": "build/modules/furn_stool__panelclad_lod0.glb", + "sha256": "8e3941c7bd9b392f23a5ddcbf5a852a67ac9ef8fe0d2aea6b0f49dfb0247b57b" + }, + "id": "furn_stool__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_stove_1c__sintered.collision.json", + "sha256": "e6d14f03be2117f9746e69276f9cda80145623504ea8d5f196f4742846e0a8ba" + }, + "glb": { + "bytes": 82968, + "path": "build/modules/furn_stove_1c__sintered_lod0.glb", + "sha256": "31bc08c5aa92373474baf68f883176c0bd31fd28a60ef711ba3babb62d049d72" + }, + "id": "furn_stove_1c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_table_1c__panelclad.collision.json", + "sha256": "0c98020d398dd3019488bde502cbd44f4ee3f2b0bfdd6518edd271e4b9099ff6" + }, + "glb": { + "bytes": 87728, + "path": "build/modules/furn_table_1c__panelclad_lod0.glb", + "sha256": "eef631cb8f1080bd97ffc512dfc0259aa12278ff846bca71f51ee70b4a9e7ecf" + }, + "id": "furn_table_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/roof_deck_1x1__sintered.collision.json", + "sha256": "378a0eb82196f9c601bf8a208d56a3985dd9721cec2b3bf30a5ecff07e08b14c" + }, + "glb": { + "bytes": 7480, + "path": "build/modules/roof_deck_1x1__sintered_lod0.glb", + "sha256": "c52c7c2396f96a500ce71552b9bffb8f6e6d0e08d4b37a9c6b1bdf864198244d" + }, + "id": "roof_deck_1x1__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/roof_skirt_1c__sintered.collision.json", + "sha256": "88b16f5b877401e738b221f3406d7ea9d962d61e8fcd959698c3e23d76927d88" + }, + "glb": { + "bytes": 11132, + "path": "build/modules/roof_skirt_1c__sintered_lod0.glb", + "sha256": "ab54cdde48a0d3453acf612414f1eef38970ce54be4ff6ce9fd59cdba9d2b06a" + }, + "id": "roof_skirt_1c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/roof_skirt_cham__sintered.collision.json", + "sha256": "a5b1739d599c2b32eaab19419b5ac88853b27dcd778a089b4f0dfe29ea43562b" + }, + "glb": { + "bytes": 17676, + "path": "build/modules/roof_skirt_cham__sintered_lod0.glb", + "sha256": "048272d57c080f42fc373745c920a213e43821a43d5c0a5754ac239ace7b87f9" + }, + "id": "roof_skirt_cham__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/wall_1c__panelclad.collision.json", + "sha256": "8568e361acc8909ea07c8fda190f550bee2f20dcf81855b098cda40d13f8eea4" + }, + "glb": { + "bytes": 37044, + "path": "build/modules/wall_1c__panelclad_lod0.glb", + "sha256": "8afeaa32b2eacec3691659a33247ad4c9f1fe97b32c100f3b38ebee5d7703170" + }, + "id": "wall_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/wall_1c__plasterlined.collision.json", + "sha256": "0c2d61b50b2e946dc5255dd7501f77c1d8e74d9bfce2bc80968ff02bb5fc2895" + }, + "glb": { + "bytes": 30856, + "path": "build/modules/wall_1c__plasterlined_lod0.glb", + "sha256": "74410e082f5daeeb6287cc82612f16222832949711127df55194344a3a0ff769" + }, + "id": "wall_1c__plasterlined", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/wall_1c__sintered.collision.json", + "sha256": "4d4c403a10a18d2f86d2130057381419352cb4e72e3b55f42ad9859ffe298154" + }, + "glb": { + "bytes": 30872, + "path": "build/modules/wall_1c__sintered_lod0.glb", + "sha256": "8cc27185001276e324983f8951a02d082f46cc8ef49860f9e2171021c0410557" + }, + "id": "wall_1c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/wall_cham_1c__sintered.collision.json", + "sha256": "7cdccafeb63a1c45bb0737fee03b0f12d7ce6d6c39b7afbee681289f135759bb" + }, + "glb": { + "bytes": 73472, + "path": "build/modules/wall_cham_1c__sintered_lod0.glb", + "sha256": "db0069922b7bf3df76e5f0af1dabe1abde7df3e47c581ce74585360147ed260f" + }, + "id": "wall_cham_1c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/wall_tee__plasterlined.collision.json", + "sha256": "839ed2efb74e1ce5d8f8563d8a0cfa001cb1e28e3bc65a6052a47eccae64f1c8" + }, + "glb": { + "bytes": 29336, + "path": "build/modules/wall_tee__plasterlined_lod0.glb", + "sha256": "1066175a60818269726df7f1b83755b338271ae6d66e292947a67ecae872da46" + }, + "id": "wall_tee__plasterlined", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/wall_tee__sintered.collision.json", + "sha256": "fca493df6c485d954658e760ab2582b816356a145343f1d8b91a8528933325f8" + }, + "glb": { + "bytes": 29360, + "path": "build/modules/wall_tee__sintered_lod0.glb", + "sha256": "4e1b83fcf02db2a585378efb4fcc1219795d14d2adc5acadb6c5ae24368fb851" + }, + "id": "wall_tee__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/window_cler_1c__sintered.collision.json", + "sha256": "4831fba90fc06359e7af9818b7eb9d8ecf085f9aada2b5046f2359277957cb84" + }, + "glb": { + "bytes": 51592, + "path": "build/modules/window_cler_1c__sintered_lod0.glb", + "sha256": "00fd72248187567a4329821e537112eef071c11a401aa740fa28cb4669c299d0" + }, + "id": "window_cler_1c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/window_slot_1c__sintered.collision.json", + "sha256": "26eae56a750e72a8d2819bade6efed096c9e69b40848e112130e7f95bef4da2d" + }, + "glb": { + "bytes": 103048, + "path": "build/modules/window_slot_1c__sintered_lod0.glb", + "sha256": "a96856f26a86bcb2fd68c2fd862e02685076845ab2f7cbac1a62de6574e3b5b5" + }, + "id": "window_slot_1c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/window_tall_2c__sintered.collision.json", + "sha256": "74fdbba411eb8d543e47f83731a29781d4ef8cfa97d9e7611742f65dd334bafd" + }, + "glb": { + "bytes": 111208, + "path": "build/modules/window_tall_2c__sintered_lod0.glb", + "sha256": "97c17b52f2cc0a5db6858ab2d085dace6f9751914281612c208e1b7f03c498d4" + }, + "id": "window_tall_2c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/expansion_furniture/collision/xs_san_basin_1c__panelclad.collision.json", + "sha256": "a5130d88eae351535a5414cef043a204efc38cc428345131149d96de69d4aea4" + }, + "glb": { + "bytes": 137756, + "path": "build/expansion_furniture/modules/xs_san_basin_1c__panelclad_lod0.glb", + "sha256": "7df47d292086142ff1574aa377b6b28e3f345fd3fc6dd9e62e02b0d2779c0ee4" + }, + "id": "xs_san_basin_1c__panelclad", + "lane": "expansion_furniture" + }, + { + "collision": { + "path": "build/expansion_furniture/collision/xs_san_drain_1c__panelclad.collision.json", + "sha256": "45c79f2757c767fe5da8e7ccf500cb9396205d38f429fca8df0e330a159dd8b8" + }, + "glb": { + "bytes": 71668, + "path": "build/expansion_furniture/modules/xs_san_drain_1c__panelclad_lod0.glb", + "sha256": "019c7e40030583d120174e9d7fcb52f3b9625a1a9b7d127f67b2a21fedb1c444" + }, + "id": "xs_san_drain_1c__panelclad", + "lane": "expansion_furniture" + }, + { + "collision": { + "path": "build/expansion_furniture/collision/xs_san_mirrorcab_1c__panelclad.collision.json", + "sha256": "7bc5220b55994e50ad3e30ededc0caa129a73ed5fd677f0ab5848ff3e1376b7c" + }, + "glb": { + "bytes": 87640, + "path": "build/expansion_furniture/modules/xs_san_mirrorcab_1c__panelclad_lod0.glb", + "sha256": "d274a541dc499ea364d2fee9485ef4fab954220c7462bf6f4b4609f297ca3c3f" + }, + "id": "xs_san_mirrorcab_1c__panelclad", + "lane": "expansion_furniture" + }, + { + "collision": { + "path": "build/expansion_furniture/collision/xs_san_reclaimer_1c__panelclad.collision.json", + "sha256": "bcc9857f9bb6427b69146ca311ded29445666985e08105d85fca399315c7ecc9" + }, + "glb": { + "bytes": 141768, + "path": "build/expansion_furniture/modules/xs_san_reclaimer_1c__panelclad_lod0.glb", + "sha256": "ece43fd2505a07e7ff88354cc7855bc2da2538cf519ffb13e03de8591f1e7501" + }, + "id": "xs_san_reclaimer_1c__panelclad", + "lane": "expansion_furniture" + }, + { + "collision": { + "path": "build/expansion_furniture/collision/xs_san_soaktub_2c__panelclad.collision.json", + "sha256": "9977eb3b32ce525b4eec2e8a3424b8e8d6f886b8260a4013fc66a473b864b55f" + }, + "glb": { + "bytes": 138440, + "path": "build/expansion_furniture/modules/xs_san_soaktub_2c__panelclad_lod0.glb", + "sha256": "e09db83a91b0a03c9c29e2fb6b5a983d15de630444eb279c63a6217617babaaa" + }, + "id": "xs_san_soaktub_2c__panelclad", + "lane": "expansion_furniture" + }, + { + "collision": { + "path": "build/expansion_furniture/collision/xs_san_towelbar_1c__panelclad.collision.json", + "sha256": "873c6f47b5e3df82ba2e3b81f272461a19f19279539982ab868c9cae6fdcf525" + }, + "glb": { + "bytes": 76728, + "path": "build/expansion_furniture/modules/xs_san_towelbar_1c__panelclad_lod0.glb", + "sha256": "b2a7bab9d9a6fd2a93bf238aef9a80fed89bf43478ae956a12e986acf38fe506" + }, + "id": "xs_san_towelbar_1c__panelclad", + "lane": "expansion_furniture" + } + ], + "pack": { + "colour_accessors_quantized": 489, + "images_embedded": 0, + "unique_image_bytes": 0 + }, + "runtime_key": "home_modular_starter", + "schema": "successor.runtime-building-provenance.v1", + "source_plan": "home_starter", + "sources": [ + { + "path": "src/package_runtime_buildings.py", + "sha256": "13be669e4cb44b83ee75f36402145d7f1b083b4e7f33ca62310f05c8a757105e" + }, + { + "path": "src/assemble_home.py", + "sha256": "236cc54de80f2e1cd0ab572fa5fdc0731e76de10617062fe8d6c227e50ab8692" + }, + { + "path": "src/home_plan.py", + "sha256": "3f0bc706756a76fa5182b1b8f1e008d71fba087c25e6faeda66c1a131a02544b" + }, + { + "path": "src/kit_grid.py", + "sha256": "b1cda4f166174d37edf25d64dd35d9fc108571676a8a1b5a417e29b0aa61fb19" + }, + { + "path": "src/kit_spec.py", + "sha256": "04456196e9e3cbe5554f576fd61582ec03bee6e02db9c7212a870f267da53fae" + }, + { + "path": "src/pack_runtime_glb.py", + "sha256": "694cfff9a23674c4be7846005f8b1b090ff5667cb554c632f2b5339abf232f6d" + }, + { + "path": "build/buildkit.manifest.json", + "sha256": "3dad4470f218c43c7c3812c2d64a4a0cb4b17f56299a9fc0a3ed479fe406e59d" + } + ] +} diff --git a/client-3d/public/assets/world-items/home_modular_starter_collision.json b/client-3d/public/assets/world-items/home_modular_starter_collision.json new file mode 100644 index 00000000..4610abf5 --- /dev/null +++ b/client-3d/public/assets/world-items/home_modular_starter_collision.json @@ -0,0 +1,4234 @@ +{ + "boxes": [ + { + "center": [ + 0.475, + -0.16, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 0.475, + 0.01, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 0.475, + -0.16, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 0.475, + 0.01, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 0.475, + -0.16, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 0.475, + 0.01, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 0.475, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 0.475, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 0.475, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 0.475, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 0.475, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 0.475, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 1.425, + -0.16, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 1.425, + 0.01, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 1.425, + -0.16, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 1.425, + 0.01, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 1.425, + -0.16, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 1.425, + 0.01, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 1.425, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 1.425, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 1.425, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 1.425, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 1.425, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 1.425, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 2.375, + -0.16, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 2.375, + 0.01, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 2.375, + -0.16, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 2.375, + 0.01, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 2.375, + -0.16, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 2.375, + 0.01, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 2.375, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 2.375, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 2.375, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 2.375, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 2.375, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 2.375, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 3.325, + -0.16, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 3.325, + 0.01, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 3.325, + -0.16, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 3.325, + 0.01, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 3.325, + -0.16, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 3.325, + 0.01, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 3.325, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 3.325, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 3.325, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 3.325, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 3.325, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 3.325, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 0.7125, + 1.5, + 0.0 + ], + "kind": "structure", + "size": [ + 0.475, + 3.0, + 0.16 + ] + }, + { + "center": [ + 0.0, + 1.5, + 0.7125 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.475 + ] + }, + { + "center": [ + 0.2375, + 1.5, + 0.2375 + ], + "kind": "structure", + "size": [ + 0.635, + 3.0, + 0.635 + ] + }, + { + "center": [ + 0.0, + 1.5, + 4.9875 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.475 + ] + }, + { + "center": [ + 0.7125, + 1.5, + 5.7 + ], + "kind": "structure", + "size": [ + 0.475, + 3.0, + 0.16 + ] + }, + { + "center": [ + 0.2375, + 1.5, + 5.4625 + ], + "kind": "structure", + "size": [ + 0.635, + 3.0, + 0.635 + ] + }, + { + "center": [ + 7.6, + 1.5, + 0.7125 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.475 + ] + }, + { + "center": [ + 6.8875, + 1.5, + 0.0 + ], + "kind": "structure", + "size": [ + 0.475, + 3.0, + 0.16 + ] + }, + { + "center": [ + 7.3625, + 1.5, + 0.2375 + ], + "kind": "structure", + "size": [ + 0.635, + 3.0, + 0.635 + ] + }, + { + "center": [ + 6.8875, + 1.5, + 5.7 + ], + "kind": "structure", + "size": [ + 0.475, + 3.0, + 0.16 + ] + }, + { + "center": [ + 7.6, + 1.5, + 4.9875 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.475 + ] + }, + { + "center": [ + 7.3625, + 1.5, + 5.4625 + ], + "kind": "structure", + "size": [ + 0.635, + 3.0, + 0.635 + ] + }, + { + "center": [ + 5.225, + 1.5, + 0.0 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 6.175, + 1.5, + 0.0 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 1.9, + 1.5, + 0.0 + ], + "kind": "structure", + "size": [ + 1.9, + 3.0, + 0.16 + ] + }, + { + "center": [ + 4.685, + 1.5, + 0.0 + ], + "kind": "structure", + "size": [ + 0.13, + 3.0, + 0.16 + ] + }, + { + "center": [ + 3.325, + 1.5, + 0.0 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 4.21, + 2.53, + 0.0 + ], + "kind": "structure", + "size": [ + 0.82, + 0.94, + 0.16 + ] + }, + { + "center": [ + 4.196, + 1.044, + 0.0 + ], + "kind": "door", + "size": [ + 0.9, + 1.992, + 0.05 + ] + }, + { + "center": [ + 3.8, + 2.58, + -0.77 + ], + "kind": "structure", + "size": [ + 1.9, + 0.32, + 1.46 + ] + }, + { + "center": [ + 3.8, + -0.02482, + -0.69 + ], + "kind": "structure", + "size": [ + 1.9, + 0.075, + 1.22 + ] + }, + { + "center": [ + 1.425, + 1.5, + 5.7 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 4.275, + 1.5, + 5.7 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 5.225, + 1.5, + 5.7 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 6.175, + 1.5, + 5.7 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 2.85, + 1.5, + 5.7 + ], + "kind": "structure", + "size": [ + 1.9, + 3.0, + 0.16 + ] + }, + { + "center": [ + 0.0, + 1.5, + 1.425 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 0.0, + 1.5, + 2.375 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 0.0, + 1.5, + 3.325 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 0.0, + 1.5, + 4.275 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 7.6, + 1.5, + 1.425 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 7.6, + 1.5, + 2.375 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 7.6, + 1.5, + 3.325 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 7.6, + 1.5, + 4.275 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 0.475, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 3.325, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 4.275, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 7.125, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 1.035, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.17, + 3.0, + 0.16 + ] + }, + { + "center": [ + 2.375, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 1.51, + 2.53, + 2.85 + ], + "kind": "structure", + "size": [ + 0.78, + 0.94, + 0.16 + ] + }, + { + "center": [ + 1.524, + 1.044, + 2.85 + ], + "kind": "door", + "size": [ + 0.86, + 1.992, + 0.05 + ] + }, + { + "center": [ + 4.835, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.17, + 3.0, + 0.16 + ] + }, + { + "center": [ + 6.175, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 5.31, + 2.53, + 2.85 + ], + "kind": "structure", + "size": [ + 0.78, + 0.94, + 0.16 + ] + }, + { + "center": [ + 5.324, + 1.044, + 2.85 + ], + "kind": "door", + "size": [ + 0.86, + 1.992, + 0.05 + ] + }, + { + "center": [ + 0.0, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.296, + 3.0, + 0.296 + ] + }, + { + "center": [ + 7.6, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.296, + 3.0, + 0.296 + ] + }, + { + "center": [ + 3.8, + 1.5, + 3.325 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 3.8, + 1.5, + 4.275 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 3.8, + 1.5, + 5.225 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 3.8, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.296, + 3.0, + 0.296 + ] + }, + { + "center": [ + 3.8, + 1.5, + 5.7 + ], + "kind": "structure", + "size": [ + 0.296, + 3.0, + 0.296 + ] + }, + { + "center": [ + 5.7, + 1.5, + 0.475 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 5.7, + 1.5, + 1.425 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 5.7, + 1.5, + 0.0 + ], + "kind": "structure", + "size": [ + 0.296, + 3.0, + 0.296 + ] + }, + { + "center": [ + 1.425, + 0.38, + 1.425 + ], + "kind": "structure", + "size": [ + 0.78, + 0.72, + 0.66 + ] + }, + { + "center": [ + 2.375, + 0.225, + 1.425 + ], + "kind": "structure", + "size": [ + 0.33, + 0.41, + 0.33 + ] + }, + { + "center": [ + 1.425, + 0.435, + 2.375 + ], + "kind": "structure", + "size": [ + 0.43, + 0.83, + 0.5 + ] + }, + { + "center": [ + 1.425, + 1.905, + 1.425 + ], + "kind": "structure", + "size": [ + 0.46, + 0.16, + 0.46 + ] + }, + { + "center": [ + 2.375, + 0.225, + 0.475 + ], + "kind": "structure", + "size": [ + 0.33, + 0.41, + 0.33 + ] + }, + { + "center": [ + 4.275, + 0.46, + 2.85 + ], + "kind": "structure", + "size": [ + 1.81, + 0.88, + 0.6 + ] + }, + { + "center": [ + 0.0, + 0.46, + 1.425 + ], + "kind": "structure", + "size": [ + 0.6, + 0.88, + 0.9 + ] + }, + { + "center": [ + 0.0, + 0.46, + 2.375 + ], + "kind": "structure", + "size": [ + 0.6, + 0.88, + 0.9 + ] + }, + { + "center": [ + 0.0, + 1.38, + 2.125 + ], + "kind": "structure", + "size": [ + 0.6, + 0.96, + 0.37 + ] + }, + { + "center": [ + 5.225, + 1.875, + 0.119 + ], + "kind": "structure", + "size": [ + 0.194, + 0.514, + 0.238 + ] + }, + { + "center": [ + 0.475, + 1.875, + 2.731 + ], + "kind": "structure", + "size": [ + 0.194, + 0.514, + 0.238 + ] + }, + { + "center": [ + 7.6, + 0.38, + 2.375 + ], + "kind": "structure", + "size": [ + 0.64, + 0.72, + 1.72 + ] + }, + { + "center": [ + 6.175, + 0.435, + 2.375 + ], + "kind": "structure", + "size": [ + 0.5, + 0.83, + 0.43 + ] + }, + { + "center": [ + 6.175, + 0.459, + 0.0 + ], + "kind": "structure", + "size": [ + 0.64, + 0.878, + 0.56 + ] + }, + { + "center": [ + 7.125, + 1.875, + 2.731 + ], + "kind": "structure", + "size": [ + 0.194, + 0.514, + 0.238 + ] + }, + { + "center": [ + 2.375, + 0.2, + 5.7 + ], + "kind": "structure", + "size": [ + 1.8, + 0.36, + 0.88 + ] + }, + { + "center": [ + 0.0, + 0.29, + 3.325 + ], + "kind": "structure", + "size": [ + 0.36, + 0.54, + 0.37 + ] + }, + { + "center": [ + 0.0, + 0.474, + 4.275 + ], + "kind": "structure", + "size": [ + 0.42, + 0.908, + 0.8 + ] + }, + { + "center": [ + 1.425, + 1.875, + 5.581 + ], + "kind": "structure", + "size": [ + 0.194, + 0.514, + 0.238 + ] + }, + { + "center": [ + 4.2, + 0.3, + 4.275 + ], + "kind": "structure", + "size": [ + 0.8, + 0.56, + 1.74 + ] + }, + { + "center": [ + 4.2, + 0.76, + 3.605 + ], + "kind": "structure", + "size": [ + 0.68, + 0.28, + 0.32 + ] + }, + { + "center": [ + 6.175, + 0.46, + 5.47 + ], + "kind": "structure", + "size": [ + 0.68, + 0.88, + 0.46 + ] + }, + { + "center": [ + 6.175, + 1.665, + 5.541 + ], + "kind": "structure", + "size": [ + 0.88, + 0.53, + 0.13 + ] + }, + { + "center": [ + 4.275, + 0.249, + 3.12 + ], + "kind": "structure", + "size": [ + 0.44, + 0.458, + 0.48 + ] + }, + { + "center": [ + 4.275, + 0.77, + 2.935 + ], + "kind": "structure", + "size": [ + 0.36, + 0.42, + 0.17 + ] + }, + { + "center": [ + 4.275, + 1.25, + 5.568 + ], + "kind": "structure", + "size": [ + 0.75, + 0.244, + 0.104 + ] + }, + { + "center": [ + 6.175, + 0.059, + 4.275 + ], + "kind": "structure", + "size": [ + 0.6, + 0.078, + 0.28 + ] + }, + { + "center": [ + 7.6, + 0.474, + 4.275 + ], + "kind": "structure", + "size": [ + 0.42, + 0.908, + 0.8 + ] + }, + { + "center": [ + 7.481, + 1.875, + 3.325 + ], + "kind": "structure", + "size": [ + 0.238, + 0.514, + 0.194 + ] + }, + { + "center": [ + 0.565, + 0.5, + 0.565 + ], + "kind": "structure", + "size": [ + 0.53475, + 0.96, + 0.53475 + ] + }, + { + "center": [ + 0.27, + 1.635, + 0.27 + ], + "kind": "structure", + "size": [ + 0.1955, + 1.31, + 0.1955 + ] + }, + { + "center": [ + 7.035, + 0.842, + 0.565 + ], + "kind": "structure", + "size": [ + 0.53475, + 1.644, + 0.53475 + ] + }, + { + "center": [ + 0.565, + 0.225, + 5.135 + ], + "kind": "structure", + "size": [ + 0.53475, + 0.41, + 0.53475 + ] + }, + { + "center": [ + 7.035, + 0.95, + 5.135 + ], + "kind": "structure", + "size": [ + 0.53475, + 1.86, + 0.53475 + ] + }, + { + "center": [ + 0.475, + 2.57, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 0.475, + 2.57, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 0.475, + 2.57, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 0.475, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 0.475, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 0.475, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 1.425, + 2.57, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 1.425, + 2.57, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 1.425, + 2.57, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 1.425, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 1.425, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 1.425, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 2.375, + 2.57, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 2.375, + 2.57, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 2.375, + 2.57, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 2.375, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 2.375, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 2.375, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 3.325, + 2.57, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 3.325, + 2.57, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 3.325, + 2.57, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 3.325, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 3.325, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 3.325, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 5.225, + 2.57, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 5.225, + 2.57, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 5.225, + 2.57, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 5.225, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 5.225, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 5.225, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 0.475, + 3.06875, + 0.475 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 0.405, + 3.06875, + 1.425 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 0.405, + 3.06875, + 2.375 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 0.405, + 3.06875, + 3.325 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 0.405, + 3.06875, + 4.275 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 0.475, + 3.06875, + 5.225 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 1.425, + 3.06875, + 0.405 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 1.425, + 3.1725, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 1.425, + 3.1725, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 1.425, + 3.1725, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 1.425, + 3.1725, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 1.425, + 3.06875, + 5.295 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 2.375, + 3.06875, + 0.405 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 2.375, + 3.1725, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 2.375, + 3.1725, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 2.375, + 3.1725, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 2.375, + 3.1725, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 2.375, + 3.06875, + 5.295 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 3.325, + 3.06875, + 0.405 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 3.325, + 3.1725, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 3.325, + 3.1725, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 3.325, + 3.1725, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 3.325, + 3.1725, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 3.325, + 3.06875, + 5.295 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 4.275, + 3.06875, + 0.405 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 4.275, + 3.1725, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 4.275, + 3.1725, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 4.275, + 3.1725, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 4.275, + 3.1725, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 4.275, + 3.06875, + 5.295 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 5.225, + 3.06875, + 0.405 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 5.225, + 3.1725, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 5.225, + 3.1725, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 5.225, + 3.1725, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 5.225, + 3.1725, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 5.225, + 3.06875, + 5.295 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 6.175, + 3.06875, + 0.405 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 6.175, + 3.1725, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 6.175, + 3.1725, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 6.175, + 3.1725, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 6.175, + 3.1725, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 6.175, + 3.06875, + 5.295 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 7.125, + 3.06875, + 0.475 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 7.195, + 3.06875, + 1.425 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 7.195, + 3.06875, + 2.375 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 7.195, + 3.06875, + 3.325 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 7.195, + 3.06875, + 4.275 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 7.125, + 3.06875, + 5.225 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 1.09 + ] + } + ], + "contract": { + "decorativeExcluded": [ + "foundation", + "floor", + "ceiling", + "roof", + "entry_apron", + "entry_hood" + ], + "furnitureRoles": [ + "furn_*", + "xs_san_*" + ], + "geometrySource": "placed_module_collision_records", + "interiorDoorPolicy": "closed_static", + "structuralRoles": [ + "walls", + "windows", + "columns", + "static_internal_doors" + ] + }, + "door": { + "closed": { + "id": "closed_exterior_entry", + "maxX": 4.646, + "maxZ": 0.025, + "minX": 3.746, + "minZ": 0.0 + }, + "node": "door_slide" + }, + "floor": { + "bounds": { + "maxX": 7.6, + "maxZ": 5.7, + "minX": 0.0, + "minZ": 0.0 + }, + "slabThicknessM": 0.02, + "topY": 0.02 + }, + "footprint": { + "centerX": 3.8, + "centerZ": 2.85, + "maxX": 7.6, + "maxZ": 5.7, + "minX": 0.0, + "minZ": 0.0, + "spanX": 7.6, + "spanZ": 5.7 + }, + "furniture": [ + { + "id": "furniture_135_furn_table_1c__panelclad", + "maxX": 1.815, + "maxZ": 1.755, + "minX": 1.035, + "minZ": 1.095 + }, + { + "id": "furniture_136_furn_stool__panelclad", + "maxX": 2.54, + "maxZ": 1.59, + "minX": 2.21, + "minZ": 1.26 + }, + { + "id": "furniture_137_furn_chair_1c__panelclad", + "maxX": 1.64, + "maxZ": 2.625, + "minX": 1.21, + "minZ": 2.125 + }, + { + "id": "furniture_138_furn_pendant_1c__panelclad", + "maxX": 1.655, + "maxZ": 1.655, + "minX": 1.195, + "minZ": 1.195 + }, + { + "id": "furniture_139_furn_stool__panelclad", + "maxX": 2.54, + "maxZ": 0.64, + "minX": 2.21, + "minZ": 0.31 + }, + { + "id": "furniture_140_furn_counter_2c__panelclad", + "maxX": 5.18, + "maxZ": 3.15, + "minX": 3.37, + "minZ": 2.55 + }, + { + "id": "furniture_141_furn_sink_1c__sintered", + "maxX": 0.3, + "maxZ": 1.875, + "minX": 0.0, + "minZ": 0.975 + }, + { + "id": "furniture_142_furn_stove_1c__sintered", + "maxX": 0.3, + "maxZ": 2.825, + "minX": 0.0, + "minZ": 1.925 + }, + { + "id": "furniture_143_furn_lamp_wall__panelclad", + "maxX": 5.322, + "maxZ": 0.238, + "minX": 5.128, + "minZ": 0.0 + }, + { + "id": "furniture_144_furn_lamp_wall__panelclad", + "maxX": 0.572, + "maxZ": 2.85, + "minX": 0.378, + "minZ": 2.612 + }, + { + "id": "furniture_145_furn_desk_2c__panelclad", + "maxX": 7.6, + "maxZ": 3.235, + "minX": 7.28, + "minZ": 1.515 + }, + { + "id": "furniture_146_furn_chair_1c__panelclad", + "maxX": 6.425, + "maxZ": 2.59, + "minX": 5.925, + "minZ": 2.16 + }, + { + "id": "furniture_147_furn_crate_stack__panelclad", + "maxX": 6.495, + "maxZ": 0.28, + "minX": 5.855, + "minZ": 0.0 + }, + { + "id": "furniture_148_furn_lamp_wall__panelclad", + "maxX": 7.222, + "maxZ": 2.85, + "minX": 7.028, + "minZ": 2.612 + }, + { + "id": "furniture_149_furn_bed_2c__panelclad", + "maxX": 3.275, + "maxZ": 5.7, + "minX": 1.475, + "minZ": 5.26 + }, + { + "id": "furniture_150_furn_nightstand__panelclad", + "maxX": 0.18, + "maxZ": 3.51, + "minX": 0.0, + "minZ": 3.14 + }, + { + "id": "furniture_151_furn_chest_1c__panelclad", + "maxX": 0.21, + "maxZ": 4.675, + "minX": 0.0, + "minZ": 3.875 + }, + { + "id": "furniture_152_furn_lamp_wall__panelclad", + "maxX": 1.522, + "maxZ": 5.7, + "minX": 1.328, + "minZ": 5.462 + }, + { + "id": "furniture_153_xs_san_soaktub_2c__panelclad", + "maxX": 4.6, + "maxZ": 5.145, + "minX": 3.8, + "minZ": 3.405 + }, + { + "id": "furniture_154_xs_san_basin_1c__panelclad", + "maxX": 6.515, + "maxZ": 5.7, + "minX": 5.835, + "minZ": 5.24 + }, + { + "id": "furniture_155_xs_san_mirrorcab_1c__panelclad", + "maxX": 6.615, + "maxZ": 5.606, + "minX": 5.735, + "minZ": 5.476 + }, + { + "id": "furniture_156_xs_san_reclaimer_1c__panelclad", + "maxX": 4.495, + "maxZ": 3.36, + "minX": 4.055, + "minZ": 2.85 + }, + { + "id": "furniture_157_xs_san_towelbar_1c__panelclad", + "maxX": 4.65, + "maxZ": 5.62, + "minX": 3.9, + "minZ": 5.516 + }, + { + "id": "furniture_158_xs_san_drain_1c__panelclad", + "maxX": 6.475, + "maxZ": 4.415, + "minX": 5.875, + "minZ": 4.135 + }, + { + "id": "furniture_159_furn_chest_1c__panelclad", + "maxX": 7.6, + "maxZ": 4.675, + "minX": 7.39, + "minZ": 3.875 + }, + { + "id": "furniture_160_furn_lamp_wall__panelclad", + "maxX": 7.6, + "maxZ": 3.422, + "minX": 7.362, + "minZ": 3.228 + }, + { + "id": "furniture_161_furn_corner_hearth__panelclad", + "maxX": 0.832375, + "maxZ": 0.832375, + "minX": 0.17225, + "minZ": 0.17225 + }, + { + "id": "furniture_162_furn_corner_shelf__panelclad", + "maxX": 7.302375, + "maxZ": 0.832375, + "minX": 6.767625, + "minZ": 0.297625 + }, + { + "id": "furniture_163_furn_corner_bench__panelclad", + "maxX": 0.832375, + "maxZ": 5.402375, + "minX": 0.297625, + "minZ": 4.867625 + }, + { + "id": "furniture_164_furn_corner_store__panelclad", + "maxX": 7.302375, + "maxZ": 5.402375, + "minX": 6.767625, + "minZ": 4.867625 + } + ], + "generatedBy": "src/package_runtime_buildings.py", + "interiorRegions": [ + { + "floorTopY": 0.02, + "id": "living", + "maxX": 5.7, + "maxZ": 2.85, + "minX": 0.0, + "minZ": 0.0 + }, + { + "floorTopY": 0.02, + "id": "study", + "maxX": 7.6, + "maxZ": 2.85, + "minX": 5.7, + "minZ": 0.0 + }, + { + "floorTopY": 0.02, + "id": "bedroom", + "maxX": 3.8, + "maxZ": 5.7, + "minX": 0.0, + "minZ": 2.85 + }, + { + "floorTopY": 0.02, + "id": "bath", + "maxX": 7.6, + "maxZ": 5.7, + "minX": 3.8, + "minZ": 2.85 + } + ], + "module_id": "home_modular_starter", + "schema": "successor.structure-collision.v3", + "source": "home_modular_starter.glb", + "version": "successor.structure-collision.v3", + "walls": [ + { + "id": "wall_096_wall_cham_1c__sintered", + "maxX": 0.95, + "maxZ": 0.95, + "minX": 0.0, + "minZ": 0.0 + }, + { + "id": "wall_097_wall_cham_1c__sintered", + "maxX": 0.95, + "maxZ": 5.7, + "minX": 0.0, + "minZ": 4.75 + }, + { + "id": "wall_098_wall_cham_1c__sintered", + "maxX": 7.6, + "maxZ": 0.95, + "minX": 6.65, + "minZ": 0.0 + }, + { + "id": "wall_099_wall_cham_1c__sintered", + "maxX": 7.6, + "maxZ": 5.7, + "minX": 6.65, + "minZ": 4.75 + }, + { + "id": "wall_100_wall_1c__sintered", + "maxX": 5.7, + "maxZ": 0.08, + "minX": 4.75, + "minZ": 0.0 + }, + { + "id": "wall_101_window_slot_1c__sintered", + "maxX": 6.65, + "maxZ": 0.08, + "minX": 5.7, + "minZ": 0.0 + }, + { + "id": "wall_102_window_tall_2c__sintered", + "maxX": 2.85, + "maxZ": 0.08, + "minX": 0.95, + "minZ": 0.0 + }, + { + "id": "wall_103_door_slide_2c__sintered", + "maxX": 4.75, + "maxZ": 0.08, + "minX": 2.85, + "minZ": 0.0 + }, + { + "id": "wall_106_wall_1c__sintered", + "maxX": 1.9, + "maxZ": 5.7, + "minX": 0.95, + "minZ": 5.62 + }, + { + "id": "wall_107_wall_1c__sintered", + "maxX": 4.75, + "maxZ": 5.7, + "minX": 3.8, + "minZ": 5.62 + }, + { + "id": "wall_108_window_slot_1c__sintered", + "maxX": 5.7, + "maxZ": 5.7, + "minX": 4.75, + "minZ": 5.62 + }, + { + "id": "wall_109_wall_1c__sintered", + "maxX": 6.65, + "maxZ": 5.7, + "minX": 5.7, + "minZ": 5.62 + }, + { + "id": "wall_110_window_tall_2c__sintered", + "maxX": 3.8, + "maxZ": 5.7, + "minX": 1.9, + "minZ": 5.62 + }, + { + "id": "wall_111_window_slot_1c__sintered", + "maxX": 0.08, + "maxZ": 1.9, + "minX": 0.0, + "minZ": 0.95 + }, + { + "id": "wall_112_window_cler_1c__sintered", + "maxX": 0.08, + "maxZ": 2.85, + "minX": 0.0, + "minZ": 1.9 + }, + { + "id": "wall_113_wall_1c__sintered", + "maxX": 0.08, + "maxZ": 3.8, + "minX": 0.0, + "minZ": 2.85 + }, + { + "id": "wall_114_window_slot_1c__sintered", + "maxX": 0.08, + "maxZ": 4.75, + "minX": 0.0, + "minZ": 3.8 + }, + { + "id": "wall_115_window_cler_1c__sintered", + "maxX": 7.6, + "maxZ": 1.9, + "minX": 7.52, + "minZ": 0.95 + }, + { + "id": "wall_116_wall_1c__sintered", + "maxX": 7.6, + "maxZ": 2.85, + "minX": 7.52, + "minZ": 1.9 + }, + { + "id": "wall_117_wall_1c__sintered", + "maxX": 7.6, + "maxZ": 3.8, + "minX": 7.52, + "minZ": 2.85 + }, + { + "id": "wall_118_window_slot_1c__sintered", + "maxX": 7.6, + "maxZ": 4.75, + "minX": 7.52, + "minZ": 3.8 + }, + { + "id": "wall_119_wall_1c__plasterlined", + "maxX": 0.95, + "maxZ": 2.93, + "minX": 0.0, + "minZ": 2.77 + }, + { + "id": "wall_120_wall_1c__plasterlined", + "maxX": 3.8, + "maxZ": 2.93, + "minX": 2.85, + "minZ": 2.77 + }, + { + "id": "wall_121_wall_1c__plasterlined", + "maxX": 4.75, + "maxZ": 2.93, + "minX": 3.8, + "minZ": 2.77 + }, + { + "id": "wall_122_wall_1c__plasterlined", + "maxX": 7.6, + "maxZ": 2.93, + "minX": 6.65, + "minZ": 2.77 + }, + { + "id": "wall_123_door_inner_2c__plasterlined", + "maxX": 2.85, + "maxZ": 2.93, + "minX": 0.95, + "minZ": 2.77 + }, + { + "id": "wall_124_door_inner_2c__plasterlined", + "maxX": 6.65, + "maxZ": 2.93, + "minX": 4.75, + "minZ": 2.77 + }, + { + "id": "wall_125_wall_tee__sintered", + "maxX": 0.148, + "maxZ": 2.998, + "minX": 0.0, + "minZ": 2.702 + }, + { + "id": "wall_126_wall_tee__sintered", + "maxX": 7.6, + "maxZ": 2.998, + "minX": 7.452, + "minZ": 2.702 + }, + { + "id": "wall_127_wall_1c__plasterlined", + "maxX": 3.88, + "maxZ": 3.8, + "minX": 3.72, + "minZ": 2.85 + }, + { + "id": "wall_128_wall_1c__plasterlined", + "maxX": 3.88, + "maxZ": 4.75, + "minX": 3.72, + "minZ": 3.8 + }, + { + "id": "wall_129_wall_1c__plasterlined", + "maxX": 3.88, + "maxZ": 5.7, + "minX": 3.72, + "minZ": 4.75 + }, + { + "id": "wall_130_wall_tee__plasterlined", + "maxX": 3.948, + "maxZ": 2.998, + "minX": 3.652, + "minZ": 2.702 + }, + { + "id": "wall_131_wall_tee__sintered", + "maxX": 3.948, + "maxZ": 5.7, + "minX": 3.652, + "minZ": 5.552 + }, + { + "id": "wall_132_wall_1c__panelclad", + "maxX": 5.78, + "maxZ": 0.95, + "minX": 5.62, + "minZ": 0.0 + }, + { + "id": "wall_133_wall_1c__panelclad", + "maxX": 5.78, + "maxZ": 1.9, + "minX": 5.62, + "minZ": 0.95 + }, + { + "id": "wall_134_wall_tee__sintered", + "maxX": 5.848, + "maxZ": 0.148, + "minX": 5.552, + "minZ": 0.0 + } + ] +} diff --git a/client-3d/public/assets/world-items/home_modular_starter_manifest.json b/client-3d/public/assets/world-items/home_modular_starter_manifest.json new file mode 100644 index 00000000..5aeba8e2 --- /dev/null +++ b/client-3d/public/assets/world-items/home_modular_starter_manifest.json @@ -0,0 +1,175 @@ +{ + "asset": { + "bytes": 7874120, + "file": "home_modular_starter.glb", + "images": { + "external_image_uri_count": 0, + "image_count": 30 + }, + "sha256": "sha256:032363b5e4558f279bd71f4ad0d94e44f65f87cff21cee963184546f6a35588f" + }, + "bbox_span_m": [ + 8.056, + 3.6015, + 7.454 + ], + "building": "home_modular_starter", + "collision": { + "boxes": 282, + "door_boxes": 3, + "sha256": "sha256:1e37247d787b0e2e17cbb9ab142b1e776f7cc4e034f23069b581994ccef9af78", + "structure_boxes": 279 + }, + "collisionProxy": { + "furnitureCount": 34, + "includesFurniture": true, + "sidecar": "home_modular_starter_collision.json", + "source": "home_modular_starter", + "structuralOnly": false, + "wallCount": 53 + }, + "cutaway": { + "faces": { + "back": "wall_back__", + "front": "wall_front__", + "left": "wall_left__", + "right": "wall_right__" + }, + "hide": [ + "roof__", + "wall_front__", + "wall_back__", + "wall_left__", + "wall_right__", + "wall_court__" + ], + "keep": [ + "floor__", + "interior__" + ], + "revealPrefixes": [ + "roof__", + "wall_front__", + "wall_back__", + "wall_left__", + "wall_right__", + "wall_court__" + ], + "stub_by_face": true + }, + "door": { + "axisLocal": [ + 1, + 0, + 0 + ], + "clips": [ + "door_close", + "door_open" + ], + "closedInGlb": true, + "local_axis": "X", + "node": "door_slide", + "slide_axis_local": [ + 1, + 0, + 0 + ], + "slide_distance_m": 0.846 + }, + "door_cells": [ + [ + 3, + 0 + ], + [ + 4, + 0 + ] + ], + "exteriorEntry": { + "doorCells": [ + [ + 3, + 0 + ], + [ + 4, + 0 + ] + ], + "doorNode": "door_slide", + "facing": "south", + "frontAxis": "+Z" + }, + "floorHeightM": 0.02, + "floorTopY": 0.02, + "footprintCells": [ + 8, + 6 + ], + "footprint_max_m": [ + 7.6, + 5.7 + ], + "front": "+Z", + "interiorRegions": [ + { + "floorTopY": 0.02, + "id": "living", + "maxX": 5.7, + "maxZ": 2.85, + "minX": 0.0, + "minZ": 0.0 + }, + { + "floorTopY": 0.02, + "id": "study", + "maxX": 7.6, + "maxZ": 2.85, + "minX": 5.7, + "minZ": 0.0 + }, + { + "floorTopY": 0.02, + "id": "bedroom", + "maxX": 3.8, + "maxZ": 5.7, + "minX": 0.0, + "minZ": 2.85 + }, + { + "floorTopY": 0.02, + "id": "bath", + "maxX": 7.6, + "maxZ": 5.7, + "minX": 3.8, + "minZ": 2.85 + } + ], + "label": "Modular Starter Home", + "materials": 455, + "promised_clear_cells": {}, + "runtimeKey": "home_modular_starter", + "runtime_scale_at_8x6_cells": 1.0, + "schema": "successor.runtime-building-manifest.v1", + "service_anchor_cells": {}, + "source": { + "floorBoundsM": [ + [ + 0.0, + 0.0 + ], + [ + 7.6, + 5.7 + ] + ], + "furnishedPlacements": 30, + "placements": 261, + "plan": "home_starter", + "provenance": "home_modular_starter.provenance.json" + }, + "tri_count": 37838, + "units": "m" +} diff --git a/client-3d/public/assets/world-items/home_modular_wing.glb b/client-3d/public/assets/world-items/home_modular_wing.glb new file mode 100644 index 00000000..6e1cbb99 --- /dev/null +++ b/client-3d/public/assets/world-items/home_modular_wing.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3cfbd238dff8b7e9c858d5722447e05f14930e879a155b9ed5cf5f12225f29d1 +size 10147884 diff --git a/client-3d/public/assets/world-items/home_modular_wing.provenance.json b/client-3d/public/assets/world-items/home_modular_wing.provenance.json new file mode 100644 index 00000000..212a9fb2 --- /dev/null +++ b/client-3d/public/assets/world-items/home_modular_wing.provenance.json @@ -0,0 +1,743 @@ +{ + "artifacts": { + "collision": { + "bytes": 90062, + "path": "home_modular_wing_collision.json", + "sha256": "da1045f01a3ab4cedc071933f5c0921dfcb7996da540d888684df728532e13fb" + }, + "glb": { + "bytes": 9933580, + "path": "home_modular_wing.glb", + "sha256": "055da4042835e922b24a9c332d26af33389ed8c3dfb5c0bb784d802157837e3b" + }, + "manifest": { + "bytes": 3396, + "path": "home_modular_wing_manifest.json", + "sha256": "86341a155654d32a2dc2ae6a7aca7e16bd7b158aeed7224c033acdc4b6d4773f" + } + }, + "asset": { + "bytes": 9933580, + "path": "home_modular_wing.glb", + "sha256": "sha256:055da4042835e922b24a9c332d26af33389ed8c3dfb5c0bb784d802157837e3b" + }, + "asset_id": "home_modular_wing", + "contract": { + "collision": "home_modular_wing_collision.json", + "entryDoor": "door_slide", + "floorTopY": 0.02, + "footprintCells": [ + 12, + 10 + ], + "manifest": "home_modular_wing_manifest.json" + }, + "generator": { + "lod": 0, + "path": "src/package_runtime_buildings.py", + "sha256": "13be669e4cb44b83ee75f36402145d7f1b083b4e7f33ca62310f05c8a757105e" + }, + "modules": [ + { + "collision": { + "path": "build/collision/ceiling_1x1__plasterlined.collision.json", + "sha256": "934f4eeb7cef52c083635c42da3b564d205d389bfd682f14d05b0b25ae91b621" + }, + "glb": { + "bytes": 3432, + "path": "build/modules/ceiling_1x1__plasterlined_lod0.glb", + "sha256": "e2de1c575d6f48e78d715293665d2253ca30adbb985bfb02c35c0d31e862d523" + }, + "id": "ceiling_1x1__plasterlined", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/ceiling_cham_1x1__plasterlined.collision.json", + "sha256": "c4bcf98c97f41b74cca1067c19da7e75c8b7960515367df8d5aa57ff41c60e1c" + }, + "glb": { + "bytes": 3820, + "path": "build/modules/ceiling_cham_1x1__plasterlined_lod0.glb", + "sha256": "0216ac6533a8699bb6b3df5cb7fb5c842d8bde2dce08ddb129fedab8a29cc8d2" + }, + "id": "ceiling_cham_1x1__plasterlined", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/corner_inner__sintered.collision.json", + "sha256": "e65cd105ad2f1b07132a62832c5e0982732c7b416b714a85e2ddc20ccc212520" + }, + "glb": { + "bytes": 35512, + "path": "build/modules/corner_inner__sintered_lod0.glb", + "sha256": "e9b4a3a980eb203d79ab73e4925d10a54f6e5eca90ca03b37469344b6ceba380" + }, + "id": "corner_inner__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/door_inner_2c__panelclad.collision.json", + "sha256": "ee43ca0df0c4393bd91d03f2ca58d0339ca3cab8af06812e43374c43ceae28dc" + }, + "glb": { + "bytes": 236992, + "path": "build/modules/door_inner_2c__panelclad_lod0.glb", + "sha256": "c29e934e44e8c79f9d5949bdbfef82c0251e4d49469c1da17a15692265b84b64" + }, + "id": "door_inner_2c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/door_inner_2c__plasterlined.collision.json", + "sha256": "949b78b80ab6b37e375aa91aaf2d51f316fc4a6d29f8bcb9cedb7001746a29a9" + }, + "glb": { + "bytes": 211108, + "path": "build/modules/door_inner_2c__plasterlined_lod0.glb", + "sha256": "5b542ee993356c46576773b960b631cae768d9d5239c1c30f63b5b20cbec882f" + }, + "id": "door_inner_2c__plasterlined", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/door_slide_2c__sintered.collision.json", + "sha256": "d50dca109f9734c23284e9366cf15bac94e8bfd048e71fa62a99d9a51549c566" + }, + "glb": { + "bytes": 217504, + "path": "build/modules/door_slide_2c__sintered_lod0.glb", + "sha256": "963afa5b32546a7f46165a8b6161bc7067cd1b079ec72a060466c3b9dbaaeb00" + }, + "id": "door_slide_2c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/entry_apron_2c__sintered.collision.json", + "sha256": "97a361f393a672ff943b4206129acba62e0df4a3eded956b42c0415d6b54eabd" + }, + "glb": { + "bytes": 11884, + "path": "build/modules/entry_apron_2c__sintered_lod0.glb", + "sha256": "1e78b9130f2d5db6e5123df72f78677622c7d4ecf3177bf8672e1afbfd2cf1f4" + }, + "id": "entry_apron_2c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/entry_hood_2c__sintered.collision.json", + "sha256": "f77bf5cfa53c81e2684642601f43d70f0b4b0dc48953d1ed8b2efe7dfa455ff3" + }, + "glb": { + "bytes": 22528, + "path": "build/modules/entry_hood_2c__sintered_lod0.glb", + "sha256": "761243c447b51b2ccc81fbf00e7b131679620a439618273f340208754b5ee036" + }, + "id": "entry_hood_2c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/floor_1x1__panelclad.collision.json", + "sha256": "f26cd0760ab21d0258518c5c81fc4a06c98c5b6225d4957a01a566e5300cefe5" + }, + "glb": { + "bytes": 7564, + "path": "build/modules/floor_1x1__panelclad_lod0.glb", + "sha256": "b49ddeb21df5605aa2262f5a6afaac70cbe814d11a7b748a08cb0ce20d453c46" + }, + "id": "floor_1x1__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/floor_1x1__sintered.collision.json", + "sha256": "97a7ecffce98eefeed72040395a71f8fbb0e116f53b03bace269cf37b294beb5" + }, + "glb": { + "bytes": 4416, + "path": "build/modules/floor_1x1__sintered_lod0.glb", + "sha256": "1afa58178344e518db24fc98103a1e3546b77ea6a78364cd83501b8546c6e23c" + }, + "id": "floor_1x1__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/floor_cham_1x1__sintered.collision.json", + "sha256": "4a4429851419baaf95b8dbb914ca46ed18379267013838af328b1840c1e04c5e" + }, + "glb": { + "bytes": 5048, + "path": "build/modules/floor_cham_1x1__sintered_lod0.glb", + "sha256": "a1821d3f6e9d13954b580c3af8183feef14cbe05811d0bcb505dc3e50e41bd26" + }, + "id": "floor_cham_1x1__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/found_cham_1x1__sintered.collision.json", + "sha256": "1632ab20bbd50580ad4a629066df14b77594fc746338ebda13f42c52c8bb28fb" + }, + "glb": { + "bytes": 6312, + "path": "build/modules/found_cham_1x1__sintered_lod0.glb", + "sha256": "c0d835ebf5f6397e25b3b77531fa0b17f725fdc526987c1a07e6bd6cb3537a9d" + }, + "id": "found_cham_1x1__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/foundation_1x1__sintered.collision.json", + "sha256": "408b7b90b12a8bcd22eca02c36a5b4e37977e996e390773b153b155866b61a19" + }, + "glb": { + "bytes": 5424, + "path": "build/modules/foundation_1x1__sintered_lod0.glb", + "sha256": "a94a48157159322f5f50969ae6961953844b6e870ae6d8ec121e6125e8f93031" + }, + "id": "foundation_1x1__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_bed_2c__panelclad.collision.json", + "sha256": "a2a08484adb884fcedd1463f34461933d2ff13ae0d08546586cbd5d7dbb43574" + }, + "glb": { + "bytes": 88928, + "path": "build/modules/furn_bed_2c__panelclad_lod0.glb", + "sha256": "53afddbde833a2cb1470adec0bbc782e38c9abc4fb3ed4144389977309c33e46" + }, + "id": "furn_bed_2c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_bench_2c__panelclad.collision.json", + "sha256": "ead6e84004572dc8152971641c23d5fefade0b029e07a6f228fbe15757f6c85d" + }, + "glb": { + "bytes": 110980, + "path": "build/modules/furn_bench_2c__panelclad_lod0.glb", + "sha256": "018d5bc41ab6d8304a8466bcb0f42f91900007d84c01aa61193970bf3fa6c098" + }, + "id": "furn_bench_2c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_chair_1c__panelclad.collision.json", + "sha256": "eb6f1cdaea3f77943b05eca62939d038a49dbd4408440d6cd44571ffc0f8a025" + }, + "glb": { + "bytes": 52132, + "path": "build/modules/furn_chair_1c__panelclad_lod0.glb", + "sha256": "daaa2ea380975961928da634fd1285c29b48b55ebe7245aa60d7c8acc60cd6eb" + }, + "id": "furn_chair_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_chest_1c__panelclad.collision.json", + "sha256": "4e26dc2627b2c703360510341a0265d39dc7c01ca2f63a3241ef6ffa066d2de3" + }, + "glb": { + "bytes": 26692, + "path": "build/modules/furn_chest_1c__panelclad_lod0.glb", + "sha256": "2c4786561dd80c9c52d280880872c41f1074c5c2c3e1fe70662ce63c8512bd6a" + }, + "id": "furn_chest_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_corner_bench__panelclad.collision.json", + "sha256": "d1f54e071452e64e1043b19a906ff12638b3bc6be72e08284eb0f027f07fe78d" + }, + "glb": { + "bytes": 28420, + "path": "build/modules/furn_corner_bench__panelclad_lod0.glb", + "sha256": "279faf446c853ae734443c0b54d6a826a83b7532589247be32257048620109f4" + }, + "id": "furn_corner_bench__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_corner_desk__panelclad.collision.json", + "sha256": "2177bd85fe13c34f04566ea929b52554c45e5ae42c73ac9bf511cbd3445fc9b4" + }, + "glb": { + "bytes": 36508, + "path": "build/modules/furn_corner_desk__panelclad_lod0.glb", + "sha256": "17070b9a196ea09fc93a5a24c4b4c7fcfd34144d5c3e6245079c9a2d93c6271d" + }, + "id": "furn_corner_desk__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_corner_hearth__panelclad.collision.json", + "sha256": "cb530c19cd574dc661fc87908c6d8b09be07e2fd30f2234f35d8b3c0cc0d83b2" + }, + "glb": { + "bytes": 54244, + "path": "build/modules/furn_corner_hearth__panelclad_lod0.glb", + "sha256": "d71acb059f615689232c700c67785f6935b20875be569e27fb59a40058910dac" + }, + "id": "furn_corner_hearth__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_corner_shelf__panelclad.collision.json", + "sha256": "f33a4481c46711873cedd16ca40ac2703e37b70a376e0258c547f1f725dae78f" + }, + "glb": { + "bytes": 43276, + "path": "build/modules/furn_corner_shelf__panelclad_lod0.glb", + "sha256": "c1ea8c47063e8ad717b9c4836e6cd199b5c25d616d461466001449590ed7ac51" + }, + "id": "furn_corner_shelf__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_corner_store__panelclad.collision.json", + "sha256": "aa45949714aeb8b1786426c581b684a2b89b0e1ddee8bfe1c064088023f59a3c" + }, + "glb": { + "bytes": 36920, + "path": "build/modules/furn_corner_store__panelclad_lod0.glb", + "sha256": "cdd89639fb4a1700189fccf10175db50551e669677bea182907ffe2d5ece00b8" + }, + "id": "furn_corner_store__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_crate_stack__panelclad.collision.json", + "sha256": "79d36c1c70727abfb2d5e3369ca2ddf6c51fa548499e07c668ef46641578725d" + }, + "glb": { + "bytes": 46740, + "path": "build/modules/furn_crate_stack__panelclad_lod0.glb", + "sha256": "f3293455ee6b9bc70f5651bf4a3622f3f498ae1cf8e345275785ba72e569c2d1" + }, + "id": "furn_crate_stack__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_desk_2c__panelclad.collision.json", + "sha256": "62f2d87359ad4d9bb02bfe456ebe1d4be8d1edfe6aace0b3cef6ca49664319d0" + }, + "glb": { + "bytes": 63788, + "path": "build/modules/furn_desk_2c__panelclad_lod0.glb", + "sha256": "25b77348d25816f719f8423c01eeefa32bd12ef979de894812c71279bd75b0e2" + }, + "id": "furn_desk_2c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_lamp_wall__panelclad.collision.json", + "sha256": "6cfecc94c25626a77cd0c015a9085f4b96639bb8d8172ced40951824273cf712" + }, + "glb": { + "bytes": 37884, + "path": "build/modules/furn_lamp_wall__panelclad_lod0.glb", + "sha256": "32df49e6cd8b1b29c9cc70e779f8354c46a248d5044a5e993e786da846f790b5" + }, + "id": "furn_lamp_wall__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_nightstand__panelclad.collision.json", + "sha256": "e54a64a0f8124cfa5002ba23e8286f5f3fbc917b418e566474a82a2efa114083" + }, + "glb": { + "bytes": 36568, + "path": "build/modules/furn_nightstand__panelclad_lod0.glb", + "sha256": "6081b7c35d47c22c69dd0675f1102d4f7582eeddf3e8216e94f6fb53b444f547" + }, + "id": "furn_nightstand__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_pendant_1c__panelclad.collision.json", + "sha256": "900b502c0bec5e00559e1165810886242d47e3e072ab8fc71613158119b225b9" + }, + "glb": { + "bytes": 19988, + "path": "build/modules/furn_pendant_1c__panelclad_lod0.glb", + "sha256": "30739ce199caf2fba5c89d633e6b7cfd63d32716bf9725874cf13cd45b5987ee" + }, + "id": "furn_pendant_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_planter_1c__panelclad.collision.json", + "sha256": "a9e6f4d565435cf0ccd792e77ab8c2f79ff5bc9f1534838fdfc133a1da8ee123" + }, + "glb": { + "bytes": 75924, + "path": "build/modules/furn_planter_1c__panelclad_lod0.glb", + "sha256": "f28bee45812cb85ff8b65f99ef9191f121cbe591bb53d805e8714057166d67e3" + }, + "id": "furn_planter_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_shelf_1c__panelclad.collision.json", + "sha256": "e4176b53db397cb627b0d395cebe6a9dba015c9d58d951bd5f9cee3d1abde461" + }, + "glb": { + "bytes": 22204, + "path": "build/modules/furn_shelf_1c__panelclad_lod0.glb", + "sha256": "af9ac4c977ecf05e7498e5ee3139140bfcebb4eddfa923671269be2e5713c3d1" + }, + "id": "furn_shelf_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_sink_1c__panelclad.collision.json", + "sha256": "60c38008176eb11ce38f3288556e5d02a9955224466a48a5d4622434b43789dc" + }, + "glb": { + "bytes": 80468, + "path": "build/modules/furn_sink_1c__panelclad_lod0.glb", + "sha256": "145f539597efcb375ffd574d7cc6a3e8c721363d61d404f3d36ec1cfcc963bf4" + }, + "id": "furn_sink_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_stove_1c__panelclad.collision.json", + "sha256": "6383594f8bb1b657a8c67b96463d9648300dbcd88756d37adefbd6bb20d278f3" + }, + "glb": { + "bytes": 83028, + "path": "build/modules/furn_stove_1c__panelclad_lod0.glb", + "sha256": "286452b4c3b0318acf963739dc363b0a080c59ec7a65d023469fe9fef90dfecf" + }, + "id": "furn_stove_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_table_1c__panelclad.collision.json", + "sha256": "0c98020d398dd3019488bde502cbd44f4ee3f2b0bfdd6518edd271e4b9099ff6" + }, + "glb": { + "bytes": 87728, + "path": "build/modules/furn_table_1c__panelclad_lod0.glb", + "sha256": "eef631cb8f1080bd97ffc512dfc0259aa12278ff846bca71f51ee70b4a9e7ecf" + }, + "id": "furn_table_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/furn_wall_cab_1c__panelclad.collision.json", + "sha256": "021f42dbad8f1c69f3ef84b24407fa05f5b5e7aca5570b0d83beea04b05877c2" + }, + "glb": { + "bytes": 30368, + "path": "build/modules/furn_wall_cab_1c__panelclad_lod0.glb", + "sha256": "c002028786a2f82e7e7fb89028c27dea243a427e09b9d013afce4f13f1108603" + }, + "id": "furn_wall_cab_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/roof_deck_1x1__sintered.collision.json", + "sha256": "378a0eb82196f9c601bf8a208d56a3985dd9721cec2b3bf30a5ecff07e08b14c" + }, + "glb": { + "bytes": 7480, + "path": "build/modules/roof_deck_1x1__sintered_lod0.glb", + "sha256": "c52c7c2396f96a500ce71552b9bffb8f6e6d0e08d4b37a9c6b1bdf864198244d" + }, + "id": "roof_deck_1x1__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/roof_light_flat_1x1__sintered.collision.json", + "sha256": "ec25c1276ce93da0548d69ddd2b59a7569faadf22da3e9ef50491129dc1ca5be" + }, + "glb": { + "bytes": 31300, + "path": "build/modules/roof_light_flat_1x1__sintered_lod0.glb", + "sha256": "a4402d54f0c4210ff56bd199e585b8d0fa91d8aec99399e82fd4ecc318cdd771" + }, + "id": "roof_light_flat_1x1__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/roof_skirt_1c__sintered.collision.json", + "sha256": "88b16f5b877401e738b221f3406d7ea9d962d61e8fcd959698c3e23d76927d88" + }, + "glb": { + "bytes": 11132, + "path": "build/modules/roof_skirt_1c__sintered_lod0.glb", + "sha256": "ab54cdde48a0d3453acf612414f1eef38970ce54be4ff6ce9fd59cdba9d2b06a" + }, + "id": "roof_skirt_1c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/roof_skirt_cham__sintered.collision.json", + "sha256": "a5b1739d599c2b32eaab19419b5ac88853b27dcd778a089b4f0dfe29ea43562b" + }, + "glb": { + "bytes": 17676, + "path": "build/modules/roof_skirt_cham__sintered_lod0.glb", + "sha256": "048272d57c080f42fc373745c920a213e43821a43d5c0a5754ac239ace7b87f9" + }, + "id": "roof_skirt_cham__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/wall_1c__panelclad.collision.json", + "sha256": "8568e361acc8909ea07c8fda190f550bee2f20dcf81855b098cda40d13f8eea4" + }, + "glb": { + "bytes": 37044, + "path": "build/modules/wall_1c__panelclad_lod0.glb", + "sha256": "8afeaa32b2eacec3691659a33247ad4c9f1fe97b32c100f3b38ebee5d7703170" + }, + "id": "wall_1c__panelclad", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/wall_1c__plasterlined.collision.json", + "sha256": "0c2d61b50b2e946dc5255dd7501f77c1d8e74d9bfce2bc80968ff02bb5fc2895" + }, + "glb": { + "bytes": 30856, + "path": "build/modules/wall_1c__plasterlined_lod0.glb", + "sha256": "74410e082f5daeeb6287cc82612f16222832949711127df55194344a3a0ff769" + }, + "id": "wall_1c__plasterlined", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/wall_1c__sintered.collision.json", + "sha256": "4d4c403a10a18d2f86d2130057381419352cb4e72e3b55f42ad9859ffe298154" + }, + "glb": { + "bytes": 30872, + "path": "build/modules/wall_1c__sintered_lod0.glb", + "sha256": "8cc27185001276e324983f8951a02d082f46cc8ef49860f9e2171021c0410557" + }, + "id": "wall_1c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/wall_cham_1c__sintered.collision.json", + "sha256": "7cdccafeb63a1c45bb0737fee03b0f12d7ce6d6c39b7afbee681289f135759bb" + }, + "glb": { + "bytes": 73472, + "path": "build/modules/wall_cham_1c__sintered_lod0.glb", + "sha256": "db0069922b7bf3df76e5f0af1dabe1abde7df3e47c581ce74585360147ed260f" + }, + "id": "wall_cham_1c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/wall_tee__sintered.collision.json", + "sha256": "fca493df6c485d954658e760ab2582b816356a145343f1d8b91a8528933325f8" + }, + "glb": { + "bytes": 29360, + "path": "build/modules/wall_tee__sintered_lod0.glb", + "sha256": "4e1b83fcf02db2a585378efb4fcc1219795d14d2adc5acadb6c5ae24368fb851" + }, + "id": "wall_tee__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/window_cler_1c__sintered.collision.json", + "sha256": "4831fba90fc06359e7af9818b7eb9d8ecf085f9aada2b5046f2359277957cb84" + }, + "glb": { + "bytes": 51592, + "path": "build/modules/window_cler_1c__sintered_lod0.glb", + "sha256": "00fd72248187567a4329821e537112eef071c11a401aa740fa28cb4669c299d0" + }, + "id": "window_cler_1c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/window_slot_1c__sintered.collision.json", + "sha256": "26eae56a750e72a8d2819bade6efed096c9e69b40848e112130e7f95bef4da2d" + }, + "glb": { + "bytes": 103048, + "path": "build/modules/window_slot_1c__sintered_lod0.glb", + "sha256": "a96856f26a86bcb2fd68c2fd862e02685076845ab2f7cbac1a62de6574e3b5b5" + }, + "id": "window_slot_1c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/collision/window_tall_2c__sintered.collision.json", + "sha256": "74fdbba411eb8d543e47f83731a29781d4ef8cfa97d9e7611742f65dd334bafd" + }, + "glb": { + "bytes": 111208, + "path": "build/modules/window_tall_2c__sintered_lod0.glb", + "sha256": "97c17b52f2cc0a5db6858ab2d085dace6f9751914281612c208e1b7f03c498d4" + }, + "id": "window_tall_2c__sintered", + "lane": "core" + }, + { + "collision": { + "path": "build/expansion_furniture/collision/xs_san_basin_1c__panelclad.collision.json", + "sha256": "a5130d88eae351535a5414cef043a204efc38cc428345131149d96de69d4aea4" + }, + "glb": { + "bytes": 137756, + "path": "build/expansion_furniture/modules/xs_san_basin_1c__panelclad_lod0.glb", + "sha256": "7df47d292086142ff1574aa377b6b28e3f345fd3fc6dd9e62e02b0d2779c0ee4" + }, + "id": "xs_san_basin_1c__panelclad", + "lane": "expansion_furniture" + }, + { + "collision": { + "path": "build/expansion_furniture/collision/xs_san_drain_1c__panelclad.collision.json", + "sha256": "45c79f2757c767fe5da8e7ccf500cb9396205d38f429fca8df0e330a159dd8b8" + }, + "glb": { + "bytes": 71668, + "path": "build/expansion_furniture/modules/xs_san_drain_1c__panelclad_lod0.glb", + "sha256": "019c7e40030583d120174e9d7fcb52f3b9625a1a9b7d127f67b2a21fedb1c444" + }, + "id": "xs_san_drain_1c__panelclad", + "lane": "expansion_furniture" + }, + { + "collision": { + "path": "build/expansion_furniture/collision/xs_san_mirrorcab_1c__panelclad.collision.json", + "sha256": "7bc5220b55994e50ad3e30ededc0caa129a73ed5fd677f0ab5848ff3e1376b7c" + }, + "glb": { + "bytes": 87640, + "path": "build/expansion_furniture/modules/xs_san_mirrorcab_1c__panelclad_lod0.glb", + "sha256": "d274a541dc499ea364d2fee9485ef4fab954220c7462bf6f4b4609f297ca3c3f" + }, + "id": "xs_san_mirrorcab_1c__panelclad", + "lane": "expansion_furniture" + }, + { + "collision": { + "path": "build/expansion_furniture/collision/xs_san_reclaimer_1c__panelclad.collision.json", + "sha256": "bcc9857f9bb6427b69146ca311ded29445666985e08105d85fca399315c7ecc9" + }, + "glb": { + "bytes": 141768, + "path": "build/expansion_furniture/modules/xs_san_reclaimer_1c__panelclad_lod0.glb", + "sha256": "ece43fd2505a07e7ff88354cc7855bc2da2538cf519ffb13e03de8591f1e7501" + }, + "id": "xs_san_reclaimer_1c__panelclad", + "lane": "expansion_furniture" + }, + { + "collision": { + "path": "build/expansion_furniture/collision/xs_san_stall_1c__panelclad.collision.json", + "sha256": "fd23f99dd2d9b1c668170014e73d5c50635556899dc0abf031f62b75d6cb0bf9" + }, + "glb": { + "bytes": 139892, + "path": "build/expansion_furniture/modules/xs_san_stall_1c__panelclad_lod0.glb", + "sha256": "6a61c184aef3a1bbe669371cb5593008bf8aa072a44ed3c527c66dc6e1d2e3a3" + }, + "id": "xs_san_stall_1c__panelclad", + "lane": "expansion_furniture" + }, + { + "collision": { + "path": "build/expansion_furniture/collision/xs_san_towelbar_1c__panelclad.collision.json", + "sha256": "873c6f47b5e3df82ba2e3b81f272461a19f19279539982ab868c9cae6fdcf525" + }, + "glb": { + "bytes": 76728, + "path": "build/expansion_furniture/modules/xs_san_towelbar_1c__panelclad_lod0.glb", + "sha256": "b2a7bab9d9a6fd2a93bf238aef9a80fed89bf43478ae956a12e986acf38fe506" + }, + "id": "xs_san_towelbar_1c__panelclad", + "lane": "expansion_furniture" + } + ], + "pack": { + "colour_accessors_quantized": 730, + "images_embedded": 0, + "unique_image_bytes": 0 + }, + "runtime_key": "home_modular_wing", + "schema": "successor.runtime-building-provenance.v1", + "source_plan": "home_wing", + "sources": [ + { + "path": "src/package_runtime_buildings.py", + "sha256": "13be669e4cb44b83ee75f36402145d7f1b083b4e7f33ca62310f05c8a757105e" + }, + { + "path": "src/assemble_home.py", + "sha256": "236cc54de80f2e1cd0ab572fa5fdc0731e76de10617062fe8d6c227e50ab8692" + }, + { + "path": "src/home_plan.py", + "sha256": "3f0bc706756a76fa5182b1b8f1e008d71fba087c25e6faeda66c1a131a02544b" + }, + { + "path": "src/kit_grid.py", + "sha256": "b1cda4f166174d37edf25d64dd35d9fc108571676a8a1b5a417e29b0aa61fb19" + }, + { + "path": "src/kit_spec.py", + "sha256": "04456196e9e3cbe5554f576fd61582ec03bee6e02db9c7212a870f267da53fae" + }, + { + "path": "src/pack_runtime_glb.py", + "sha256": "694cfff9a23674c4be7846005f8b1b090ff5667cb554c632f2b5339abf232f6d" + }, + { + "path": "build/buildkit.manifest.json", + "sha256": "3dad4470f218c43c7c3812c2d64a4a0cb4b17f56299a9fc0a3ed479fe406e59d" + } + ] +} diff --git a/client-3d/public/assets/world-items/home_modular_wing_collision.json b/client-3d/public/assets/world-items/home_modular_wing_collision.json new file mode 100644 index 00000000..fd89684c --- /dev/null +++ b/client-3d/public/assets/world-items/home_modular_wing_collision.json @@ -0,0 +1,6280 @@ +{ + "boxes": [ + { + "center": [ + 1.425, + -0.16, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 1.425, + 0.01, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 1.425, + -0.16, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 1.425, + 0.01, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 1.425, + -0.16, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 1.425, + 0.01, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 2.375, + -0.16, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 2.375, + 0.01, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 2.375, + -0.16, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 2.375, + 0.01, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 2.375, + -0.16, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 2.375, + 0.01, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 3.325, + -0.16, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 3.325, + 0.01, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 3.325, + -0.16, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 3.325, + 0.01, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 3.325, + -0.16, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 3.325, + 0.01, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 4.275, + -0.16, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 4.275, + 0.01, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 5.225, + -0.16, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 5.225, + 0.01, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 6.175, + -0.16, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 6.175, + 0.01, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 7.125, + -0.16, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 7.125, + 0.01, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 8.075, + -0.16, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 8.075, + 0.01, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 8.075, + -0.16, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 8.075, + 0.01, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 8.075, + -0.16, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 8.075, + 0.01, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 8.075, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 8.075, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 8.075, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 8.075, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 8.075, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 8.075, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 8.075, + -0.16, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 8.075, + 0.01, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 9.025, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 9.025, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 9.025, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 9.025, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 9.025, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 9.025, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 9.025, + -0.16, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 9.025, + 0.01, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 9.975, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 9.975, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 9.975, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 9.975, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 9.975, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 9.975, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 9.975, + -0.16, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 9.975, + 0.01, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 10.925, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 10.925, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 10.925, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 10.925, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 10.925, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 10.925, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 10.925, + -0.16, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 10.925, + 0.01, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 11.875, + -0.16, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 11.875, + 0.01, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 11.875, + -0.16, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 11.875, + 0.01, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 11.875, + -0.16, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 11.875, + 0.01, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 11.875, + -0.16, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.32, + 0.95 + ] + }, + { + "center": [ + 11.875, + 0.01, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.02, + 0.95 + ] + }, + { + "center": [ + 1.6625, + 1.5, + 6.65 + ], + "kind": "structure", + "size": [ + 0.475, + 3.0, + 0.16 + ] + }, + { + "center": [ + 0.95, + 1.5, + 7.3625 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.475 + ] + }, + { + "center": [ + 1.1875, + 1.5, + 6.8875 + ], + "kind": "structure", + "size": [ + 0.635, + 3.0, + 0.635 + ] + }, + { + "center": [ + 0.95, + 1.5, + 8.7875 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.475 + ] + }, + { + "center": [ + 1.6625, + 1.5, + 9.5 + ], + "kind": "structure", + "size": [ + 0.475, + 3.0, + 0.16 + ] + }, + { + "center": [ + 1.1875, + 1.5, + 9.2625 + ], + "kind": "structure", + "size": [ + 0.635, + 3.0, + 0.635 + ] + }, + { + "center": [ + 4.5125, + 1.5, + 0.0 + ], + "kind": "structure", + "size": [ + 0.475, + 3.0, + 0.16 + ] + }, + { + "center": [ + 3.8, + 1.5, + 0.7125 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.475 + ] + }, + { + "center": [ + 4.0375, + 1.5, + 0.2375 + ], + "kind": "structure", + "size": [ + 0.635, + 3.0, + 0.635 + ] + }, + { + "center": [ + 6.8875, + 1.5, + 9.5 + ], + "kind": "structure", + "size": [ + 0.475, + 3.0, + 0.16 + ] + }, + { + "center": [ + 7.6, + 1.5, + 8.7875 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.475 + ] + }, + { + "center": [ + 7.3625, + 1.5, + 9.2625 + ], + "kind": "structure", + "size": [ + 0.635, + 3.0, + 0.635 + ] + }, + { + "center": [ + 8.55, + 1.5, + 0.7125 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.475 + ] + }, + { + "center": [ + 7.8375, + 1.5, + 0.0 + ], + "kind": "structure", + "size": [ + 0.475, + 3.0, + 0.16 + ] + }, + { + "center": [ + 8.3125, + 1.5, + 0.2375 + ], + "kind": "structure", + "size": [ + 0.635, + 3.0, + 0.635 + ] + }, + { + "center": [ + 12.35, + 1.5, + 3.5625 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.475 + ] + }, + { + "center": [ + 11.6375, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.475, + 3.0, + 0.16 + ] + }, + { + "center": [ + 12.1125, + 1.5, + 3.0875 + ], + "kind": "structure", + "size": [ + 0.635, + 3.0, + 0.635 + ] + }, + { + "center": [ + 11.6375, + 1.5, + 6.65 + ], + "kind": "structure", + "size": [ + 0.475, + 3.0, + 0.16 + ] + }, + { + "center": [ + 12.35, + 1.5, + 5.9375 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.475 + ] + }, + { + "center": [ + 12.1125, + 1.5, + 6.4125 + ], + "kind": "structure", + "size": [ + 0.635, + 3.0, + 0.635 + ] + }, + { + "center": [ + 3.8, + 1.5, + 1.425 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 3.8, + 1.5, + 2.375 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 3.8, + 1.5, + 3.325 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 3.8, + 1.5, + 6.175 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 3.8, + 1.5, + 3.865 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.13 + ] + }, + { + "center": [ + 3.8, + 1.5, + 5.225 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 3.8, + 2.53, + 4.34 + ], + "kind": "structure", + "size": [ + 0.16, + 0.94, + 0.82 + ] + }, + { + "center": [ + 3.8, + 1.044, + 4.354 + ], + "kind": "door", + "size": [ + 0.05, + 1.992, + 0.9 + ] + }, + { + "center": [ + 3.03, + 2.58, + 4.75 + ], + "kind": "structure", + "size": [ + 1.46, + 0.32, + 1.9 + ] + }, + { + "center": [ + 3.11, + -0.02482, + 4.75 + ], + "kind": "structure", + "size": [ + 1.22, + 0.075, + 1.9 + ] + }, + { + "center": [ + 5.225, + 1.5, + 0.0 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 6.175, + 1.5, + 0.0 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 7.125, + 1.5, + 0.0 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 8.55, + 1.5, + 1.425 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 8.55, + 1.5, + 2.375 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 9.025, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 9.975, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 10.925, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 12.35, + 1.5, + 4.275 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 12.35, + 1.5, + 5.225 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 8.075, + 1.5, + 6.65 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 10.925, + 1.5, + 6.65 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 9.5, + 1.5, + 6.65 + ], + "kind": "structure", + "size": [ + 1.9, + 3.0, + 0.16 + ] + }, + { + "center": [ + 7.6, + 1.5, + 7.125 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 7.6, + 1.5, + 8.075 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 2.375, + 1.5, + 9.5 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 5.225, + 1.5, + 9.5 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 6.175, + 1.5, + 9.5 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 3.8, + 1.5, + 9.5 + ], + "kind": "structure", + "size": [ + 1.9, + 3.0, + 0.16 + ] + }, + { + "center": [ + 0.95, + 1.5, + 8.075 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 2.375, + 1.5, + 6.65 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 3.325, + 1.5, + 6.65 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 3.8, + 1.5, + 6.65 + ], + "kind": "structure", + "size": [ + 0.35, + 3.0, + 0.35 + ] + }, + { + "center": [ + 7.6, + 1.5, + 6.65 + ], + "kind": "structure", + "size": [ + 0.35, + 3.0, + 0.35 + ] + }, + { + "center": [ + 8.55, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.35, + 3.0, + 0.35 + ] + }, + { + "center": [ + 4.275, + 1.5, + 6.65 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 7.125, + 1.5, + 6.65 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 4.835, + 1.5, + 6.65 + ], + "kind": "structure", + "size": [ + 0.17, + 3.0, + 0.16 + ] + }, + { + "center": [ + 6.175, + 1.5, + 6.65 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 5.31, + 2.53, + 6.65 + ], + "kind": "structure", + "size": [ + 0.78, + 0.94, + 0.16 + ] + }, + { + "center": [ + 5.324, + 1.044, + 6.65 + ], + "kind": "door", + "size": [ + 0.86, + 1.992, + 0.05 + ] + }, + { + "center": [ + 7.6, + 1.5, + 3.325 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 7.6, + 1.5, + 6.175 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 7.6, + 1.5, + 5.615 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.17 + ] + }, + { + "center": [ + 7.6, + 1.5, + 4.275 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 7.6, + 2.53, + 5.14 + ], + "kind": "structure", + "size": [ + 0.16, + 0.94, + 0.78 + ] + }, + { + "center": [ + 7.6, + 1.044, + 5.126 + ], + "kind": "door", + "size": [ + 0.05, + 1.992, + 0.86 + ] + }, + { + "center": [ + 7.6, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.296, + 3.0, + 0.296 + ] + }, + { + "center": [ + 4.275, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 7.125, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 4.835, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.17, + 3.0, + 0.16 + ] + }, + { + "center": [ + 6.175, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 5.31, + 2.53, + 2.85 + ], + "kind": "structure", + "size": [ + 0.78, + 0.94, + 0.16 + ] + }, + { + "center": [ + 5.324, + 1.044, + 2.85 + ], + "kind": "door", + "size": [ + 0.86, + 1.992, + 0.05 + ] + }, + { + "center": [ + 3.8, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.296, + 3.0, + 0.296 + ] + }, + { + "center": [ + 8.075, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.95, + 3.0, + 0.16 + ] + }, + { + "center": [ + 9.5, + 1.5, + 3.325 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 9.5, + 1.5, + 6.175 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 9.5, + 1.5, + 5.615 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.17 + ] + }, + { + "center": [ + 9.5, + 1.5, + 4.275 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 9.5, + 2.53, + 5.14 + ], + "kind": "structure", + "size": [ + 0.16, + 0.94, + 0.78 + ] + }, + { + "center": [ + 9.5, + 1.044, + 5.126 + ], + "kind": "door", + "size": [ + 0.05, + 1.992, + 0.86 + ] + }, + { + "center": [ + 9.5, + 1.5, + 2.85 + ], + "kind": "structure", + "size": [ + 0.296, + 3.0, + 0.296 + ] + }, + { + "center": [ + 9.5, + 1.5, + 6.65 + ], + "kind": "structure", + "size": [ + 0.296, + 3.0, + 0.296 + ] + }, + { + "center": [ + 5.7, + 1.5, + 8.075 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 5.7, + 1.5, + 9.025 + ], + "kind": "structure", + "size": [ + 0.16, + 3.0, + 0.95 + ] + }, + { + "center": [ + 5.7, + 1.5, + 9.5 + ], + "kind": "structure", + "size": [ + 0.296, + 3.0, + 0.296 + ] + }, + { + "center": [ + 1.515, + 0.5, + 8.935 + ], + "kind": "structure", + "size": [ + 0.53475, + 0.96, + 0.53475 + ] + }, + { + "center": [ + 1.22, + 1.635, + 9.23 + ], + "kind": "structure", + "size": [ + 0.1955, + 1.31, + 0.1955 + ] + }, + { + "center": [ + 1.515, + 0.225, + 7.215 + ], + "kind": "structure", + "size": [ + 0.53475, + 0.41, + 0.53475 + ] + }, + { + "center": [ + 3.325, + 0.38, + 8.075 + ], + "kind": "structure", + "size": [ + 0.78, + 0.72, + 0.66 + ] + }, + { + "center": [ + 2.375, + 0.435, + 8.075 + ], + "kind": "structure", + "size": [ + 0.5, + 0.83, + 0.43 + ] + }, + { + "center": [ + 4.275, + 0.435, + 8.075 + ], + "kind": "structure", + "size": [ + 0.5, + 0.83, + 0.43 + ] + }, + { + "center": [ + 3.325, + 1.905, + 8.075 + ], + "kind": "structure", + "size": [ + 0.46, + 0.16, + 0.46 + ] + }, + { + "center": [ + 3.325, + 0.225, + 6.65 + ], + "kind": "structure", + "size": [ + 1.8, + 0.41, + 0.44 + ] + }, + { + "center": [ + 5.225, + 0.474, + 9.5 + ], + "kind": "structure", + "size": [ + 0.8, + 0.908, + 0.42 + ] + }, + { + "center": [ + 4.275, + 1.4, + 6.65 + ], + "kind": "structure", + "size": [ + 0.8, + 0.5, + 0.28 + ] + }, + { + "center": [ + 5.225, + 0.24, + 8.075 + ], + "kind": "structure", + "size": [ + 0.632, + 0.44, + 0.468 + ] + }, + { + "center": [ + 2.375, + 1.875, + 9.381 + ], + "kind": "structure", + "size": [ + 0.194, + 0.514, + 0.238 + ] + }, + { + "center": [ + 7.035, + 0.95, + 8.935 + ], + "kind": "structure", + "size": [ + 0.53475, + 1.86, + 0.53475 + ] + }, + { + "center": [ + 7.6, + 0.46, + 7.125 + ], + "kind": "structure", + "size": [ + 0.6, + 0.88, + 0.9 + ] + }, + { + "center": [ + 7.6, + 0.46, + 8.075 + ], + "kind": "structure", + "size": [ + 0.6, + 0.88, + 0.9 + ] + }, + { + "center": [ + 7.6, + 1.38, + 8.325 + ], + "kind": "structure", + "size": [ + 0.6, + 0.96, + 0.37 + ] + }, + { + "center": [ + 5.7, + 1.69, + 8.075 + ], + "kind": "structure", + "size": [ + 0.32, + 0.54, + 0.9 + ] + }, + { + "center": [ + 5.819, + 1.875, + 9.025 + ], + "kind": "structure", + "size": [ + 0.238, + 0.514, + 0.194 + ] + }, + { + "center": [ + 9.025, + 0.38, + 6.65 + ], + "kind": "structure", + "size": [ + 1.72, + 0.72, + 0.64 + ] + }, + { + "center": [ + 9.025, + 0.435, + 5.225 + ], + "kind": "structure", + "size": [ + 0.43, + 0.83, + 0.5 + ] + }, + { + "center": [ + 8.55, + 0.459, + 3.325 + ], + "kind": "structure", + "size": [ + 0.56, + 0.878, + 0.64 + ] + }, + { + "center": [ + 9.025, + 1.4, + 2.85 + ], + "kind": "structure", + "size": [ + 0.8, + 0.5, + 0.28 + ] + }, + { + "center": [ + 7.719, + 1.875, + 3.325 + ], + "kind": "structure", + "size": [ + 0.238, + 0.514, + 0.194 + ] + }, + { + "center": [ + 11.785, + 0.842, + 3.415 + ], + "kind": "structure", + "size": [ + 0.53475, + 1.644, + 0.53475 + ] + }, + { + "center": [ + 11.785, + 0.38, + 6.085 + ], + "kind": "structure", + "size": [ + 0.53475, + 0.72, + 0.53475 + ] + }, + { + "center": [ + 9.975, + 0.2, + 2.85 + ], + "kind": "structure", + "size": [ + 1.8, + 0.36, + 0.88 + ] + }, + { + "center": [ + 9.5, + 0.29, + 3.325 + ], + "kind": "structure", + "size": [ + 0.36, + 0.54, + 0.37 + ] + }, + { + "center": [ + 10.925, + 1.875, + 6.531 + ], + "kind": "structure", + "size": [ + 0.194, + 0.514, + 0.238 + ] + }, + { + "center": [ + 4.365, + 0.842, + 0.565 + ], + "kind": "structure", + "size": [ + 0.53475, + 1.644, + 0.53475 + ] + }, + { + "center": [ + 7.985, + 0.225, + 0.565 + ], + "kind": "structure", + "size": [ + 0.53475, + 0.41, + 0.53475 + ] + }, + { + "center": [ + 6.175, + 0.459, + 0.0 + ], + "kind": "structure", + "size": [ + 0.64, + 0.878, + 0.56 + ] + }, + { + "center": [ + 7.125, + 0.46, + 2.62 + ], + "kind": "structure", + "size": [ + 0.68, + 0.88, + 0.46 + ] + }, + { + "center": [ + 7.125, + 1.665, + 2.691 + ], + "kind": "structure", + "size": [ + 0.88, + 0.53, + 0.13 + ] + }, + { + "center": [ + 7.125, + 0.249, + 0.27 + ], + "kind": "structure", + "size": [ + 0.44, + 0.458, + 0.48 + ] + }, + { + "center": [ + 7.125, + 0.77, + 0.085 + ], + "kind": "structure", + "size": [ + 0.36, + 0.42, + 0.17 + ] + }, + { + "center": [ + 8.12, + 0.07, + 2.375 + ], + "kind": "structure", + "size": [ + 0.86, + 0.1, + 0.88 + ] + }, + { + "center": [ + 8.526, + 1.09, + 2.375 + ], + "kind": "structure", + "size": [ + 0.048, + 1.94, + 0.88 + ] + }, + { + "center": [ + 8.285, + 1.09, + 1.959 + ], + "kind": "structure", + "size": [ + 0.53, + 1.94, + 0.048 + ] + }, + { + "center": [ + 8.285, + 1.09, + 2.791 + ], + "kind": "structure", + "size": [ + 0.53, + 1.94, + 0.048 + ] + }, + { + "center": [ + 4.275, + 1.25, + 2.718 + ], + "kind": "structure", + "size": [ + 0.75, + 0.244, + 0.104 + ] + }, + { + "center": [ + 6.175, + 0.059, + 1.425 + ], + "kind": "structure", + "size": [ + 0.6, + 0.078, + 0.28 + ] + }, + { + "center": [ + 5.225, + 1.875, + 0.119 + ], + "kind": "structure", + "size": [ + 0.194, + 0.514, + 0.238 + ] + }, + { + "center": [ + 4.275, + 0.24, + 6.175 + ], + "kind": "structure", + "size": [ + 0.632, + 0.44, + 0.468 + ] + }, + { + "center": [ + 7.125, + 1.905, + 3.325 + ], + "kind": "structure", + "size": [ + 0.46, + 0.16, + 0.46 + ] + }, + { + "center": [ + 4.275, + 1.4, + 2.85 + ], + "kind": "structure", + "size": [ + 0.8, + 0.5, + 0.28 + ] + }, + { + "center": [ + 7.125, + 1.875, + 6.531 + ], + "kind": "structure", + "size": [ + 0.194, + 0.514, + 0.238 + ] + }, + { + "center": [ + 1.425, + 2.57, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 1.425, + 2.57, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 1.425, + 2.57, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 2.375, + 2.57, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 2.375, + 2.57, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 2.375, + 2.57, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 3.325, + 2.57, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 3.325, + 2.57, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 3.325, + 2.57, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 4.275, + 2.57, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 5.225, + 2.57, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 5.225, + 2.57, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 5.225, + 2.57, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 5.225, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 5.225, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 5.225, + 2.57, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 5.225, + 2.57, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 5.225, + 2.57, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 5.225, + 2.57, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 6.175, + 2.57, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 7.125, + 2.57, + 9.025 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 8.075, + 2.57, + 0.475 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 8.075, + 2.57, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 8.075, + 2.57, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 8.075, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 8.075, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 8.075, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 8.075, + 2.57, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 9.025, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 9.025, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 9.025, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 9.025, + 2.57, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 9.975, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 9.975, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 9.975, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 9.975, + 2.57, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 10.925, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 10.925, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 10.925, + 2.57, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 11.875, + 2.57, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 11.875, + 2.57, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 11.875, + 2.57, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 11.875, + 2.57, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.06, + 0.95 + ] + }, + { + "center": [ + 1.425, + 3.06875, + 7.125 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 1.355, + 3.06875, + 8.075 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 1.425, + 3.06875, + 9.025 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 2.375, + 3.06875, + 7.055 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 2.375, + 3.1725, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 2.375, + 3.06875, + 9.095 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 3.325, + 3.06875, + 7.055 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 3.325, + 3.1725, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 3.325, + 3.06875, + 9.095 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 4.275, + 3.06875, + 0.475 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 4.205, + 3.06875, + 1.425 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 4.205, + 3.06875, + 2.375 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 4.205, + 3.06875, + 3.325 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 4.205, + 3.06875, + 4.275 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 4.205, + 3.06875, + 5.225 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 4.205, + 3.06875, + 6.175 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 4.275, + 3.1725, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 4.275, + 3.1725, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 4.275, + 3.06875, + 9.095 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 5.225, + 3.06875, + 0.405 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 5.225, + 3.1725, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 5.225, + 3.1725, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 5.225, + 3.1725, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 5.225, + 3.1725, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 5.225, + 3.1725, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 5.225, + 3.3325, + 5.225 + ], + "kind": "structure", + "size": [ + 0.58, + 0.19, + 0.58 + ] + }, + { + "center": [ + 5.225, + 3.1725, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 5.225, + 3.1725, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 5.225, + 3.1725, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 5.225, + 3.06875, + 9.095 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 6.175, + 3.06875, + 0.405 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 6.175, + 3.1725, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 6.175, + 3.1725, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 6.175, + 3.1725, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 6.175, + 3.1725, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 6.175, + 3.1725, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 6.175, + 3.1725, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 6.175, + 3.1725, + 7.125 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 6.175, + 3.1725, + 8.075 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 6.175, + 3.06875, + 9.095 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 7.125, + 3.06875, + 0.405 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 7.125, + 3.1725, + 1.425 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 7.125, + 3.1725, + 2.375 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 7.125, + 3.1725, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 7.125, + 3.1725, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 7.125, + 3.1725, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 7.125, + 3.1725, + 6.175 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 7.195, + 3.06875, + 7.125 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 7.195, + 3.06875, + 8.075 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 7.125, + 3.06875, + 9.025 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 8.075, + 3.06875, + 0.475 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 8.145, + 3.06875, + 1.425 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 8.145, + 3.06875, + 2.375 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 8.075, + 3.1725, + 3.325 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 8.075, + 3.1725, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 8.075, + 3.1725, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 8.075, + 3.06875, + 6.245 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 9.025, + 3.06875, + 3.255 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 9.025, + 3.1725, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 9.025, + 3.1725, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 9.025, + 3.06875, + 6.245 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 9.975, + 3.06875, + 3.255 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 9.975, + 3.1725, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 9.975, + 3.1725, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 9.975, + 3.06875, + 6.245 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 10.925, + 3.06875, + 3.255 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 10.925, + 3.1725, + 4.275 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 10.925, + 3.1725, + 5.225 + ], + "kind": "structure", + "size": [ + 0.95, + 0.13, + 0.95 + ] + }, + { + "center": [ + 10.925, + 3.3325, + 5.225 + ], + "kind": "structure", + "size": [ + 0.58, + 0.19, + 0.58 + ] + }, + { + "center": [ + 10.925, + 3.06875, + 6.245 + ], + "kind": "structure", + "size": [ + 0.95, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 11.875, + 3.06875, + 3.325 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 1.09 + ] + }, + { + "center": [ + 11.945, + 3.06875, + 4.275 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 11.945, + 3.06875, + 5.225 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 0.95 + ] + }, + { + "center": [ + 11.875, + 3.06875, + 6.175 + ], + "kind": "structure", + "size": [ + 1.09, + 0.3375, + 1.09 + ] + } + ], + "contract": { + "decorativeExcluded": [ + "foundation", + "floor", + "ceiling", + "roof", + "entry_apron", + "entry_hood" + ], + "furnitureRoles": [ + "furn_*", + "xs_san_*" + ], + "geometrySource": "placed_module_collision_records", + "interiorDoorPolicy": "closed_static", + "structuralRoles": [ + "walls", + "windows", + "columns", + "static_internal_doors" + ] + }, + "door": { + "closed": { + "id": "closed_exterior_entry", + "maxX": 3.825, + "maxZ": 4.804, + "minX": 3.775, + "minZ": 3.904 + }, + "node": "door_slide" + }, + "floor": { + "bounds": { + "maxX": 12.35, + "maxZ": 9.5, + "minX": 0.95, + "minZ": 0.0 + }, + "slabThicknessM": 0.02, + "topY": 0.02 + }, + "footprint": { + "centerX": 6.65, + "centerZ": 4.75, + "maxX": 12.35, + "maxZ": 9.5, + "minX": 0.95, + "minZ": 0.0, + "spanX": 11.4, + "spanZ": 9.5 + }, + "furniture": [ + { + "id": "furniture_203_furn_corner_hearth__panelclad", + "maxX": 1.782375, + "maxZ": 9.32775, + "minX": 1.12225, + "minZ": 8.667625 + }, + { + "id": "furniture_204_furn_corner_bench__panelclad", + "maxX": 1.782375, + "maxZ": 7.482375, + "minX": 1.247625, + "minZ": 6.947625 + }, + { + "id": "furniture_205_furn_table_1c__panelclad", + "maxX": 3.715, + "maxZ": 8.405, + "minX": 2.935, + "minZ": 7.745 + }, + { + "id": "furniture_206_furn_chair_1c__panelclad", + "maxX": 2.625, + "maxZ": 8.29, + "minX": 2.125, + "minZ": 7.86 + }, + { + "id": "furniture_207_furn_chair_1c__panelclad", + "maxX": 4.525, + "maxZ": 8.29, + "minX": 4.025, + "minZ": 7.86 + }, + { + "id": "furniture_208_furn_pendant_1c__panelclad", + "maxX": 3.555, + "maxZ": 8.305, + "minX": 3.095, + "minZ": 7.845 + }, + { + "id": "furniture_209_furn_bench_2c__panelclad", + "maxX": 4.225, + "maxZ": 6.87, + "minX": 2.425, + "minZ": 6.43 + }, + { + "id": "furniture_210_furn_chest_1c__panelclad", + "maxX": 5.625, + "maxZ": 9.5, + "minX": 4.825, + "minZ": 9.29 + }, + { + "id": "furniture_211_furn_shelf_1c__panelclad", + "maxX": 4.675, + "maxZ": 6.79, + "minX": 3.875, + "minZ": 6.51 + }, + { + "id": "furniture_212_furn_planter_1c__panelclad", + "maxX": 5.541, + "maxZ": 8.309, + "minX": 4.909, + "minZ": 7.841 + }, + { + "id": "furniture_213_furn_lamp_wall__panelclad", + "maxX": 2.472, + "maxZ": 9.5, + "minX": 2.278, + "minZ": 9.262 + }, + { + "id": "furniture_214_furn_corner_store__panelclad", + "maxX": 7.302375, + "maxZ": 9.202375, + "minX": 6.767625, + "minZ": 8.667625 + }, + { + "id": "furniture_215_furn_sink_1c__panelclad", + "maxX": 7.9, + "maxZ": 7.575, + "minX": 7.3, + "minZ": 6.675 + }, + { + "id": "furniture_216_furn_stove_1c__panelclad", + "maxX": 7.9, + "maxZ": 8.525, + "minX": 7.3, + "minZ": 7.625 + }, + { + "id": "furniture_217_furn_wall_cab_1c__panelclad", + "maxX": 5.86, + "maxZ": 8.525, + "minX": 5.54, + "minZ": 7.625 + }, + { + "id": "furniture_218_furn_lamp_wall__panelclad", + "maxX": 5.938, + "maxZ": 9.122, + "minX": 5.7, + "minZ": 8.928 + }, + { + "id": "furniture_219_furn_desk_2c__panelclad", + "maxX": 9.885, + "maxZ": 6.97, + "minX": 8.165, + "minZ": 6.33 + }, + { + "id": "furniture_220_furn_chair_1c__panelclad", + "maxX": 9.24, + "maxZ": 5.475, + "minX": 8.81, + "minZ": 4.975 + }, + { + "id": "furniture_221_furn_crate_stack__panelclad", + "maxX": 8.83, + "maxZ": 3.645, + "minX": 8.27, + "minZ": 3.005 + }, + { + "id": "furniture_222_furn_shelf_1c__panelclad", + "maxX": 9.425, + "maxZ": 2.99, + "minX": 8.625, + "minZ": 2.71 + }, + { + "id": "furniture_223_furn_lamp_wall__panelclad", + "maxX": 7.838, + "maxZ": 3.422, + "minX": 7.6, + "minZ": 3.228 + }, + { + "id": "furniture_224_furn_corner_shelf__panelclad", + "maxX": 12.052375, + "maxZ": 3.682375, + "minX": 11.517625, + "minZ": 3.147625 + }, + { + "id": "furniture_225_furn_corner_desk__panelclad", + "maxX": 12.052375, + "maxZ": 6.352375, + "minX": 11.517625, + "minZ": 5.817625 + }, + { + "id": "furniture_226_furn_bed_2c__panelclad", + "maxX": 10.875, + "maxZ": 3.29, + "minX": 9.075, + "minZ": 2.41 + }, + { + "id": "furniture_227_furn_nightstand__panelclad", + "maxX": 9.68, + "maxZ": 3.51, + "minX": 9.32, + "minZ": 3.14 + }, + { + "id": "furniture_228_furn_lamp_wall__panelclad", + "maxX": 11.022, + "maxZ": 6.65, + "minX": 10.828, + "minZ": 6.412 + }, + { + "id": "furniture_229_furn_corner_shelf__panelclad", + "maxX": 4.632375, + "maxZ": 0.832375, + "minX": 4.097625, + "minZ": 0.297625 + }, + { + "id": "furniture_230_furn_corner_bench__panelclad", + "maxX": 8.252375, + "maxZ": 0.832375, + "minX": 7.717625, + "minZ": 0.297625 + }, + { + "id": "furniture_231_furn_crate_stack__panelclad", + "maxX": 6.495, + "maxZ": 0.28, + "minX": 5.855, + "minZ": 0.0 + }, + { + "id": "furniture_232_xs_san_basin_1c__panelclad", + "maxX": 7.465, + "maxZ": 2.85, + "minX": 6.785, + "minZ": 2.39 + }, + { + "id": "furniture_233_xs_san_mirrorcab_1c__panelclad", + "maxX": 7.565, + "maxZ": 2.756, + "minX": 6.685, + "minZ": 2.626 + }, + { + "id": "furniture_234_xs_san_reclaimer_1c__panelclad", + "maxX": 7.345, + "maxZ": 0.51, + "minX": 6.905, + "minZ": 0.0 + }, + { + "id": "furniture_235_xs_san_stall_1c__panelclad", + "maxX": 8.55, + "maxZ": 2.815, + "minX": 7.69, + "minZ": 1.935 + }, + { + "id": "furniture_236_xs_san_towelbar_1c__panelclad", + "maxX": 4.65, + "maxZ": 2.77, + "minX": 3.9, + "minZ": 2.666 + }, + { + "id": "furniture_237_xs_san_drain_1c__panelclad", + "maxX": 6.475, + "maxZ": 1.565, + "minX": 5.875, + "minZ": 1.285 + }, + { + "id": "furniture_238_furn_lamp_wall__panelclad", + "maxX": 5.322, + "maxZ": 0.238, + "minX": 5.128, + "minZ": 0.0 + }, + { + "id": "furniture_239_furn_planter_1c__panelclad", + "maxX": 4.591, + "maxZ": 6.409, + "minX": 3.959, + "minZ": 5.941 + }, + { + "id": "furniture_240_furn_pendant_1c__panelclad", + "maxX": 7.355, + "maxZ": 3.555, + "minX": 6.895, + "minZ": 3.095 + }, + { + "id": "furniture_241_furn_shelf_1c__panelclad", + "maxX": 4.675, + "maxZ": 2.99, + "minX": 3.875, + "minZ": 2.71 + }, + { + "id": "furniture_242_furn_lamp_wall__panelclad", + "maxX": 7.222, + "maxZ": 6.65, + "minX": 7.028, + "minZ": 6.412 + } + ], + "generatedBy": "src/package_runtime_buildings.py", + "interiorRegions": [ + { + "floorTopY": 0.02, + "id": "hall", + "maxX": 7.6, + "maxZ": 6.65, + "minX": 3.8, + "minZ": 2.85 + }, + { + "floorTopY": 0.02, + "id": "living", + "maxX": 5.7, + "maxZ": 9.5, + "minX": 0.95, + "minZ": 6.65 + }, + { + "floorTopY": 0.02, + "id": "kitchen", + "maxX": 7.6, + "maxZ": 9.5, + "minX": 5.7, + "minZ": 6.65 + }, + { + "floorTopY": 0.02, + "id": "study", + "maxX": 9.5, + "maxZ": 6.65, + "minX": 7.6, + "minZ": 2.85 + }, + { + "floorTopY": 0.02, + "id": "bed", + "maxX": 12.35, + "maxZ": 6.65, + "minX": 9.5, + "minZ": 2.85 + }, + { + "floorTopY": 0.02, + "id": "bath", + "maxX": 8.55, + "maxZ": 2.85, + "minX": 3.8, + "minZ": 0.0 + } + ], + "module_id": "home_modular_wing", + "schema": "successor.structure-collision.v3", + "source": "home_modular_wing.glb", + "version": "successor.structure-collision.v3", + "walls": [ + { + "id": "wall_144_wall_cham_1c__sintered", + "maxX": 1.9, + "maxZ": 7.6, + "minX": 0.95, + "minZ": 6.57 + }, + { + "id": "wall_145_wall_cham_1c__sintered", + "maxX": 1.9, + "maxZ": 9.5, + "minX": 0.95, + "minZ": 8.55 + }, + { + "id": "wall_146_wall_cham_1c__sintered", + "maxX": 4.75, + "maxZ": 0.95, + "minX": 3.72, + "minZ": 0.0 + }, + { + "id": "wall_147_wall_cham_1c__sintered", + "maxX": 7.68, + "maxZ": 9.5, + "minX": 6.65, + "minZ": 8.55 + }, + { + "id": "wall_148_wall_cham_1c__sintered", + "maxX": 8.63, + "maxZ": 0.95, + "minX": 7.6, + "minZ": 0.0 + }, + { + "id": "wall_149_wall_cham_1c__sintered", + "maxX": 12.35, + "maxZ": 3.8, + "minX": 11.4, + "minZ": 2.77 + }, + { + "id": "wall_150_wall_cham_1c__sintered", + "maxX": 12.35, + "maxZ": 6.73, + "minX": 11.4, + "minZ": 5.7 + }, + { + "id": "wall_151_window_slot_1c__sintered", + "maxX": 3.88, + "maxZ": 1.9, + "minX": 3.72, + "minZ": 0.95 + }, + { + "id": "wall_152_wall_1c__sintered", + "maxX": 3.88, + "maxZ": 2.85, + "minX": 3.72, + "minZ": 1.9 + }, + { + "id": "wall_153_window_cler_1c__sintered", + "maxX": 3.88, + "maxZ": 3.8, + "minX": 3.72, + "minZ": 2.85 + }, + { + "id": "wall_154_wall_1c__sintered", + "maxX": 3.88, + "maxZ": 6.65, + "minX": 3.72, + "minZ": 5.7 + }, + { + "id": "wall_155_door_slide_2c__sintered", + "maxX": 3.88, + "maxZ": 5.7, + "minX": 3.72, + "minZ": 3.8 + }, + { + "id": "wall_158_wall_1c__sintered", + "maxX": 5.7, + "maxZ": 0.08, + "minX": 4.75, + "minZ": 0.0 + }, + { + "id": "wall_159_window_slot_1c__sintered", + "maxX": 6.65, + "maxZ": 0.08, + "minX": 5.7, + "minZ": 0.0 + }, + { + "id": "wall_160_wall_1c__sintered", + "maxX": 7.6, + "maxZ": 0.08, + "minX": 6.65, + "minZ": 0.0 + }, + { + "id": "wall_161_window_slot_1c__sintered", + "maxX": 8.63, + "maxZ": 1.9, + "minX": 8.47, + "minZ": 0.95 + }, + { + "id": "wall_162_wall_1c__sintered", + "maxX": 8.63, + "maxZ": 2.85, + "minX": 8.47, + "minZ": 1.9 + }, + { + "id": "wall_163_wall_1c__sintered", + "maxX": 9.5, + "maxZ": 2.93, + "minX": 8.55, + "minZ": 2.77 + }, + { + "id": "wall_164_wall_1c__sintered", + "maxX": 10.45, + "maxZ": 2.93, + "minX": 9.5, + "minZ": 2.77 + }, + { + "id": "wall_165_window_slot_1c__sintered", + "maxX": 11.4, + "maxZ": 2.93, + "minX": 10.45, + "minZ": 2.77 + }, + { + "id": "wall_166_window_cler_1c__sintered", + "maxX": 12.35, + "maxZ": 4.75, + "minX": 12.27, + "minZ": 3.8 + }, + { + "id": "wall_167_window_slot_1c__sintered", + "maxX": 12.35, + "maxZ": 5.7, + "minX": 12.27, + "minZ": 4.75 + }, + { + "id": "wall_168_window_slot_1c__sintered", + "maxX": 8.55, + "maxZ": 6.73, + "minX": 7.6, + "minZ": 6.57 + }, + { + "id": "wall_169_wall_1c__sintered", + "maxX": 11.4, + "maxZ": 6.73, + "minX": 10.45, + "minZ": 6.57 + }, + { + "id": "wall_170_window_tall_2c__sintered", + "maxX": 10.45, + "maxZ": 6.73, + "minX": 8.55, + "minZ": 6.57 + }, + { + "id": "wall_171_wall_1c__sintered", + "maxX": 7.68, + "maxZ": 7.6, + "minX": 7.52, + "minZ": 6.65 + }, + { + "id": "wall_172_window_cler_1c__sintered", + "maxX": 7.68, + "maxZ": 8.55, + "minX": 7.52, + "minZ": 7.6 + }, + { + "id": "wall_173_wall_1c__sintered", + "maxX": 2.85, + "maxZ": 9.5, + "minX": 1.9, + "minZ": 9.42 + }, + { + "id": "wall_174_wall_1c__sintered", + "maxX": 5.7, + "maxZ": 9.5, + "minX": 4.75, + "minZ": 9.42 + }, + { + "id": "wall_175_window_slot_1c__sintered", + "maxX": 6.65, + "maxZ": 9.5, + "minX": 5.7, + "minZ": 9.42 + }, + { + "id": "wall_176_window_tall_2c__sintered", + "maxX": 4.75, + "maxZ": 9.5, + "minX": 2.85, + "minZ": 9.42 + }, + { + "id": "wall_177_window_cler_1c__sintered", + "maxX": 1.03, + "maxZ": 8.55, + "minX": 0.95, + "minZ": 7.6 + }, + { + "id": "wall_178_window_slot_1c__sintered", + "maxX": 2.85, + "maxZ": 6.73, + "minX": 1.9, + "minZ": 6.57 + }, + { + "id": "wall_179_wall_1c__sintered", + "maxX": 3.8, + "maxZ": 6.73, + "minX": 2.85, + "minZ": 6.57 + }, + { + "id": "wall_180_corner_inner__sintered", + "maxX": 3.975, + "maxZ": 6.825, + "minX": 3.625, + "minZ": 6.475 + }, + { + "id": "wall_181_corner_inner__sintered", + "maxX": 7.775, + "maxZ": 6.825, + "minX": 7.425, + "minZ": 6.475 + }, + { + "id": "wall_182_corner_inner__sintered", + "maxX": 8.725, + "maxZ": 3.025, + "minX": 8.375, + "minZ": 2.675 + }, + { + "id": "wall_183_wall_1c__plasterlined", + "maxX": 4.75, + "maxZ": 6.73, + "minX": 3.8, + "minZ": 6.57 + }, + { + "id": "wall_184_wall_1c__plasterlined", + "maxX": 7.6, + "maxZ": 6.73, + "minX": 6.65, + "minZ": 6.57 + }, + { + "id": "wall_185_door_inner_2c__plasterlined", + "maxX": 6.65, + "maxZ": 6.73, + "minX": 4.75, + "minZ": 6.57 + }, + { + "id": "wall_186_wall_1c__plasterlined", + "maxX": 7.68, + "maxZ": 3.8, + "minX": 7.52, + "minZ": 2.85 + }, + { + "id": "wall_187_wall_1c__plasterlined", + "maxX": 7.68, + "maxZ": 6.65, + "minX": 7.52, + "minZ": 5.7 + }, + { + "id": "wall_188_door_inner_2c__plasterlined", + "maxX": 7.68, + "maxZ": 5.7, + "minX": 7.52, + "minZ": 3.8 + }, + { + "id": "wall_189_wall_tee__sintered", + "maxX": 7.748, + "maxZ": 2.998, + "minX": 7.452, + "minZ": 2.702 + }, + { + "id": "wall_190_wall_1c__plasterlined", + "maxX": 4.75, + "maxZ": 2.93, + "minX": 3.8, + "minZ": 2.77 + }, + { + "id": "wall_191_wall_1c__plasterlined", + "maxX": 7.6, + "maxZ": 2.93, + "minX": 6.65, + "minZ": 2.77 + }, + { + "id": "wall_192_door_inner_2c__plasterlined", + "maxX": 6.65, + "maxZ": 2.93, + "minX": 4.75, + "minZ": 2.77 + }, + { + "id": "wall_193_wall_tee__sintered", + "maxX": 3.948, + "maxZ": 2.998, + "minX": 3.652, + "minZ": 2.702 + }, + { + "id": "wall_194_wall_1c__plasterlined", + "maxX": 8.55, + "maxZ": 2.93, + "minX": 7.6, + "minZ": 2.77 + }, + { + "id": "wall_195_wall_1c__panelclad", + "maxX": 9.58, + "maxZ": 3.8, + "minX": 9.42, + "minZ": 2.85 + }, + { + "id": "wall_196_wall_1c__panelclad", + "maxX": 9.58, + "maxZ": 6.65, + "minX": 9.42, + "minZ": 5.7 + }, + { + "id": "wall_197_door_inner_2c__panelclad", + "maxX": 9.58, + "maxZ": 5.7, + "minX": 9.42, + "minZ": 3.8 + }, + { + "id": "wall_198_wall_tee__sintered", + "maxX": 9.648, + "maxZ": 2.998, + "minX": 9.352, + "minZ": 2.702 + }, + { + "id": "wall_199_wall_tee__sintered", + "maxX": 9.648, + "maxZ": 6.798, + "minX": 9.352, + "minZ": 6.502 + }, + { + "id": "wall_200_wall_1c__panelclad", + "maxX": 5.78, + "maxZ": 8.55, + "minX": 5.62, + "minZ": 7.6 + }, + { + "id": "wall_201_wall_1c__panelclad", + "maxX": 5.78, + "maxZ": 9.5, + "minX": 5.62, + "minZ": 8.55 + }, + { + "id": "wall_202_wall_tee__sintered", + "maxX": 5.848, + "maxZ": 9.5, + "minX": 5.552, + "minZ": 9.352 + } + ] +} diff --git a/client-3d/public/assets/world-items/home_modular_wing_manifest.json b/client-3d/public/assets/world-items/home_modular_wing_manifest.json new file mode 100644 index 00000000..8a22a708 --- /dev/null +++ b/client-3d/public/assets/world-items/home_modular_wing_manifest.json @@ -0,0 +1,191 @@ +{ + "asset": { + "bytes": 9933580, + "file": "home_modular_wing.glb", + "images": { + "external_image_uri_count": 0, + "image_count": 27 + }, + "sha256": "sha256:055da4042835e922b24a9c332d26af33389ed8c3dfb5c0bb784d802157837e3b" + }, + "bbox_span_m": [ + 11.81, + 3.7655, + 9.943 + ], + "building": "home_modular_wing", + "collision": { + "boxes": 422, + "door_boxes": 5, + "sha256": "sha256:da1045f01a3ab4cedc071933f5c0921dfcb7996da540d888684df728532e13fb", + "structure_boxes": 417 + }, + "collisionProxy": { + "furnitureCount": 46, + "includesFurniture": true, + "sidecar": "home_modular_wing_collision.json", + "source": "home_modular_wing", + "structuralOnly": false, + "wallCount": 83 + }, + "cutaway": { + "faces": { + "back": "wall_back__", + "front": "wall_front__", + "left": "wall_left__", + "right": "wall_right__" + }, + "hide": [ + "roof__", + "wall_front__", + "wall_back__", + "wall_left__", + "wall_right__", + "wall_court__" + ], + "keep": [ + "floor__", + "interior__" + ], + "revealPrefixes": [ + "roof__", + "wall_front__", + "wall_back__", + "wall_left__", + "wall_right__", + "wall_court__" + ], + "stub_by_face": true + }, + "door": { + "axisLocal": [ + 1, + 0, + 0 + ], + "clips": [ + "door_close", + "door_open" + ], + "closedInGlb": true, + "local_axis": "X", + "node": "door_slide", + "slide_axis_local": [ + 1, + 0, + 0 + ], + "slide_distance_m": 0.846 + }, + "door_cells": [ + [ + 4, + 4 + ], + [ + 4, + 5 + ] + ], + "exteriorEntry": { + "doorCells": [ + [ + 4, + 4 + ], + [ + 4, + 5 + ] + ], + "doorNode": "door_slide", + "facing": "west", + "frontAxis": "+Z" + }, + "floorHeightM": 0.02, + "floorTopY": 0.02, + "footprintCells": [ + 12, + 10 + ], + "footprint_max_m": [ + 11.4, + 9.5 + ], + "front": "+Z", + "interiorRegions": [ + { + "floorTopY": 0.02, + "id": "hall", + "maxX": 7.6, + "maxZ": 6.65, + "minX": 3.8, + "minZ": 2.85 + }, + { + "floorTopY": 0.02, + "id": "living", + "maxX": 5.7, + "maxZ": 9.5, + "minX": 0.95, + "minZ": 6.65 + }, + { + "floorTopY": 0.02, + "id": "kitchen", + "maxX": 7.6, + "maxZ": 9.5, + "minX": 5.7, + "minZ": 6.65 + }, + { + "floorTopY": 0.02, + "id": "study", + "maxX": 9.5, + "maxZ": 6.65, + "minX": 7.6, + "minZ": 2.85 + }, + { + "floorTopY": 0.02, + "id": "bed", + "maxX": 12.35, + "maxZ": 6.65, + "minX": 9.5, + "minZ": 2.85 + }, + { + "floorTopY": 0.02, + "id": "bath", + "maxX": 8.55, + "maxZ": 2.85, + "minX": 3.8, + "minZ": 0.0 + } + ], + "label": "Modular Wing Home", + "materials": 675, + "promised_clear_cells": {}, + "runtimeKey": "home_modular_wing", + "runtime_scale_at_12x10_cells": 1.0, + "schema": "successor.runtime-building-manifest.v1", + "service_anchor_cells": {}, + "source": { + "floorBoundsM": [ + [ + 0.95, + 0.0 + ], + [ + 12.35, + 9.5 + ] + ], + "furnishedPlacements": 40, + "placements": 385, + "plan": "home_wing", + "provenance": "home_modular_wing.provenance.json" + }, + "tri_count": 54814, + "units": "m" +} diff --git a/client-3d/public/assets/world-items/house_h1.glb b/client-3d/public/assets/world-items/house_h1.glb index 902493c9..6fc58960 100644 --- a/client-3d/public/assets/world-items/house_h1.glb +++ b/client-3d/public/assets/world-items/house_h1.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d99858d8ef75a411d58875232476a51091bfb4bf180ac36cfd62543c23ea0a9e -size 61848 +oid sha256:308761ac0e49729238f0fdfa0bfcc2448ff412eb1c1e77ddf851fd1f3085257a +size 61808 diff --git a/client-3d/public/assets/world-items/pa_terminal.glb b/client-3d/public/assets/world-items/pa_terminal.glb index d6c72b78..089263fa 100644 --- a/client-3d/public/assets/world-items/pa_terminal.glb +++ b/client-3d/public/assets/world-items/pa_terminal.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:237c21c6284b7f6617ae4bc7fbff3e8230888064a6bf5f240c3389abce15086a -size 939596 +oid sha256:3a78cb02e2b91e6a42988d69a9ce2d86c0291b87c378bfb2a61c90cdd283b949 +size 939816 diff --git a/client-3d/public/assets/world-items/resource_iron.glb b/client-3d/public/assets/world-items/resource_iron.glb index fd83b75a..b4776f2f 100644 --- a/client-3d/public/assets/world-items/resource_iron.glb +++ b/client-3d/public/assets/world-items/resource_iron.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f4aebfc830f6eec5f95bef5f45655f5752056caa9bc79e01334f5b0f9070c612 -size 10404 +oid sha256:d7157d107ed5d726103538f5ac90164d966c825eb8b27d79e71d8ee3304dab92 +size 10424 diff --git a/client-3d/public/assets/world-items/shelter_frontier.glb b/client-3d/public/assets/world-items/shelter_frontier.glb index b1491dc4..b3c9f4ed 100644 --- a/client-3d/public/assets/world-items/shelter_frontier.glb +++ b/client-3d/public/assets/world-items/shelter_frontier.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:869f897c1b67ee564046014936037159c6523fca83c74565f120aaedb604e3c7 -size 3551260 +oid sha256:1c446b74219c464cfcc3419f3d0b1253c2af173e3faded963127811d7405f23f +size 3551304 diff --git a/client-3d/public/assets/world-items/supply_cache.glb b/client-3d/public/assets/world-items/supply_cache.glb index 64a630b6..c0d4e7b6 100644 --- a/client-3d/public/assets/world-items/supply_cache.glb +++ b/client-3d/public/assets/world-items/supply_cache.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4cfabd303f3a4dde10ca57a589fb9b86fd90ff652266aa77d6a97238cfe58255 -size 1952572 +oid sha256:6aa2acdacfae1c8d42cb3554514f7f9a7f93c832b18b9f0c04a68fd2d9b2bdfa +size 1952616 diff --git a/client-3d/public/assets/world-items/tank_water_frontier.glb b/client-3d/public/assets/world-items/tank_water_frontier.glb index 7892ec6d..697032c3 100644 --- a/client-3d/public/assets/world-items/tank_water_frontier.glb +++ b/client-3d/public/assets/world-items/tank_water_frontier.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0878cfb8e4786a61abd551ead78aac370aaa3ed3e32fa0273d38269c96912bc7 -size 368964 +oid sha256:1fd58e1844160f5a437d507240a089fd15b534c3410d139e75da7564fb234c53 +size 369104 diff --git a/client-3d/public/assets/world-items/trade_terminal.glb b/client-3d/public/assets/world-items/trade_terminal.glb index f30d5936..9ac7699b 100644 --- a/client-3d/public/assets/world-items/trade_terminal.glb +++ b/client-3d/public/assets/world-items/trade_terminal.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:90a2602192261b3e5f14cc738c49d240ae44776166acf1779239527d23179805 -size 955232 +oid sha256:f0bc63de75d2bc063c53e6d439f42151015b2f6099f7e23c83c41db72226ce14 +size 955456 diff --git a/client-3d/public/assets/world-items/travel_terminal_grok_wedge.glb b/client-3d/public/assets/world-items/travel_terminal_grok_wedge.glb index 5e5604bd..cf2f1137 100644 --- a/client-3d/public/assets/world-items/travel_terminal_grok_wedge.glb +++ b/client-3d/public/assets/world-items/travel_terminal_grok_wedge.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a26cc0591111b5456c1ac6822d35f9b5895171cf8c036d792b62643788cbad2a -size 10862804 +oid sha256:2204d27ae532cc20f5cebaccf1de8007ec97540e6f8c352c285061658a113c20 +size 10862848 diff --git a/client-3d/public/assets/world-items/valley_market.glb b/client-3d/public/assets/world-items/valley_market.glb new file mode 100644 index 00000000..ca7fdd1e --- /dev/null +++ b/client-3d/public/assets/world-items/valley_market.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:115ff90a447a1f7c931c2359fcbe48108af26cfd4de80dd5c12572a068962438 +size 9662996 diff --git a/client-3d/public/assets/world-items/valley_market.provenance.json b/client-3d/public/assets/world-items/valley_market.provenance.json new file mode 100644 index 00000000..4455e9ee --- /dev/null +++ b/client-3d/public/assets/world-items/valley_market.provenance.json @@ -0,0 +1,167 @@ +{ + "schema": "successor-asset-provenance/1", + "asset_id": "valley_market", + "asset_kind": "model_glb", + "asset_path": "runtime-buildings/market/valley_market.glb", + "asset_hash": "sha256:1f4e5e0d52e4fe504dcd70ed7b6590e45dc5c2cb69781ac7bab7e530ca40a1a3", + "source_blend_or_script": "tools/successor/assets/market-opus5-rebuild/src/build_market.py (deterministic Blender authoring source)", + "package_script": "tools/successor/assets/market-opus5-rebuild/src/package_runtime.mjs", + "regeneration_command": "cd tools/successor/assets/market-opus5-rebuild && python3 src/gen_textures.py && blender -b --factory-startup -noaudio --python-exit-code 1 -P src/build_market.py -- --stage full && node src/post_gltf.mjs build/market_house_furnished.glb 1.0 && python3 src/manifest.py && node src/package_runtime.mjs", + "source_inputs": { + "source_manifest_sha256": "sha256:965ba1f1bc7b3fd148b5b6849a337b67dec54aad1f9864e9d241ddf9fc4d150b", + "source_collision_sha256": "sha256:4fc7c186fc5dcb7da97e51939958bbf3fc1ebdfa88b90a91d6ce4ba0d74d0e4d", + "furnished_source_sha256": "sha256:1f4e5e0d52e4fe504dcd70ed7b6590e45dc5c2cb69781ac7bab7e530ca40a1a3", + "imported_props": [ + { + "file": "client-3d/public/assets/world-items/bank_terminal_civic.glb", + "sha256": "eb825f81370b014660499bcb7fc67480895b3734683ba6647234203b996c2151", + "bytes": 1034480, + "provenance": "client-3d/public/assets/world-items/bank_terminal_civic.provenance.json", + "manifest": "client-3d/public/assets/world-items/bank_terminal_civic_manifest.json", + "role": "bank / private-value service point, docked in its niche, screens facing the customer approach (+Z)", + "placement_asset_space": { + "x": -2.375, + "y": 0.02, + "z": -1.927, + "yaw_deg_about_Y": 0 + }, + "note": "asset space is glTF: +Y up, +Z public front" + }, + { + "file": "client-3d/public/assets/world-items/trade_terminal.glb", + "sha256": "90a2602192261b3e5f14cc738c49d240ae44776166acf1779239527d23179805", + "bytes": 955232, + "provenance": "client-3d/public/assets/world-items/trade_terminal.provenance.json", + "manifest": "client-3d/public/assets/world-items/trade_terminal_manifest.json", + "role": "trade / exchange service point, docked in its niche, scan bed and readout facing the customer approach (+Z)", + "placement_asset_space": { + "x": 0.475, + "y": 0.02, + "z": -1.9, + "yaw_deg_about_Y": 0 + }, + "note": "asset space is glTF: +Y up, +Z public front" + }, + { + "file": "client-3d/public/assets/world-items/pa_terminal.glb", + "sha256": "237c21c6284b7f6617ae4bc7fbff3e8230888064a6bf5f240c3389abce15086a", + "bytes": 939596, + "provenance": "client-3d/public/assets/world-items/pa_terminal.provenance.json", + "manifest": "client-3d/public/assets/world-items/pa_terminal_manifest.json", + "role": "player-association registry point, docked in its niche, roster screen and reader podium facing the customer approach (+Z)", + "placement_asset_space": { + "x": 3.325, + "y": 0.02, + "z": -1.931, + "yaw_deg_about_Y": 0 + }, + "note": "asset space is glTF: +Y up, +Z public front" + }, + { + "file": "client-3d/public/assets/world-items/chair_frontier_a.glb", + "sha256": "04575d7fe54e7762bedcf1622a15aa31f2b76decca8c2c6f6ce2305a3d03bcb6", + "bytes": 3423744, + "provenance": "client-3d/public/assets/world-items/chair_frontier_a.provenance.json", + "manifest": "client-3d/public/assets/world-items/chair_frontier_a_manifest.json", + "role": "trainer's seat behind the consultation table, facing the customer approach", + "placement_asset_space": { + "x": 4.3, + "y": 0.02, + "z": 3.3, + "yaw_deg_about_Y": -180 + }, + "note": "asset space is glTF: +Y up, +Z public front" + }, + { + "file": "client-3d/public/assets/world-items/chair_frontier_b.glb", + "sha256": "ba6d6901bfde01a5c858fb80db89b29f5e18dd5cb42bb59946fe5d55893c15f2", + "bytes": 3384220, + "provenance": "client-3d/public/assets/world-items/chair_frontier_b.provenance.json", + "manifest": "client-3d/public/assets/world-items/chair_frontier_b_manifest.json", + "role": "visitor seat drawn up to the north side of the consultation table", + "placement_asset_space": { + "x": 3.43, + "y": 0.02, + "z": 2.14, + "yaw_deg_about_Y": 0 + }, + "note": "asset space is glTF: +Y up, +Z public front" + }, + { + "file": "client-3d/public/assets/world-items/crate_planked.glb", + "sha256": "d5ac6f0d9a939d0f4cb77ced8594b013db1d40ebe895c0e1871c1543ea83dfa6", + "bytes": 2336804, + "provenance": "client-3d/public/assets/world-items/crate_planked.provenance.json", + "manifest": null, + "role": "vendor goods delivered at the display line's south end, clear of the counter, the south wall and the queue", + "placement_asset_space": { + "x": -3.2, + "y": 0.02, + "z": 2.1, + "yaw_deg_about_Y": -12 + }, + "note": "asset space is glTF: +Y up, +Z public front" + }, + { + "file": "client-3d/public/assets/world-items/footlocker_frontier.glb", + "sha256": "f8bbc53ff96b0020466da861e6cdce2ac43090db6384d14e8656fa83a71e75ad", + "bytes": 3178584, + "provenance": "client-3d/public/assets/world-items/footlocker_frontier.provenance.json", + "manifest": "client-3d/public/assets/world-items/footlocker_frontier_manifest.json", + "role": "back-of-house storage locker, against the rear wall west of the staff route, clear of the proven corridor", + "placement_asset_space": { + "x": -4.7, + "y": 0.02, + "z": -3.293, + "yaw_deg_about_Y": 0 + }, + "note": "asset space is glTF: +Y up, +Z public front" + } + ] + }, + "contract": { + "runtime_key": "valley_market", + "footprint_cells": [ + 12, + 9 + ], + "footprint_m": [ + 11.4, + 8.55 + ], + "floor_top_y_m": 0.02, + "exterior_entry_facing": "south", + "terminal_cells_kept_clear": { + "bank_terminal_civic": [ + 3, + 3 + ], + "trade_terminal": [ + 6, + 3 + ], + "pa_terminal": [ + 9, + 3 + ], + "trainer_npc": [ + 10, + 6 + ] + }, + "door_node": "door_slide" + }, + "validation": { + "image_embedding": { + "image_count": 41, + "external_image_uri_count": 0 + }, + "authored_clearance_checks": 23, + "authored_clearance_issues": [], + "verifier": "tools/successor/assets/market-opus5-rebuild/src/verify_runtime_package.mjs" + }, + "rights": { + "redistribution_status": "authorized for Successor runtime distribution only; no standalone reuse grant", + "source_license": "Successor proprietary project asset; all rights reserved" + } +} diff --git a/client-3d/public/assets/world-items/valley_market_collision.json b/client-3d/public/assets/world-items/valley_market_collision.json new file mode 100644 index 00000000..2552085b --- /dev/null +++ b/client-3d/public/assets/world-items/valley_market_collision.json @@ -0,0 +1,641 @@ +{ + "schema": "successor.structure-collision.v3", + "source": "valley_market.glb", + "generatedBy": "tools/successor/assets/market-opus5-rebuild/src/package_runtime.mjs", + "contract": { + "runtimeKey": "valley_market", + "footprint_cells": [ + 12, + 9 + ], + "cell_m": 0.95, + "floor_top_y_m": 0.02, + "entry_facing": "south", + "entry_axis": "+Z", + "structuralRoles": [ + "authored_shell", + "service_wall", + "niches", + "flank_recesses", + "entry_structure" + ], + "furnitureRoles": [ + "authored_furnished_interior", + "trainer_consultation", + "back_of_house", + "service_hardware" + ], + "decorativeExcluded": [ + "roof", + "clerestory", + "trim", + "lights", + "loose_visual_props" + ], + "terminal_cells_kept_clear": { + "bank_terminal_civic": [ + 3, + 3 + ], + "trade_terminal": [ + 6, + 3 + ], + "pa_terminal": [ + 9, + 3 + ], + "trainer_npc": [ + 10, + 6 + ] + } + }, + "footprint": { + "minX": -5.7, + "minZ": -4.275, + "maxX": 5.7, + "maxZ": 4.275, + "spanX": 11.4, + "spanZ": 8.55, + "centerX": 0, + "centerZ": 0 + }, + "floor": { + "topY": 0.02, + "slabThicknessM": 0.32 + }, + "walls": [ + { + "id": "shell__wall_west_0", + "minX": -5.56, + "minZ": 2.6, + "maxX": -5.22, + "maxZ": 3.12 + }, + { + "id": "shell__wall_west_1", + "minX": -5.56, + "minZ": -3.95, + "maxX": -5.22, + "maxZ": -1.34 + }, + { + "id": "shell__wall_west_back_0", + "minX": -5.08, + "minZ": -1.34, + "maxX": -4.92, + "maxZ": 2.6 + }, + { + "id": "shell__wall_east_0", + "minX": 5.22, + "minZ": 2.66, + "maxX": 5.56, + "maxZ": 4.06 + }, + { + "id": "shell__wall_east_1", + "minX": 5.22, + "minZ": -2.24, + "maxX": 5.56, + "maxZ": 0.62 + }, + { + "id": "shell__wall_east_2", + "minX": 5.22, + "minZ": -3.95, + "maxX": 5.56, + "maxZ": -3.66 + }, + { + "id": "shell__wall_east_back_0", + "minX": 4.94, + "minZ": 0.62, + "maxX": 5.1, + "maxZ": 2.66 + }, + { + "id": "shell__wall_east_back_1", + "minX": 5.22, + "minZ": -3.66, + "maxX": 5.32, + "maxZ": -2.24 + }, + { + "id": "shell__cheek_w0_s", + "minX": -5.56, + "minZ": 2.6, + "maxX": -4.92, + "maxZ": 2.94 + }, + { + "id": "shell__cheek_w0_n", + "minX": -5.56, + "minZ": -1.68, + "maxX": -4.92, + "maxZ": -1.34 + }, + { + "id": "shell__cheek_e0_s", + "minX": 4.94, + "minZ": 2.66, + "maxX": 5.56, + "maxZ": 3 + }, + { + "id": "shell__cheek_e0_n", + "minX": 4.94, + "minZ": 0.28, + "maxX": 5.56, + "maxZ": 0.62 + }, + { + "id": "shell__cheek_e1_s", + "minX": 5.22, + "minZ": -2.24, + "maxX": 5.56, + "maxZ": -1.9 + }, + { + "id": "shell__cheek_e1_n", + "minX": 5.22, + "minZ": -4, + "maxX": 5.56, + "maxZ": -3.66 + }, + { + "id": "shell__arc_pier", + "minX": -5.56, + "minZ": 0.68, + "maxX": -5.08, + "maxZ": 1.04 + }, + { + "id": "shell__plb_mullion", + "minX": 5.32, + "minZ": -3.23, + "maxX": 5.56, + "maxZ": -2.94 + }, + { + "id": "shell__wall_north_w", + "minX": -5.56, + "minZ": -3.95, + "maxX": 0.725, + "maxZ": -3.61 + }, + { + "id": "shell__wall_north_e", + "minX": 1.875, + "minZ": -3.95, + "maxX": 5.56, + "maxZ": -3.61 + }, + { + "id": "shell__wall_south_w", + "minX": -5.56, + "minZ": 2.78, + "maxX": -1.72, + "maxZ": 3.12 + }, + { + "id": "shell__wall_south_e", + "minX": 1.12, + "minZ": 2.78, + "maxX": 2.72, + "maxZ": 3.12 + }, + { + "id": "shell__bay_south", + "minX": 2.72, + "minZ": 3.72, + "maxX": 5.56, + "maxZ": 4.06 + }, + { + "id": "shell__bay_west", + "minX": 2.72, + "minZ": 2.78, + "maxX": 3.06, + "maxZ": 4.06 + }, + { + "id": "shell__hood_pier_w", + "minX": -2.2, + "minZ": 3.12, + "maxX": -1.72, + "maxZ": 4.16 + }, + { + "id": "shell__hood_pier_e", + "minX": 1.12, + "minZ": 3.12, + "maxX": 1.6, + "maxZ": 4.16 + }, + { + "id": "shell__loggia_pier_0", + "minX": -5.53, + "minZ": 3.75, + "maxX": -5.07, + "maxZ": 4.21 + }, + { + "id": "shell__loggia_pier_1", + "minX": -4.05, + "minZ": 3.79, + "maxX": -3.67, + "maxZ": 4.17 + }, + { + "id": "shell__loggia_pier_2", + "minX": -2.78, + "minZ": 3.82, + "maxX": -2.46, + "maxZ": 4.14 + }, + { + "id": "shell__lowcorner_corner_sw", + "minX": -5.645, + "minZ": 2.78, + "maxX": -5.22, + "maxZ": 3.205 + }, + { + "id": "shell__lowcorner_corner_nw", + "minX": -5.645, + "minZ": -4.035, + "maxX": -5.22, + "maxZ": -3.61 + }, + { + "id": "shell__lowcorner_corner_ne", + "minX": 5.22, + "minZ": -4.035, + "maxX": 5.645, + "maxZ": -3.61 + }, + { + "id": "shell__lowcorner_corner_bay_se", + "minX": 5.22, + "minZ": 3.72, + "maxX": 5.645, + "maxZ": 4.145 + }, + { + "id": "shell__lowcorner_corner_bay_sw", + "minX": 2.635, + "minZ": 3.72, + "maxX": 3.06, + "maxZ": 4.145 + }, + { + "id": "shell__lowcorner_corner_bay_re", + "minX": 2.635, + "minZ": 2.78, + "maxX": 3.06, + "maxZ": 3.205 + }, + { + "id": "shell__west_buttress", + "minX": -5.66, + "minZ": -2.02, + "maxX": -5.56, + "maxZ": -1.34 + }, + { + "id": "shell__service_wall_0", + "minX": -5.22, + "minZ": -2.19, + "maxX": -5.135, + "maxZ": -1.85 + }, + { + "id": "shell__service_wall_1", + "minX": -4.185, + "minZ": -2.19, + "maxX": -3.045, + "maxZ": -1.85 + }, + { + "id": "shell__service_wall_2", + "minX": -1.705, + "minZ": -2.19, + "maxX": -0.195, + "maxZ": -1.85 + }, + { + "id": "shell__service_wall_3", + "minX": 1.145, + "minZ": -2.19, + "maxX": 2.655, + "maxZ": -1.85 + }, + { + "id": "shell__service_wall_4", + "minX": 3.995, + "minZ": -2.19, + "maxX": 5.22, + "maxZ": -1.85 + }, + { + "id": "shell__niche_bank_cheek_w", + "minX": -3.215, + "minZ": -2.32, + "maxX": -3.045, + "maxZ": -2.19 + }, + { + "id": "shell__niche_bank_cheek_e", + "minX": -1.705, + "minZ": -2.32, + "maxX": -1.535, + "maxZ": -2.19 + }, + { + "id": "shell__niche_bank_back", + "minX": -3.045, + "minZ": -2.45, + "maxX": -1.705, + "maxZ": -2.32 + }, + { + "id": "shell__niche_trade_cheek_w", + "minX": -0.365, + "minZ": -2.32, + "maxX": -0.195, + "maxZ": -2.19 + }, + { + "id": "shell__niche_trade_cheek_e", + "minX": 1.145, + "minZ": -2.32, + "maxX": 1.315, + "maxZ": -2.19 + }, + { + "id": "shell__niche_trade_back", + "minX": -0.195, + "minZ": -2.45, + "maxX": 1.145, + "maxZ": -2.32 + }, + { + "id": "shell__niche_assoc_cheek_w", + "minX": 2.485, + "minZ": -2.32, + "maxX": 2.655, + "maxZ": -2.19 + }, + { + "id": "shell__niche_assoc_cheek_e", + "minX": 3.995, + "minZ": -2.32, + "maxX": 4.165, + "maxZ": -2.19 + }, + { + "id": "shell__niche_assoc_back", + "minX": 2.655, + "minZ": -2.45, + "maxX": 3.995, + "maxZ": -2.32 + } + ], + "furniture": [ + { + "id": "fixture__dock_bank_fin_w", + "minX": -3.19, + "minZ": -1.85, + "maxX": -3.07, + "maxZ": -1.5 + }, + { + "id": "fixture__dock_bank_fin_e", + "minX": -1.68, + "minZ": -1.85, + "maxX": -1.56, + "maxZ": -1.5 + }, + { + "id": "fixture__dock_bank_drawer", + "minX": -3.475, + "minZ": -1.85, + "maxX": -3.075, + "maxZ": -1.6 + }, + { + "id": "fixture__dock_bank_ledge", + "minX": -1.705, + "minZ": -1.85, + "maxX": -1.245, + "maxZ": -1.51 + }, + { + "id": "fixture__dock_trade_leg_w", + "minX": -0.465, + "minZ": -1.66, + "maxX": -0.405, + "maxZ": -1.6 + }, + { + "id": "fixture__dock_trade_leg_e", + "minX": 1.355, + "minZ": -1.66, + "maxX": 1.415, + "maxZ": -1.6 + }, + { + "id": "fixture__dock_trade_shelf", + "minX": 1.225, + "minZ": -1.85, + "maxX": 1.625, + "maxZ": -1.55 + }, + { + "id": "fixture__dock_assoc_notice", + "minX": 2.135, + "minZ": -1.85, + "maxX": 2.715, + "maxZ": -1.76 + }, + { + "id": "fixture__dock_assoc_bench", + "minX": 3.955, + "minZ": -1.85, + "maxX": 4.495, + "maxZ": -1.45 + }, + { + "id": "fixture__vendor_counter", + "minX": -4.92, + "minZ": -1.12, + "maxX": -4.18, + "maxZ": 2.2 + }, + { + "id": "fixture__goods_counter", + "minX": -5.46, + "minZ": -1.19, + "maxX": -5.08, + "maxZ": 0.13 + }, + { + "id": "fixture__stock_ledge", + "minX": -5.46, + "minZ": 1.1, + "maxX": -5.08, + "maxZ": 2.34 + }, + { + "id": "fixture__water_basin", + "minX": 5.1, + "minZ": 1.43, + "maxX": 5.55, + "maxZ": 2.29 + }, + { + "id": "fixture__isolator_cabinet", + "minX": 5.32, + "minZ": -3.57, + "maxX": 5.55, + "maxZ": -3.23 + }, + { + "id": "fixture__trainer_table", + "minX": 3.5, + "minZ": 2.5, + "maxX": 4.9, + "maxZ": 2.92 + }, + { + "id": "fixture__trainer_credenza", + "minX": 3.1, + "minZ": 3.34, + "maxX": 3.86, + "maxZ": 3.66 + }, + { + "id": "fixture__boh_bench", + "minX": 4.21, + "minZ": -2.57, + "maxX": 5.25, + "maxZ": -2.2 + }, + { + "id": "fixture__boh_shelving", + "minX": -4.14, + "minZ": -2.57, + "maxX": -3.26, + "maxZ": -2.2 + }, + { + "id": "fixture__boh_switchgear", + "minX": 4.95, + "minZ": -3.55, + "maxX": 5.22, + "maxZ": -2.75 + }, + { + "id": "fixture__boh_filter_0", + "minX": -1.395, + "minZ": -2.575, + "maxX": -0.985, + "maxZ": -2.165 + }, + { + "id": "fixture__boh_filter_1", + "minX": -0.915, + "minZ": -2.575, + "maxX": -0.505, + "maxZ": -2.165 + }, + { + "id": "fixture__boh_valve_panel", + "minX": -0.3, + "minZ": -2.57, + "maxX": 0.04, + "maxZ": -2.45 + }, + { + "id": "fixture__boh_ladder", + "minX": -2.55, + "minZ": -3.61, + "maxX": -2.05, + "maxZ": -3.5 + }, + { + "id": "fixture__nook_bench", + "minX": 4.69, + "minZ": -1.2, + "maxX": 5.22, + "maxZ": 0.08 + }, + { + "id": "fixture__queue_rail", + "minX": -1.59, + "minZ": 1.51, + "maxX": 1.69, + "maxZ": 1.59 + }, + { + "id": "fixture__loggia_ledge", + "minX": -5.25, + "minZ": 3.33, + "maxX": -2.95, + "maxZ": 3.95 + }, + { + "id": "fixture__loggia_bench", + "minX": -3.75, + "minZ": 3.32, + "maxX": -2.65, + "maxZ": 3.76 + } + ], + "door": { + "node": "door_slide", + "closed": { + "id": "closed_door_panel", + "minX": -1.52, + "minZ": 2.66, + "maxX": 0.92, + "maxZ": 2.75 + } + }, + "interiorRegions": [ + { + "id": "hall", + "minX": -5.22, + "minZ": -1.85, + "maxX": 5.22, + "maxZ": 2.7800000000000002, + "floorTopY": 0.02 + }, + { + "id": "back_of_house", + "minX": -5.22, + "minZ": -3.6100000000000003, + "maxX": 5.22, + "maxZ": -2.32, + "floorTopY": 0.02 + }, + { + "id": "trainer_bay", + "minX": 3.06, + "minZ": 2.7800000000000002, + "maxX": 5.22, + "maxZ": 3.7199999999999998, + "floorTopY": 0.02 + } + ], + "sourceCollision": { + "file": "market_house.collision.json", + "sha256": "sha256:4fc7c186fc5dcb7da97e51939958bbf3fc1ebdfa88b90a91d6ce4ba0d74d0e4d", + "authored_box_count": 81, + "body_height_max_m": 1.85, + "body_height_box_count": 76, + "excluded_overhead_box_count": 5 + } +} diff --git a/client-3d/public/assets/world-items/valley_market_manifest.json b/client-3d/public/assets/world-items/valley_market_manifest.json new file mode 100644 index 00000000..6ed378ce --- /dev/null +++ b/client-3d/public/assets/world-items/valley_market_manifest.json @@ -0,0 +1,229 @@ +{ + "schema": "successor.runtime-building-manifest.v1", + "building": "valley_market", + "runtimeKey": "valley_market", + "label": "Valley Market", + "units": "m", + "front": "+Z", + "footprintCells": [ + 12, + 9 + ], + "footprint_max_m": [ + 11.4, + 8.55 + ], + "runtime_scale_at_12x9_cells": 1.0526315789473684, + "floorHeightM": 0.02, + "floorTopY": 0.02, + "exteriorEntry": { + "facing": "south", + "frontAxis": "+Z", + "doorNode": "door_slide", + "doorCells": [ + [ + 4, + 7 + ], + [ + 5, + 7 + ], + [ + 6, + 7 + ] + ] + }, + "door": { + "node": "door_slide", + "node_parent": null, + "movable_child": "door_slide__leaf", + "local_axis": "X", + "closed_local_x_m": 0, + "open_local_x_m": -2.6, + "travel_abs_m": 2.6, + "direction": "opens toward -X (asset west) in local space", + "clips": [ + "door_open", + "door_close" + ], + "clip_seconds": 0.8, + "fps": 30, + "frames": [ + 0, + 24 + ], + "leaf_size_m": [ + 2.44, + 2.52, + 0.09 + ], + "opening_size_m": [ + 2.2, + 2.4 + ], + "axisLocal": [ + -1, + 0, + 0 + ], + "slide_axis_local": [ + -1, + 0, + 0 + ], + "slide_distance_m": 2.6, + "facing": "south" + }, + "door_cells": [ + [ + 4, + 7 + ], + [ + 5, + 7 + ], + [ + 6, + 7 + ] + ], + "service_anchor_cells": { + "bank_terminal_civic": [ + 3, + 3 + ], + "trade_terminal": [ + 6, + 3 + ], + "pa_terminal": [ + 9, + 3 + ], + "trainer_npc": [ + 10, + 6 + ] + }, + "promised_clear_cells": { + "bank_terminal_civic": [ + 3, + 3 + ], + "trade_terminal": [ + 6, + 3 + ], + "pa_terminal": [ + 9, + 3 + ], + "trainer_npc": [ + 10, + 6 + ] + }, + "collisionProxy": { + "source": "valley_market", + "sidecar": "valley_market_collision.json", + "structuralOnly": false, + "includesFurniture": true, + "wallCount": 48, + "furnitureCount": 27 + }, + "cutaway": { + "faces": { + "front": "wall_front__", + "back": "wall_back__", + "left": "wall_left__", + "right": "wall_right__" + }, + "hide": [ + "roof__", + "wall_front__", + "wall_back__", + "wall_left__", + "wall_right__" + ], + "revealPrefixes": [ + "roof__", + "wall_front__", + "wall_back__", + "wall_left__", + "wall_right__" + ], + "keep": [ + "floor__", + "interior__" + ], + "stub_by_face": true + }, + "interiorRegions": [ + { + "id": "hall", + "minX": -5.22, + "minZ": -1.85, + "maxX": 5.22, + "maxZ": 2.7800000000000002, + "floorTopY": 0.02 + }, + { + "id": "back_of_house", + "minX": -5.22, + "minZ": -3.6100000000000003, + "maxX": 5.22, + "maxZ": -2.32, + "floorTopY": 0.02 + }, + { + "id": "trainer_bay", + "minX": 3.06, + "minZ": 2.7800000000000002, + "maxX": 5.22, + "maxZ": 3.7199999999999998, + "floorTopY": 0.02 + } + ], + "bbox_span_m": [ + 11.378, + 7.13, + 8.524 + ], + "tri_count": 75456, + "materials": [ + "MKT_Brass", + "MKT_Ceramic", + "MKT_Glass", + "MKT_LampCyan", + "MKT_LampKey", + "MKT_LampWarm", + "MKT_Plaster", + "MKT_RoofMetal", + "MKT_Screed", + "MKT_Seal", + "MKT_Sinter", + "MKT_Steel" + ], + "asset": { + "file": "valley_market.glb", + "bytes": 9724088, + "sha256": "sha256:1f4e5e0d52e4fe504dcd70ed7b6590e45dc5c2cb69781ac7bab7e530ca40a1a3", + "images": { + "image_count": 41, + "external_image_uri_count": 0 + } + }, + "source": { + "furnished_glb": "market_house_furnished.glb", + "furnished_sha256": "sha256:1f4e5e0d52e4fe504dcd70ed7b6590e45dc5c2cb69781ac7bab7e530ca40a1a3", + "generator": "tools/successor/assets/market-opus5-rebuild/src/build_market.py", + "generator_sha256": "sha256:917c2ee9756a4e0d88add914fd12d96081805482511badcc30db0dcc06977635", + "post_export": "src/post_gltf.mjs (occlusion wiring + unused-attribute prune)", + "git_rev": "751d6650", + "authored_clearance_check_count": 23, + "authored_clearance_issues": [] + } +} diff --git a/client-3d/src/assets/bootsCanvasAnkleFit.test.ts b/client-3d/src/assets/bootsCanvasAnkleFit.test.ts index 45d9ea23..89abe051 100644 --- a/client-3d/src/assets/bootsCanvasAnkleFit.test.ts +++ b/client-3d/src/assets/bootsCanvasAnkleFit.test.ts @@ -3,19 +3,29 @@ import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; /** - * Fixed-issue footwear runtime-fit contract (owner program 2026-07-18). + * Fixed-issue footwear runtime-fit contract. * - * boots_canvas_ankle is worn OVER under_bodysuit; the runtime replaces - * equipment materials with FRONT-SIDE-ONLY matcaps (equipmentMaterials.ts), - * so the boot must (1) fully enclose the bodysuit's foot region — not just - * the bare body — and (2) wind every face outward or it becomes invisible - * in-world even though Blender/doubleSided previews look perfect. Both - * failure modes shipped once; this test pins them. + * The bodysuit now terminates at the ankle and the boot owns the foot zones. + * Each sex therefore ships a boot authored from its promoted body: the shell + * must enclose that body's feet, retain the three dye materials with outward + * faces, and preserve smooth body-native weights so the shaft follows hard + * ankle folds instead of detaching as the former binary split did. */ const publicRoot = resolve(process.cwd(), "public"); -const BOOT = resolve(publicRoot, "assets/pawn-pack/equipment/Under/boots_canvas_ankle.glb"); -const SUIT = resolve(publicRoot, "assets/pawn-pack/equipment/Under/under_bodysuit.glb"); +const pawnPack = resolve(publicRoot, "assets/pawn-pack"); +const CASES = [ + { + label: "male", + body: resolve(pawnPack, "pawn_male.glb"), + boot: resolve(pawnPack, "equipment/Under/boots_canvas_ankle.glb"), + }, + { + label: "female", + body: resolve(pawnPack, "pawn_female.glb"), + boot: resolve(pawnPack, "equipment/Female/Under/boots_canvas_ankle.glb"), + }, +] as const; interface Gltf { meshes?: Array<{ name?: string; primitives: Array<{ attributes: Record; indices?: number; material?: number }> }>; @@ -102,107 +112,135 @@ function footRegionBounds(path: string, footBone: "foot_l" | "foot_r"): { min: n } describe("boots_canvas_ankle runtime fit contract", () => { - it("keeps the wardrobe material contract (3 dye-slot materials, doubleSided source)", () => { - const { gltf } = readGlb(BOOT); - const names = (gltf.materials ?? []).map((m) => m.name); - expect(names).toEqual(["boots_canvas_ankle_c0", "boots_canvas_ankle_c1", "boots_canvas_ankle_c3"]); - for (const material of gltf.materials ?? []) expect(material.doubleSided).toBe(true); - expect((gltf.meshes ?? []).map((m) => m.name)).toEqual(["boots_canvas_ankle_l", "boots_canvas_ankle_r"]); + it("keeps the wardrobe material and mesh contract on both authored variants", () => { + for (const variant of CASES) { + const { gltf } = readGlb(variant.boot); + const names = (gltf.materials ?? []).map((material) => material.name); + expect(names, variant.label).toEqual([ + "boots_canvas_ankle_c0", + "boots_canvas_ankle_c1", + "boots_canvas_ankle_c3", + ]); + for (const material of gltf.materials ?? []) { + expect(material.doubleSided, `${variant.label} ${material.name}`).toBe(true); + } + expect((gltf.meshes ?? []).map((mesh) => mesh.name), variant.label).toEqual([ + "boots_canvas_ankle_l", + "boots_canvas_ankle_r", + ]); + } }); - it("encloses the WORN bodysuit foot region (not just the bare body) on both feet", () => { - for (const [footBone, side] of [["foot_l", 1], ["foot_r", -1]] as const) { - const suit = footRegionBounds(SUIT, footBone); - const { gltf, bin } = readGlb(BOOT); - const meshIndex = footBone === "foot_l" ? 0 : 1; - const bootMin = [Infinity, Infinity, Infinity]; - const bootMax = [-Infinity, -Infinity, -Infinity]; - for (const prim of gltf.meshes![meshIndex]!.primitives) { - const positions = readAccessor(gltf, bin, prim.attributes.POSITION!); - for (const p of positions) { - for (let axis = 0; axis < 3; axis += 1) { - bootMin[axis] = Math.min(bootMin[axis]!, p[axis]!); - bootMax[axis] = Math.max(bootMax[axis]!, p[axis]!); + it("encloses each promoted body's foot region on both feet", () => { + for (const variant of CASES) { + const { gltf, bin } = readGlb(variant.boot); + for (const [footBone, meshIndex] of [["foot_l", 0], ["foot_r", 1]] as const) { + const body = footRegionBounds(variant.body, footBone); + expect(body.min.every(Number.isFinite), `${variant.label} ${footBone} body minimum`).toBe(true); + expect(body.max.every(Number.isFinite), `${variant.label} ${footBone} body maximum`).toBe(true); + + const bootMin = [Infinity, Infinity, Infinity]; + const bootMax = [-Infinity, -Infinity, -Infinity]; + for (const primitive of gltf.meshes![meshIndex]!.primitives) { + for (const position of readAccessor(gltf, bin, primitive.attributes.POSITION!)) { + for (let axis = 0; axis < 3; axis += 1) { + bootMin[axis] = Math.min(bootMin[axis]!, position[axis]!); + bootMax[axis] = Math.max(bootMax[axis]!, position[axis]!); + } } } + + const slack = 0.002; + expect(bootMin[0]!, `${variant.label} ${footBone} inner/outer x`).toBeLessThanOrEqual(body.min[0]! + slack); + expect(bootMax[0]!, `${variant.label} ${footBone} inner/outer x`).toBeGreaterThanOrEqual(body.max[0]! - slack); + expect(bootMin[1]!, `${variant.label} ${footBone} sole`).toBeLessThanOrEqual(body.min[1]!); + expect(bootMin[2]!, `${variant.label} ${footBone} heel`).toBeLessThanOrEqual(body.min[2]! + slack); + expect(bootMax[2]!, `${variant.label} ${footBone} toe`).toBeGreaterThanOrEqual(body.max[2]! - slack); } - // The boot shell must reach past the suit's foot cloud on every axis - // (except upward, where the leg exits the collar). 2 mm slack, BOTH - // X sides, BOTH feet — no side may degrade to a no-op. - void side; - const slack = 0.002; - expect(bootMin[0]!).toBeLessThanOrEqual(suit.min[0]! + slack); - expect(bootMax[0]!).toBeGreaterThanOrEqual(suit.max[0]! - slack); - expect(bootMin[1]!).toBeLessThanOrEqual(suit.min[1]!); // sole under-reach - expect(bootMin[2]!).toBeLessThanOrEqual(suit.min[2]! + slack); // heel - expect(bootMax[2]!).toBeGreaterThanOrEqual(suit.max[2]! - slack); // toe } }); - it("winds boot faces outward (runtime equipment materials are front-side only)", () => { - const { gltf, bin } = readGlb(BOOT); - for (const mesh of gltf.meshes ?? []) { - const centerX = mesh.name === "boots_canvas_ankle_l" ? 0.086 : -0.086; - for (const prim of mesh.primitives) { - const material = gltf.materials![prim.material ?? 0]!.name ?? ""; - const positions = readAccessor(gltf, bin, prim.attributes.POSITION!); - const indices = readAccessor(gltf, bin, prim.indices!).map((row) => row[0]!); - let outward = 0; - let total = 0; - for (let i = 0; i < indices.length; i += 3) { - const [a, b, c] = [positions[indices[i]!]!, positions[indices[i + 1]!]!, positions[indices[i + 2]!]!]; - const u = [b[0]! - a[0]!, b[1]! - a[1]!, b[2]! - a[2]!]; - const v = [c[0]! - a[0]!, c[1]! - a[1]!, c[2]! - a[2]!]; - const n = [u[1]! * v[2]! - u[2]! * v[1]!, u[2]! * v[0]! - u[0]! * v[2]!, u[0]! * v[1]! - u[1]! * v[0]!]; - const cx = (a[0]! + b[0]! + c[0]!) / 3; - const cy = (a[1]! + b[1]! + c[1]!) / 3; - const cz = (a[2]! + b[2]! + c[2]!) / 3; - // interior reference: shaft axis above the ankle, foot core below - const ref = cy > 0.09 ? [centerX, cy, 0.0] : [centerX, Math.min(cy + 0.02, 0.055), Math.max(-0.03, Math.min(cz, 0.17))]; - const d = [cx - ref[0]!, cy - ref[1]!, cz - ref[2]!]; - const dot = n[0]! * d[0]! + n[1]! * d[1]! + n[2]! * d[2]!; - total += 1; - if (dot > 0) outward += 1; + it("winds boot faces outward for the runtime's front-side materials", () => { + for (const variant of CASES) { + const { gltf, bin } = readGlb(variant.boot); + for (const [meshIndex, mesh] of (gltf.meshes ?? []).entries()) { + const footBone = meshIndex === 0 ? "foot_l" : "foot_r"; + const body = footRegionBounds(variant.body, footBone); + const centerX = (body.min[0]! + body.max[0]!) * 0.5; + for (const primitive of mesh.primitives) { + const material = gltf.materials![primitive.material ?? 0]!.name ?? ""; + const positions = readAccessor(gltf, bin, primitive.attributes.POSITION!); + const indices = readAccessor(gltf, bin, primitive.indices!).map((row) => row[0]!); + let outward = 0; + let total = 0; + for (let i = 0; i < indices.length; i += 3) { + const [a, b, c] = [ + positions[indices[i]!]!, + positions[indices[i + 1]!]!, + positions[indices[i + 2]!]!, + ]; + const u = [b[0]! - a[0]!, b[1]! - a[1]!, b[2]! - a[2]!]; + const v = [c[0]! - a[0]!, c[1]! - a[1]!, c[2]! - a[2]!]; + const normal = [ + u[1]! * v[2]! - u[2]! * v[1]!, + u[2]! * v[0]! - u[0]! * v[2]!, + u[0]! * v[1]! - u[1]! * v[0]!, + ]; + const cx = (a[0]! + b[0]! + c[0]!) / 3; + const cy = (a[1]! + b[1]! + c[1]!) / 3; + const cz = (a[2]! + b[2]! + c[2]!) / 3; + const reference = cy > 0.09 + ? [centerX, cy, 0] + : [centerX, Math.min(cy + 0.02, 0.055), Math.max(-0.03, Math.min(cz, 0.17))]; + const radial = [cx - reference[0]!, cy - reference[1]!, cz - reference[2]!]; + const dot = normal[0]! * radial[0]! + normal[1]! * radial[1]! + normal[2]! * radial[2]!; + total += 1; + if (dot > 0) outward += 1; + } + // c0/c3 include inward-facing cuff/outsole thickness. The exterior + // still has a clear outward majority; a fully inverted shell is ~0.5. + const threshold = material.endsWith("_c1") ? 0.8 : 0.7; + expect(outward / total, `${variant.label} ${mesh.name} ${material}`).toBeGreaterThan(threshold); } - // the interior liner sleeve (part of c1) legitimately faces inward, - // and a few heel-counter/lace faces sit oblique to this coarse ref - // heuristic; the contract pins "overwhelmingly outward" (the broken - // pre-fix dome measured ~0.5 on c1). - const threshold = material.endsWith("_c1") ? 0.8 : material.endsWith("_c3") ? 0.7 : 0.9; - // c3 (outsole slab): its top ring legitimately faces up into the - // hidden shell interior, which this coarse core-point heuristic - // counts as inward — the fully inverted pre-fix state measured ~0.5. - expect(outward / total, `${mesh.name} ${material} outward fraction`).toBeGreaterThan(threshold); } } }); - it("skins with the pack-native BINARY foot/calf split (gradient blends smear against the binary bodysuit and read as collapse)", () => { - const { gltf, bin } = readGlb(BOOT); - for (const mesh of gltf.meshes ?? []) { - for (const prim of mesh.primitives) { - const weights = readAccessor(gltf, bin, prim.attributes.WEIGHTS_0!); - for (const w of weights) { - const sorted = [...w].sort((a, b) => b - a); - expect(sorted[0]!).toBeGreaterThan(0.999); // dominant bone owns the vertex fully - expect(sorted[1]!).toBeLessThan(0.001); + it("preserves normalized smooth body-native skin weights", () => { + for (const variant of CASES) { + const { gltf, bin } = readGlb(variant.boot); + let blendedRows = 0; + for (const mesh of gltf.meshes ?? []) { + for (const primitive of mesh.primitives) { + const accessorIndex = primitive.attributes.WEIGHTS_0!; + const accessor = gltf.accessors[accessorIndex]!; + const scale = accessor.componentType === 5121 ? 255 : accessor.componentType === 5123 ? 65535 : 1; + for (const encoded of readAccessor(gltf, bin, accessorIndex)) { + const weights = encoded.map((weight) => weight / scale); + expect(weights.every((weight) => Number.isFinite(weight) && weight >= 0 && weight <= 1)).toBe(true); + expect(weights.reduce((sum, weight) => sum + weight, 0)).toBeCloseTo(1, 4); + const sorted = [...weights].sort((a, b) => b - a); + if (sorted[1]! > 0.01) blendedRows += 1; + } } } + expect(blendedRows, `${variant.label} blended ankle rows`).toBeGreaterThan(0); } }); - it("keeps c0 as the cuff trim ring only (simplified basic-boot contract: no layered foot panels)", () => { - const { gltf, bin } = readGlb(BOOT); - for (const mesh of gltf.meshes ?? []) { - for (const prim of mesh.primitives) { - const material = gltf.materials![prim.material ?? 0]!.name!; - if (!material.endsWith("_c0")) continue; - const positions = readAccessor(gltf, bin, prim.attributes.POSITION!); - expect(positions.length).toBeGreaterThan(20); - for (const p of positions) { - // the ONLY light-panel geometry is the collar trim band; anything - // lower reintroduces the layered-shell interleaving failure mode - expect(p[1]!).toBeGreaterThan(0.18); + it("keeps c0 confined to the authored 185-215 mm cuff band", () => { + for (const variant of CASES) { + const { gltf, bin } = readGlb(variant.boot); + for (const mesh of gltf.meshes ?? []) { + for (const primitive of mesh.primitives) { + const material = gltf.materials![primitive.material ?? 0]!.name!; + if (!material.endsWith("_c0")) continue; + const positions = readAccessor(gltf, bin, primitive.attributes.POSITION!); + expect(positions.length, `${variant.label} ${mesh.name} trim vertices`).toBeGreaterThan(20); + for (const position of positions) { + expect(position[1]!, `${variant.label} trim minimum`).toBeGreaterThan(0.18); + expect(position[1]!, `${variant.label} trim maximum`).toBeLessThan(0.225); + } } } } diff --git a/client-3d/src/assets/pawnPack.equipmentManifest.test.ts b/client-3d/src/assets/pawnPack.equipmentManifest.test.ts index 50c4ac5e..9b614c5e 100644 --- a/client-3d/src/assets/pawnPack.equipmentManifest.test.ts +++ b/client-3d/src/assets/pawnPack.equipmentManifest.test.ts @@ -36,6 +36,7 @@ describe("equipment manifest rigid-accessory contract", () => { expect(isPawnEquipmentManifestJson(manifestWithItem({ authorityItemId: 7203, rigidAnchorBone: "head" }))).toBe(true); // Both fields stay optional — classic skinned entries are untouched. expect(isPawnEquipmentManifestJson(manifestWithItem({}))).toBe(true); + expect(isPawnEquipmentManifestJson(manifestWithItem({ glbFemale: "Female/Under/Hat_Warm.glb" }))).toBe(true); }); it("rejects malformed authorityItemId (string, fraction, non-positive)", () => { @@ -49,6 +50,16 @@ describe("equipment manifest rigid-accessory contract", () => { expect(isPawnEquipmentManifestJson(manifestWithItem({ rigidAnchorBone: "" }))).toBe(false); expect(isPawnEquipmentManifestJson(manifestWithItem({ rigidAnchorBone: 12 }))).toBe(false); }); + + it("rejects malformed female variant paths", () => { + expect(isPawnEquipmentManifestJson(manifestWithItem({ glbFemale: 12 }))).toBe(false); + }); + + it("accepts optional string coverage arrays but rejects malformed entries", () => { + expect(isPawnEquipmentManifestJson(manifestWithItem({ hideBodyZones: ["torso", "not_a_zone"] }))).toBe(true); + expect(isPawnEquipmentManifestJson(manifestWithItem({ hideBodyZones: "torso" }))).toBe(false); + expect(isPawnEquipmentManifestJson(manifestWithItem({ hideBodyZones: ["torso", 3] }))).toBe(false); + }); }); describe("toPawnEquipmentItem (load-path mapping)", () => { @@ -65,6 +76,16 @@ describe("toPawnEquipmentItem (load-path mapping)", () => { authorityItemId: 7203, rigidAnchorBone: "head", }); + const femaleVariant = toPawnEquipmentItem({ + id: "under_tank", + name: "Tank", + layer: "Under", + group: "Torso", + slot: "under_tank", + glb: "Under/Tank.glb", + glbFemale: "Female/Under/Tank.glb", + }); + expect(femaleVariant.glbFemale).toBe("Female/Under/Tank.glb"); expect(item.authorityItemId).toBe(7203); expect(item.rigidAnchorBone).toBe("head"); expect(item.requires).toEqual([]); @@ -75,6 +96,18 @@ describe("toPawnEquipmentItem (load-path mapping)", () => { expect(classic.authorityItemId).toBeUndefined(); expect(classic.rigidAnchorBone).toBeUndefined(); }); + + it("keeps only canonical coverage zones and reports unknown declarations", () => { + const manifest = manifestWithItem({ hideBodyZones: ["torso", "not_a_zone", "left_hand"] }); + if (!isPawnEquipmentManifestJson(manifest)) throw new Error("coverage manifest guard rejected string array"); + const reported: string[] = []; + const item = toPawnEquipmentItem( + manifest.items[0]!, + (itemId, zone) => reported.push(`${itemId}:${zone}`), + ); + expect(item.hideBodyZones).toEqual(["torso", "left_hand"]); + expect(reported).toEqual(["hat_field_cap:not_a_zone"]); + }); }); describe("shipped equipment manifest — 7203 Field Cap entry", () => { diff --git a/client-3d/src/assets/pawnPack.ts b/client-3d/src/assets/pawnPack.ts index 8752a5ec..b7384ae4 100644 --- a/client-3d/src/assets/pawnPack.ts +++ b/client-3d/src/assets/pawnPack.ts @@ -10,7 +10,7 @@ // - Per-instance skinned bodies are produced with SkeletonUtils.clone(). // - The uniform scale constant that maps the cooked 1.7525 m body onto the // ~1.7-unit contract height lives here (ONE constant, applied at instance root). -import { AnimationClip, Bone, Group, Quaternion, Vector3 } from "three"; +import { AnimationClip, Bone, Group, Quaternion, SkinnedMesh, Vector3, type Object3D } from "three"; import { GLTFLoader, type GLTF } from "three/examples/jsm/loaders/GLTFLoader.js"; import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; import { SUCCESSOR_3D_CONFIG } from "../config"; @@ -19,13 +19,66 @@ import { gearDescriptionFor } from "../ui/inventory/itemCopy"; import { registerKnownGearIds, get as getStoredGearIds } from "../ui/inventory/equippedGearStore"; import { registerPawnPackEquipmentIds } from "../render/equipmentSlots"; import { WARDROBE_PIECES } from "./wardrobe.gen"; -import { toClipLayer } from "./pawnRigTypes"; +import { + toClipLayer, + type SlugthrowerAttachSpec, + type SupportArmSpec, + type TorsoYawRecipe, + type VibroswordAttachSpec, + type WeaponStowSpec, +} from "./pawnRigTypes"; import { requireRuntimePublicPath } from "@successor/client/src/slice-core/runtimePublicPaths"; export type PawnBody = "male" | "female"; export type ClipLayer = "base" | "upper" | "hand" | "montage" | "arm"; export type PawnEquipmentLayer = "Armor" | "Under"; +/** Canonical skin-coverage vocabulary shared by authored equipment and both runtimes. */ +export const PAWN_BODY_ZONES = [ + "torso", + "pelvis", + "neck", + "head", + "left_upper_arm", + "right_upper_arm", + "left_forearm", + "right_forearm", + "left_hand", + "right_hand", + "left_thigh", + "right_thigh", + "left_calf", + "right_calf", + "left_foot", + "right_foot", +] as const; +export type PawnBodyZone = (typeof PAWN_BODY_ZONES)[number]; +export type PawnBodyZoneMask = number; + +const pawnBodyZoneMaskByName: Readonly> = Object.freeze({ + torso: 1 << 0, + pelvis: 1 << 1, + neck: 1 << 2, + head: 1 << 3, + left_upper_arm: 1 << 4, + right_upper_arm: 1 << 5, + left_forearm: 1 << 6, + right_forearm: 1 << 7, + left_hand: 1 << 8, + right_hand: 1 << 9, + left_thigh: 1 << 10, + right_thigh: 1 << 11, + left_calf: 1 << 12, + right_calf: 1 << 13, + left_foot: 1 << 14, + right_foot: 1 << 15, +}); + +export function isPawnBodyZone(value: string): value is PawnBodyZone { + return Object.prototype.hasOwnProperty.call(pawnBodyZoneMaskByName, value); +} + + export interface PawnEquipmentPaletteZone { /** Zone key from the wardrobe taxonomy (e.g. "body", "straps"). */ key: string; @@ -44,8 +97,12 @@ export interface PawnEquipmentItem { group: string; slot: string; glb: string; + glbFemale?: string; mat?: string; requires: readonly string[]; + /** Segmented skin primitives this item fully encloses. Unknown manifest + * values are reported and omitted while the pack loads. */ + hideBodyZones?: readonly PawnBodyZone[]; /** Dye zones (wardrobe pieces only): player colors map onto atlas slots. */ palette?: { zones: readonly PawnEquipmentPaletteZone[] }; /** Asset-Lab-only items (fit trials, prototypes): shown in the viewer, @@ -70,12 +127,15 @@ export interface PawnEquipmentLookup { readonly itemIds: readonly string[]; /** Helmet ids in deterministic sort order, reused by remote/default resolution. */ readonly helmetIds: readonly string[]; + /** Item coverage bits, precomputed once when the equipment manifest loads. */ + readonly hideBodyZoneMaskById: ReadonlyMap; } export interface PawnEquipmentPack { basePath: string; items: readonly PawnEquipmentItem[]; scenes: ReadonlyMap; + femaleScenes?: ReadonlyMap; /** Precomputed equipment indexes. Legacy hand-built test packs may omit it. */ readonly lookup?: PawnEquipmentLookup; } @@ -87,11 +147,15 @@ export function buildPawnEquipmentLookup(equipment: PawnEquipmentPack): PawnEqui const availableIds = new Set(); const itemIds: string[] = []; const helmetIds: string[] = []; + const hideBodyZoneMaskById = new Map(); for (const item of equipment.items) { itemById.set(item.id, item); availableIds.add(item.id); itemIds.push(item.id); if (item.slot === "armor_helmet") helmetIds.push(item.id); + let hideBodyZoneMask = 0; + for (const zone of item.hideBodyZones ?? []) hideBodyZoneMask |= pawnBodyZoneMaskByName[zone]; + if (hideBodyZoneMask !== 0) hideBodyZoneMaskById.set(item.id, hideBodyZoneMask); } helmetIds.sort(); return Object.freeze({ @@ -99,6 +163,7 @@ export function buildPawnEquipmentLookup(equipment: PawnEquipmentPack): PawnEqui availableIds, itemIds: Object.freeze(itemIds), helmetIds: Object.freeze(helmetIds), + hideBodyZoneMaskById, }); } @@ -112,6 +177,62 @@ export function pawnEquipmentLookupFor(equipment: PawnEquipmentPack): PawnEquipm return lookup; } +/** Resolve the exact equipped set's segmented-skin coverage without creating + * a per-pawn Set. Both body sex variants use the same manifest vocabulary. */ +export function resolvePawnBodyZoneMask( + equipment: PawnEquipmentPack, + itemIds: readonly string[], + lookup: PawnEquipmentLookup = pawnEquipmentLookupFor(equipment), +): PawnBodyZoneMask { + let hiddenZones = 0; + for (let i = 0; i < itemIds.length; i += 1) { + hiddenZones |= lookup.hideBodyZoneMaskById.get(itemIds[i]!) ?? 0; + } + return hiddenZones; +} + +/** A segmented body primitive discovered once from an unmodified body clone. */ +export interface PawnBodyZoneMesh { + readonly mesh: SkinnedMesh; + readonly zoneMask: PawnBodyZoneMask; + readonly initialVisible: boolean; +} + +/** Cache only exact `BodyZone_` skinned body primitives. */ +export function collectPawnBodyZoneMeshes(bodyRoot: Object3D): PawnBodyZoneMesh[] { + const bodyZoneMeshes: PawnBodyZoneMesh[] = []; + bodyRoot.traverse((object) => { + if (!(object instanceof SkinnedMesh)) return; + const material = Array.isArray(object.material) + ? object.material.length === 1 ? object.material[0]! : null + : object.material; + if (!material) return; + const materialName = material.name; + if (!materialName.startsWith("BodyZone_")) return; + const zone = materialName.slice("BodyZone_".length); + if (!isPawnBodyZone(zone)) return; + bodyZoneMeshes.push({ + mesh: object, + zoneMask: pawnBodyZoneMaskByName[zone], + initialVisible: object.visible, + }); + }); + return bodyZoneMeshes; +} + +/** Apply a spawn/appearance-time coverage mask and restore zones no longer + * claimed by an equipped item. Apparel is never part of this cached list. */ +export function applyPawnBodyZoneMask( + bodyZoneMeshes: readonly PawnBodyZoneMesh[], + hiddenZones: PawnBodyZoneMask, +): void { + for (let i = 0; i < bodyZoneMeshes.length; i += 1) { + const bodyZoneMesh = bodyZoneMeshes[i]!; + bodyZoneMesh.mesh.visible = bodyZoneMesh.initialVisible + && (hiddenZones & bodyZoneMesh.zoneMask) === 0; + } +} + export interface PawnClipMeta { name: string; layer: ClipLayer; @@ -125,42 +246,15 @@ export interface PawnClipMeta { events: Record; } -export interface SlugthrowerAttachSpec { - /** hand_r-local mount transform — USER-TUNED, applied verbatim. */ - mountPos: Vector3; - mountQuat: Quaternion; - /** Sockets in weapon-GLB-local glTF space. */ - sockets: { - grip: Vector3; - foregrip: Vector3; - muzzle: Vector3; - stock: Vector3; - }; - nodes: { frame: string; mag?: string }; - scale?: number; - silhouetteClass?: string; -} - -export interface VibroswordAttachSpec { - /** hand_r-local mount transform — USER-TUNED, applied verbatim. */ - mountPos: Vector3; - mountQuat: Quaternion; - /** Sockets in vibrosword-GLB-local glTF space. */ - sockets: { - guardPlane: Vector3; - wrapTop: Vector3; - wrapMid: Vector3; - wrapBottom: Vector3; - pommel: Vector3; - }; - nodes: { frame: string }; -} - -export interface TorsoYawRecipe { - /** boneName -> weight (spine_01 .25, spine_03 .60, neck_01 .15). */ - weights: ReadonlyArray; - maxRad: number; -} +// The attach-spec contract lives in pawnRigTypes so the loader, the rigs and +// the Asset Lab all extend ONE definition; re-exported here because most +// consumers import the pack surface. +export type { + SlugthrowerAttachSpec, + VibroswordAttachSpec, + WeaponStowSpec, + TorsoYawRecipe, +} from "./pawnRigTypes"; export interface WeaponModel { scene: Group; @@ -272,6 +366,7 @@ interface VibroswordAttachJson { sockets: Record; nodes: { frame: string }; mount_hand_r_local: { pos: [number, number, number]; quat: [number, number, number, number] }; + stow_socket?: WeaponStowSocketJson; } interface PawnEquipmentManifestJson { @@ -282,12 +377,14 @@ interface PawnEquipmentManifestJson { group: string; slot: string; glb: string; + glbFemale?: string; mat?: string; requires?: string[]; palette?: { zones: Array<{ key: string; family: string; slots: string[]; default: string }> }; viewerOnly?: boolean; authorityItemId?: number; rigidAnchorBone?: string; + hideBodyZones?: string[]; }>; } @@ -381,7 +478,9 @@ export function isPawnEquipmentManifestJson(value: unknown): value is PawnEquipm && typeof item.group === "string" && typeof item.slot === "string" && typeof item.glb === "string" + && (item.glbFemale === undefined || typeof item.glbFemale === "string") && (item.requires === undefined || isStringArray(item.requires)) + && (item.hideBodyZones === undefined || isStringArray(item.hideBodyZones)) && (item.palette === undefined || isPaletteJson(item.palette)) && (item.authorityItemId === undefined || (typeof item.authorityItemId === "number" && Number.isInteger(item.authorityItemId) && item.authorityItemId > 0)) @@ -405,6 +504,18 @@ async function fetchOptionalEquipmentManifest(basePath: string): Promise(); + +function reportUnknownEquipmentHideBodyZone(itemId: string, zone: string): void { + const key = `${itemId}\u0000${zone}`; + if (warnedUnknownEquipmentHideBodyZones.has(key)) return; + warnedUnknownEquipmentHideBodyZones.add(key); + console.warn(`pawn pack: equipment "${itemId}" declares unknown hideBodyZones entry "${zone}"; ignoring it`); +} + async function loadEquipmentPack(loader: GLTFLoader, basePath: string): Promise { const manifest = await fetchOptionalEquipmentManifest(basePath); if (!manifest) { @@ -416,22 +527,40 @@ async function loadEquipmentPack(loader: GLTFLoader, basePath: string): Promise< // opts them into the transient pack via ?slotColorProof=1 so the same runtime // surfaces can prove a trial garment without making it regular wardrobe. const includeViewerOnly = includeViewerOnlyEquipment(); - const items = manifest.items - .filter((item) => includeViewerOnly || item.viewerOnly !== true) - .map(toPawnEquipmentItem); + const manifestItems = manifest.items.map((item) => toPawnEquipmentItem(item, reportUnknownEquipmentHideBodyZone)); + const items = manifestItems + .filter((item) => includeViewerOnly || item.viewerOnly !== true); const scenes = new Map(); + const femaleScenes = new Map(); await Promise.all(items.map(async (item) => { - const gltf = await loader.loadAsync(requireRuntimePublicPath(`${basePath}/${item.glb}`)); - scenes.set(item.id, gltf.scene); + const [baseGltf, femaleGltf] = await Promise.all([ + loader.loadAsync(requireRuntimePublicPath(`${basePath}/${item.glb}`)), + item.glbFemale + ? loader.loadAsync(requireRuntimePublicPath(`${basePath}/${item.glbFemale}`)) + : Promise.resolve(null), + ]); + scenes.set(item.id, baseGltf.scene); + if (femaleGltf) femaleScenes.set(item.id, femaleGltf.scene); })); - const equipment = { basePath, items, scenes }; + const equipment = { basePath, items, scenes, femaleScenes }; return { ...equipment, lookup: buildPawnEquipmentLookup(equipment) }; } /** Manifest JSON → runtime item. Exported for the manifest contract test: * dropping a field here (rigidAnchorBone especially) would silently demote a * rigid accessory to the SkinnedMesh route, which attaches nothing. */ -export function toPawnEquipmentItem(item: PawnEquipmentManifestJson["items"][number]): PawnEquipmentItem { +export function toPawnEquipmentItem( + item: PawnEquipmentManifestJson["items"][number], + reportUnknownBodyZone?: (itemId: string, zone: string) => void, +): PawnEquipmentItem { + const hideBodyZones: PawnBodyZone[] = []; + for (const zone of item.hideBodyZones ?? []) { + if (isPawnBodyZone(zone)) { + hideBodyZones.push(zone); + } else { + reportUnknownBodyZone?.(item.id, zone); + } + } return { id: item.id, name: item.name, @@ -439,8 +568,10 @@ export function toPawnEquipmentItem(item: PawnEquipmentManifestJson["items"][num group: item.group, slot: item.slot, glb: item.glb, + glbFemale: item.glbFemale, mat: item.mat, requires: item.requires ?? [], + ...(hideBodyZones.length > 0 ? { hideBodyZones: Object.freeze(hideBodyZones) } : {}), ...(item.palette ? { palette: { zones: item.palette.zones.map((zone) => ({ ...zone, slots: [...zone.slots] })) } } : {}), viewerOnly: item.viewerOnly, ...(item.authorityItemId !== undefined ? { authorityItemId: item.authorityItemId } : {}), @@ -525,12 +656,26 @@ export function buildWardrobeCatalog( return entries; } +interface WeaponStowSocketJson { + pos?: [number, number, number]; + rot_deg?: [number, number, number]; + arc_lift?: number; +} + +interface SupportArmJson { + min_elbow_bend_deg?: number; + shoulder_advance_max_m?: number; + elbow_pole_deg?: number; +} + interface WeaponAttachJson { sockets: Record; nodes: { frame: string; mag?: string }; mount_hand_r_local: { pos: [number, number, number]; quat: [number, number, number, number] }; scale_to_pawn?: number; silhouette_class?: string; + stow_socket?: WeaponStowSocketJson; + hold?: { resting_yaw_deg?: number; support_arm?: SupportArmJson }; } interface WeaponsManifestJson { @@ -549,8 +694,63 @@ function isWeaponsManifestJson(value: unknown): value is WeaponsManifestJson { && typeof it.class === "string"); } +function isTriple(value: unknown): value is [number, number, number] { + return Array.isArray(value) && value.length === 3 && value.every((n) => typeof n === "number" && Number.isFinite(n)); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +/** Hard rails on the authored hold posture: an arm, not a pretzel. */ +const MAX_SUPPORT_BEND_DEG = 80; +const MAX_SHOULDER_ADVANCE_M = 0.12; + +/** + * `hold.support_arm` is only honoured when it carries BOTH a bend floor and a + * pole angle: together they are the posture, and rolling an elbow with nothing + * holding the bend would spin a straight arm about its own axis. A partial or + * malformed block falls through to the legacy unposed solve, the same way + * `stow_socket` falls through to the class default. Values are clamped to + * anatomical rails so a bad edit cannot dislocate the shoulder. + */ +function parseSupportArm(json: SupportArmJson | undefined): SupportArmSpec | undefined { + if (!json || !isFiniteNumber(json.min_elbow_bend_deg) || !isFiniteNumber(json.elbow_pole_deg)) { + return undefined; + } + const advance = json.shoulder_advance_max_m; + const toRad = Math.PI / 180; + return { + minBendRad: Math.min(MAX_SUPPORT_BEND_DEG, Math.max(0, json.min_elbow_bend_deg)) * toRad, + poleRad: json.elbow_pole_deg * toRad, + shoulderAdvanceMaxM: isFiniteNumber(advance) + ? Math.min(MAX_SHOULDER_ADVANCE_M, Math.max(0, advance)) + : 0, + }; +} + +/** + * `stow_socket` is only honoured when it carries BOTH a position and a + * rotation. A partial or malformed block falls through to the class default + * rather than half-applying, so a bad edit can never silently produce a + * weapon floating at the spine origin. + */ +function parseStowSocket(json: WeaponStowSocketJson | undefined, fallbackArcLift: number): WeaponStowSpec | undefined { + if (!json || !isTriple(json.pos) || !isTriple(json.rot_deg)) return undefined; + return { + pos: toVector3(json.pos), + rotDeg: toVector3(json.rot_deg), + arcLift: isFiniteNumber(json.arc_lift) ? json.arc_lift : fallbackArcLift, + }; +} + function parseWeaponAttach(json: WeaponAttachJson, silhouetteClass: string): SlugthrowerAttachSpec { const s = json.sockets; + const melee = (json.silhouette_class ?? silhouetteClass) === "melee"; + const stowDefaults = melee + ? SUCCESSOR_3D_CONFIG.pawnPack.swordStow + : SUCCESSOR_3D_CONFIG.pawnPack.weaponStow; + const restingYawDeg = json.hold?.resting_yaw_deg; return { mountPos: toVector3(json.mount_hand_r_local.pos), mountQuat: new Quaternion(...json.mount_hand_r_local.quat), @@ -559,10 +759,14 @@ function parseWeaponAttach(json: WeaponAttachJson, silhouetteClass: string): Slu foregrip: toVector3(s.foregrip ?? s.grip ?? [0, 0, 0.25]), muzzle: toVector3(s.muzzle ?? [0, 0, 0.44]), stock: toVector3(s.stock ?? [0, 0, -0.41]), + foregripContact: isTriple(s.foregrip_contact) ? toVector3(s.foregrip_contact) : undefined, }, nodes: { frame: json.nodes.frame, mag: json.nodes.mag }, scale: json.scale_to_pawn ?? 1, silhouetteClass: json.silhouette_class ?? silhouetteClass, + stow: parseStowSocket(json.stow_socket, stowDefaults.arcLift), + restingYawRad: isFiniteNumber(restingYawDeg) ? restingYawDeg * (Math.PI / 180) : undefined, + supportArm: parseSupportArm(json.hold?.support_arm), }; } @@ -724,6 +928,7 @@ export async function loadPawnPack(): Promise { pommel: toVector3(vibroswordAttach.sockets.pommel ?? [0, 0, -0.39]), }, nodes: vibroswordAttach.nodes, + stow: parseStowSocket(vibroswordAttach.stow_socket, SUCCESSOR_3D_CONFIG.pawnPack.swordStow.arcLift), }; return { bodies: { male: maleGltf.scene, female: femaleGltf.scene }, @@ -747,11 +952,11 @@ export async function loadPawnPack(): Promise { /** Clone a skinned pawn body for one actor instance (bones + skinned mesh rebind). */ export function clonePawnBody(pack: PawnPack, body: PawnBody, options: PawnBodyCloneOptions = {}): Group { const source = options.bare ? pack.bareBodies?.[body] ?? pack.bodies[body] : pack.bodies[body]; - const root = cloneSkeleton(source); - if (root instanceof Group) return root; - const group = new Group(); - group.add(root); - return group; + const cloned = cloneSkeleton(source); + const root = cloned instanceof Group ? cloned : new Group(); + if (cloned !== root) root.add(cloned); + root.userData.successorPawnBody = body; + return root; } /** Clone a non-player humanoid that is rig-compatible with the shared pack. */ diff --git a/client-3d/src/assets/pawnRigTypes.ts b/client-3d/src/assets/pawnRigTypes.ts index 8ac24842..eaa9c59c 100644 --- a/client-3d/src/assets/pawnRigTypes.ts +++ b/client-3d/src/assets/pawnRigTypes.ts @@ -18,6 +18,7 @@ export interface PawnEquipmentItem { group: string; slot: string; glb: string; + glbFemale?: string; mat?: string; requires: readonly string[]; /** Asset-Lab-only items: visible in the viewer, never in the game wardrobe. */ @@ -28,6 +29,7 @@ export interface PawnEquipmentPack { basePath: string; items: readonly PawnEquipmentItem[]; scenes: ReadonlyMap; + femaleScenes?: ReadonlyMap; } export interface PawnClipMeta { @@ -43,6 +45,46 @@ export interface PawnClipMeta { events: Record; } +/** + * Per-model out-of-combat carry, authored in the weapon's own attach json as + * `stow_socket`. Dissimilar geometry (a +Z-blade cleaver vs a +Y-blade + * machete, a bullpup vs a long-tailed LMG) cannot share one carry transform, + * so each model brings its own. Absent -> the rig falls back to the legacy + * class default in config, which is what keeps the legacy Slugthrower and + * Vibrosword byte-identical in pose. + */ +export interface WeaponStowSpec { + /** spine_03-local socket position. */ + pos: Vector3; + /** spine_03-local XYZ Euler, degrees. */ + rotDeg: Vector3; + /** Peak of the world-space draw/sheath arc, metres. */ + arcLift: number; +} + +/** + * Per-model SUPPORT-ARM hold posture, authored in the weapon's own attach json + * as `hold.support_arm`. A support socket that sits beyond the arm's own + * reach (upperarm + lowerarm) makes the two-bone solver clamp to a locked + * straight arm: the elbow collapses onto the shoulder->wrist line and the limb + * becomes a rod laid along the weapon. This block tells the solver to keep a + * minimum elbow bend, buy back the reach that bend costs by swinging the + * shoulder girdle toward the target, and roll the retained bend to a chosen + * pole angle. Absent -> the legacy unposed solve, unchanged. + */ +export interface SupportArmSpec { + /** Smallest elbow bend the hold may leave, radians away from straight. */ + minBendRad: number; + /** Metres the clavicle may swing the shoulder toward an out-of-reach target. */ + shoulderAdvanceMaxM: number; + /** + * Elbow roll about the shoulder->wrist axis, radians, measured from + * world-down and positive toward `axis x down` (anatomically outboard for + * the support arm). The accepted legacy hold sits near +0.6 rad. + */ + poleRad: number; +} + export interface SlugthrowerAttachSpec { /** hand_r-local mount transform — USER-TUNED, applied verbatim. */ mountPos: Vector3; @@ -53,12 +95,25 @@ export interface SlugthrowerAttachSpec { foregrip: Vector3; muzzle: Vector3; stock: Vector3; + /** + * Weapon-local SUPPORT-WRIST target. Authored per model because the + * global foregrip contact offset assumes the legacy handguard radius and + * scale — on a fat, scaled, or caged handguard it buries the wrist inside + * the shroud. Absent -> foregrip + the global contact offset (legacy). + */ + foregripContact?: Vector3; }; nodes: { frame: string; mag?: string }; /** Uniform scale-to-pawn baked into the weaponRoot (slugthrower=1). */ scale?: number; /** silhouette class (pistol/smg/rifle/shotgun/launcher/melee) for stow/pose selection. */ silhouetteClass?: string; + /** Authored back carry; absent -> config.pawnPack.weaponStow. */ + stow?: WeaponStowSpec; + /** Non-aiming bore yaw offset, radians; absent -> config boreLevel.restingYawRad. */ + restingYawRad?: number; + /** Authored support-arm hold posture; absent -> the legacy unposed solve. */ + supportArm?: SupportArmSpec; } export interface VibroswordAttachSpec { @@ -74,6 +129,8 @@ export interface VibroswordAttachSpec { pommel: Vector3; }; nodes: { frame: string }; + /** Authored back carry; absent -> config.pawnPack.swordStow. */ + stow?: WeaponStowSpec; } export interface TorsoYawRecipe { diff --git a/client-3d/src/assets/stenKilnPromotion.test.ts b/client-3d/src/assets/stenKilnPromotion.test.ts index a5d942fa..d6e24976 100644 --- a/client-3d/src/assets/stenKilnPromotion.test.ts +++ b/client-3d/src/assets/stenKilnPromotion.test.ts @@ -6,7 +6,7 @@ import itemModels from "../ui/inventory/itemModels.json"; import { weaponModelAssetKey } from "./weaponModelRegistry"; type WeaponManifest = { - items: Array<{ id: string; glb: string; attach: string; class: string; track: string; tier_hint: string }>; + items: Array<{ id: string; item_ids?: number[]; glb: string; attach: string; class: string; track: string; tier_hint: string }>; }; type Attach = { @@ -39,22 +39,53 @@ function glbMeshCount(glbPath: string): number { return gltf.meshes?.length ?? 0; } -describe("approved-weapon cutover: STEN Mk II + Kiln Energy Cell", () => { - it("keeps only 3111/3112 from the old 3110-3117 ranged item band and maps them to the custom GLBs", () => { - expect(itemModels["3111"]).toBe("/assets/pawn-pack/weapons/custom/wpn_smg_sten_mk2.glb"); - expect(itemModels["3112"]).toBe("/assets/pawn-pack/weapons/custom/wpn_carbine_kiln.glb"); - expect(itemModels["3101"]).toBe("/assets/pawn-pack/weapons/custom/wpn_smg_sten_mk2.glb"); - expect(itemModels["3121"]).toBe("/assets/pawn-pack/weapons/custom/lightning_carbine.glb"); +describe("concrete weapon inventory catalog", () => { + it("maps every concrete ranged inventory id to its authored preview while leaving retired ids absent", () => { + const modelPaths = [ + [3101, "/assets/pawn-pack/weapons/custom/wpn_smg_sten_mk2.glb"], + [3111, "/assets/pawn-pack/weapons/custom/wpn_smg_sten_mk2.glb"], + [3112, "/assets/pawn-pack/weapons/custom/wpn_carbine_kiln.glb"], + [3121, "/assets/pawn-pack/weapons/custom/lightning_carbine.glb"], + [3122, "/assets/pawn-pack/weapons/custom/wpn_pistol_badge_bolt.glb"], + [3123, "/assets/pawn-pack/weapons/custom/wpn_rifle_slagrail_vanguard.glb"], + [3124, "/assets/pawn-pack/weapons/custom/wpn_shotgun_coilgate_scatter.glb"], + [3125, "/assets/pawn-pack/weapons/custom/wpn_carbine_kiln.glb"], + [3126, "/assets/pawn-pack/weapons/custom/wpn_heavy_bastion_lmg.glb"], + [3127, "/assets/pawn-pack/weapons/custom/wpn_launcher_flare_net.glb"], + ] as const; + for (const [itemId, modelPath] of modelPaths) { + expect((itemModels as Readonly>)[String(itemId)]).toBe(modelPath); + } + for (const [modelKey, itemId] of [ + ["wpn_pistol", 3122], + ["wpn_assault", 3123], + ["wpn_shotgun", 3124], + ["wpn_sniper", 3125], + ["wpn_heavy", 3126], + ["wpn_launcher", 3127], + ] as const) { + expect(weapon(modelKey).item_ids).toEqual([itemId]); + } for (const id of REMOVED_ITEM_IDS) { expect(itemModels).not.toHaveProperty(String(id)); } }); - it("resolves only the approved item ids through the weapon registry; removed ids fall back to nothing", () => { - expect(weaponModelAssetKey(3111, "slugthrower")).toBe("wpn_smg"); - expect(weaponModelAssetKey(3112, "slugthrower")).toBe("wpn_carbine"); - expect(weaponModelAssetKey(3101, "slugthrower")).toBe("wpn_smg"); - expect(weaponModelAssetKey(3121, "lightning-carbine")).toBe("lightning_carbine"); + it("resolves every concrete ranged item through the weapon registry and leaves retired ids unmapped", () => { + for (const [itemId, weaponId, modelKey] of [ + [3101, "slugthrower", "wpn_smg"], + [3111, "wpn-smg", "wpn_smg"], + [3112, "wpn-carbine", "wpn_carbine"], + [3121, "lightning-carbine", "lightning_carbine"], + [3122, "wpn-pistol", "wpn_pistol"], + [3123, "wpn-assault", "wpn_assault"], + [3124, "wpn-shotgun", "wpn_shotgun"], + [3125, "wpn-sniper", "wpn_sniper"], + [3126, "wpn-heavy", "wpn_heavy"], + [3127, "wpn-launcher", "wpn_launcher"], + ] as const) { + expect(weaponModelAssetKey(itemId, weaponId)).toBe(modelKey); + } for (const id of REMOVED_ITEM_IDS) { expect(weaponModelAssetKey(id, "slugthrower")).toBeNull(); } diff --git a/client-3d/src/assets/weaponAttachSchema.test.ts b/client-3d/src/assets/weaponAttachSchema.test.ts new file mode 100644 index 00000000..1d93e26a --- /dev/null +++ b/client-3d/src/assets/weaponAttachSchema.test.ts @@ -0,0 +1,163 @@ +// weaponAttachSchema.test.ts — the authored attach packets are the contract the +// Three runtime AND the native port both read. These assert the data itself, so +// a placeholder re-appearing (or a socket going missing) fails here rather than +// on someone's screen. +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { Euler, Quaternion, Vector3 } from "three"; +import { describe, expect, it } from "vitest"; + +const PACK = join(__dirname, "../../public/assets/pawn-pack"); +const CUSTOM = join(PACK, "weapons/custom"); + +interface StowSocket { + space?: string; + pos?: number[]; + rot_deg?: number[]; + arc_lift?: number; +} +interface AttachJson { + weapon?: string; + silhouette_class?: string; + sockets?: Record; + stow_socket?: StowSocket; + hold?: { + resting_yaw_deg?: number; + support_arm?: { + min_elbow_bend_deg?: number; + shoulder_advance_max_m?: number; + elbow_pole_deg?: number; + }; + }; + mount_hand_r_local?: { pos: number[]; quat: number[] }; +} + +function read(file: string): AttachJson { + return JSON.parse(readFileSync(file, "utf8")) as AttachJson; +} + +const attachFiles = [ + join(PACK, "slugthrower_attach.json"), + join(PACK, "vibrosword_attach.json"), + ...readdirSync(CUSTOM).filter((f) => f.endsWith("_attach.json")).map((f) => join(CUSTOM, f)), +]; + +/** The placeholder every packet used to carry, copied from the rifle contract. */ +const PLACEHOLDER_POS = [0.16, 0, -0.14]; +const PLACEHOLDER_ROT = [85, -45, 0]; + +describe("weapon attach packets", () => { + it("every weapon authors a concrete stow socket", () => { + // The native port reads stow_socket directly; a class fallback is invisible + // to it, so "missing" is a real interop break, not a cosmetic gap. + for (const file of attachFiles) { + const doc = read(file); + const stow = doc.stow_socket; + expect(stow, `${file} has no stow_socket`).toBeDefined(); + expect(stow!.space).toBe("spine_03_local"); + expect(stow!.pos, `${file} stow pos`).toHaveLength(3); + expect(stow!.rot_deg, `${file} stow rot_deg`).toHaveLength(3); + expect(typeof stow!.arc_lift).toBe("number"); + } + }); + + it("no melee model still carries the rifle placeholder carry", () => { + for (const file of attachFiles) { + const doc = read(file); + if (doc.silhouette_class !== "melee") continue; + expect( + doc.stow_socket!.pos!.every((v, i) => v === PLACEHOLDER_POS[i]) + && doc.stow_socket!.rot_deg!.every((v, i) => v === PLACEHOLDER_ROT[i]), + `${file} still holds the placeholder rifle stow socket`, + ).toBe(false); + } + }); + + it("keeps the legacy Slugthrower and Vibrosword carries at their accepted values", () => { + const slug = read(join(PACK, "slugthrower_attach.json")).stow_socket!; + expect(slug.pos).toEqual([0.16, 0, -0.14]); + expect(slug.rot_deg).toEqual([85, -45, 0]); + + const vibro = read(join(PACK, "vibrosword_attach.json")).stow_socket!; + expect(vibro.pos).toEqual([-0.04, 0.25, -0.135]); + expect(vibro.rot_deg).toEqual([91, 9, -93]); + }); + + it("carries the three melee_early models blade-down, not across the neck", () => { + // This family's blade axis is local +Y. The shipped defect was a carry + // that left that axis horizontal (measured +9.8 deg, tip out past the + // shoulder at neck height); the accepted Vibrosword rakes down at -80. + const D2R = Math.PI / 180; + for (const id of ["scrapline_machete", "field_saber", "quarry_chopper"]) { + const stow = read(join(CUSTOM, `${id}_attach.json`)).stow_socket!; + const [rx, ry, rz] = stow.rot_deg as [number, number, number]; + const quat = new Quaternion().setFromEuler(new Euler(rx * D2R, ry * D2R, rz * D2R, "XYZ")); + const blade = new Vector3(0, 1, 0).applyQuaternion(quat); + expect(blade.y, `${id} blade must rake downward in spine-local space`).toBeLessThan(-0.85); + + // Flat against the back: the thickness axis (+Z) must point roughly + // along the back normal, not sideways, or the blade carries edge-on. + const face = new Vector3(0, 0, 1).applyQuaternion(quat); + expect(Math.abs(face.z), `${id} must lie flat on the back`).toBeGreaterThan(0.9); + } + }); + + it("gives the plasma hilt its own weld instead of the vibrosword's blade-origin offset", () => { + const doc = read(join(CUSTOM, "plasma_hilt_attach.json")); + expect(doc.weapon).toBe("plasma_sword"); + // The hilt's grip midpoint is near its own origin; the Vibrosword's is + // 0.1525 m up the blade. The two mounts must therefore NOT be equal. + const vibro = read(join(PACK, "vibrosword_attach.json")); + expect(doc.mount_hand_r_local!.pos).not.toEqual(vibro.mount_hand_r_local!.pos); + // Same family frame, so the rotation IS shared verbatim. + expect(doc.mount_hand_r_local!.quat).toEqual(vibro.mount_hand_r_local!.quat); + expect(doc.sockets!.emitter).toEqual([0, 0, 0.09]); + }); + + it("authors a support-wrist contact for every gun whose shroud is not the legacy radius", () => { + for (const id of [ + "wpn_shotgun_coilgate_scatter", "wpn_heavy_bastion_lmg", "wpn_launcher_flare_net", + "wpn_rifle_slagrail_vanguard", "lightning_carbine", "wpn_smg_sten_mk2", "wpn_pistol_badge_bolt", + ]) { + const sockets = read(join(CUSTOM, `${id}_attach.json`)).sockets!; + const contact = sockets.foregrip_contact; + expect(contact, `${id} needs an authored support contact`).toHaveLength(3); + // Support hand rides UNDER the bore, never on or above it. + expect(contact![1]).toBeLessThan(0); + } + }); + + it("authors a resting yaw only where a long tail needed one", () => { + for (const id of ["lightning_carbine", "wpn_heavy_bastion_lmg"]) { + const hold = read(join(CUSTOM, `${id}_attach.json`)).hold; + expect(hold?.resting_yaw_deg, `${id} needs an authored resting yaw`).toBeGreaterThan(10); + } + // The passing models must NOT have been retuned. + for (const id of ["wpn_carbine_kiln", "wpn_smg_sten_mk2"]) { + expect(read(join(CUSTOM, `${id}_attach.json`)).hold).toBeUndefined(); + } + expect(read(join(PACK, "slugthrower_attach.json")).hold).toBeUndefined(); + }); + + it("authors a support-arm hold only on the two guns whose contact is past the arm", () => { + // Support arm reach on this skeleton is upperarm 0.325 + lowerarm 0.259 = + // 0.584 m. These two models put their support contact 0.586-0.658 m from + // the shoulder, so the solver ran out of arm and locked it straight. + for (const id of ["wpn_shotgun_coilgate_scatter", "wpn_launcher_flare_net"]) { + const arm = read(join(CUSTOM, `${id}_attach.json`)).hold?.support_arm; + expect(arm, `${id} needs an authored support-arm hold`).toBeDefined(); + // Both halves or the block is ignored by both runtimes. + expect(arm!.min_elbow_bend_deg).toBeGreaterThan(0); + expect(arm!.min_elbow_bend_deg).toBeLessThanOrEqual(80); + expect(typeof arm!.elbow_pole_deg).toBe("number"); + // The girdle assist is a scapula, not a dislocation. + expect(arm!.shoulder_advance_max_m!).toBeGreaterThan(0); + expect(arm!.shoulder_advance_max_m!).toBeLessThanOrEqual(0.12); + } + // Every model that already posed correctly keeps the legacy unposed solve. + for (const file of attachFiles) { + if (/wpn_shotgun_coilgate_scatter|wpn_launcher_flare_net/.test(file)) continue; + expect(read(file).hold?.support_arm, `${file} must not have been retuned`).toBeUndefined(); + } + }); +}); diff --git a/client-3d/src/assets/weaponModelRegistry.ts b/client-3d/src/assets/weaponModelRegistry.ts index 0422d6c5..7eab668d 100644 --- a/client-3d/src/assets/weaponModelRegistry.ts +++ b/client-3d/src/assets/weaponModelRegistry.ts @@ -5,12 +5,19 @@ */ const WEAPON_MODEL_BY_ITEM: Readonly> = { 3101: "wpn_smg", + 3104: "plasma_sword", 3105: "scrapline_machete", 3106: "field_saber", 3107: "quarry_chopper", 3111: "wpn_smg", 3112: "wpn_carbine", 3121: "lightning_carbine", + 3122: "wpn_pistol", + 3123: "wpn_assault", + 3124: "wpn_shotgun", + 3125: "wpn_sniper", + 3126: "wpn_heavy", + 3127: "wpn_launcher", }; const WEAPON_MODEL_BY_WEAPON_ID: Readonly> = { diff --git a/client-3d/src/render/anim/twoBoneIk.ts b/client-3d/src/render/anim/twoBoneIk.ts index 364ded22..266091ad 100644 --- a/client-3d/src/render/anim/twoBoneIk.ts +++ b/client-3d/src/render/anim/twoBoneIk.ts @@ -6,9 +6,21 @@ // so the wrist lands on the target. Applied post-mixer; caller must have // world matrices up to date before solving and must refresh the arm subtree's // matrices afterwards (solve() does this for the three chain bones). +// +// An optional per-model SupportArmSpec layers a HOLD POSTURE on that base +// solve. It exists because a support socket authored beyond the arm's reach +// (a + b) makes the base solve clamp to a locked-straight arm — the elbow +// collapses onto the shoulder->wrist line and the limb turns into a rod lying +// along the weapon. The posture keeps a floor under the elbow bend, pays for +// the reach that bend costs with a bounded shoulder-girdle swing, and rolls +// the retained bend to an authored pole angle. Absent -> nothing changes. import { Bone, Quaternion, Vector3 } from "three"; +import type { SupportArmSpec } from "../../assets/pawnRigTypes"; const EPSILON = 1e-5; +/** 1 mm, squared: below this the pole plane is numerical noise, not a pose. */ +const POLE_PLANE_EPSILON = 1e-6; +const TAU = Math.PI * 2; const shoulderPos = new Vector3(); const elbowPos = new Vector3(); @@ -20,6 +32,13 @@ const hingeAxis = new Vector3(); const newWrist = new Vector3(); const fromDir = new Vector3(); const toDir = new Vector3(); +const rootPos = new Vector3(); +const girdleVec = new Vector3(); +const advanceAxis = new Vector3(); +const poleAxis = new Vector3(); +const poleRef = new Vector3(); +const poleSide = new Vector3(); +const poleVec = new Vector3(); const parentWorldQuat = new Quaternion(); const worldRotation = new Quaternion(); const localRotation = new Quaternion(); @@ -39,18 +58,55 @@ function applyWorldRotation(bone: Bone, rotation: Quaternion, weight: number): v } export class TwoBoneIk { + /** Girdle local rotation before the last swing, and the swing's result. */ + private readonly girdleRestPose = new Quaternion(); + private readonly girdleSwungPose = new Quaternion(); + private girdleSwung = false; + constructor( private readonly upper: Bone, private readonly lower: Bone, private readonly end: Bone, + /** + * Shoulder girdle bone feeding `upper` (clavicle). Optional: it is only + * ever touched by a hold posture that needs reach the arm does not have. + */ + private readonly root: Bone | null = null, ) {} + /** + * Undo any outstanding girdle swing. The swing lands on a bone the base + * clips may not animate, so the mixer will not wipe it: every frame that + * does not re-solve — a stow, an "off" ik mode, a disposed rig — has to hand + * the shoulder back or the pawn keeps a shrugged girdle. If the bone no + * longer holds what we wrote, a clip has since claimed it and wins. + */ + relax(): void { + const root = this.root; + if (!this.girdleSwung || root === null) return; + this.girdleSwung = false; + if (!root.quaternion.equals(this.girdleSwungPose)) return; + root.quaternion.copy(this.girdleRestPose); + root.updateWorldMatrix(false, true); + } + /** * Pull the end bone's origin onto `targetWorld`. * @param weight 0..1 blend between the animated pose and the solved pose. + * @param posture optional per-model hold posture; null is the legacy solve. + * @param postureWeight 0..1 strength of `posture`. The caller fades this with + * the hold itself, so a reload reach hands the elbow back to the animation + * instead of pinning it at the weapon's pole angle. */ - solve(targetWorld: Vector3, weight: number): void { + solve( + targetWorld: Vector3, + weight: number, + posture: SupportArmSpec | null = null, + postureWeight: number = weight, + ): void { + this.relax(); if (weight <= 0) return; + const hold = posture !== null && postureWeight > EPSILON ? posture : null; this.upper.getWorldPosition(shoulderPos); this.lower.getWorldPosition(elbowPos); this.end.getWorldPosition(wristPos); @@ -61,8 +117,25 @@ export class TwoBoneIk { const b = lowerVec.length(); if (a < EPSILON || b < EPSILON) return; + // Longest wrist reach this solve may ask for. Legacy: a hair short of a + // locked-straight arm. Under a hold posture: the chord that still leaves + // `minBendRad` at the elbow, so the limb can never collapse onto its axis. + let reachLimit = a + b - EPSILON; + if (hold !== null) { + const bendFloor = hold.minBendRad * postureWeight; + const bendLimit = Math.sqrt(a * a + b * b - 2 * a * b * Math.cos(Math.PI - bendFloor)); + if (bendLimit < reachLimit) reachLimit = bendLimit; + this.advanceShoulder(targetWorld, reachLimit, hold, postureWeight); + // The girdle swing moved the shoulder — re-read the chain it carries. + this.upper.getWorldPosition(shoulderPos); + this.lower.getWorldPosition(elbowPos); + this.end.getWorldPosition(wristPos); + upperVec.subVectors(elbowPos, shoulderPos); + lowerVec.subVectors(wristPos, elbowPos); + } + toTarget.subVectors(targetWorld, shoulderPos); - const reach = Math.min(Math.max(toTarget.length(), Math.abs(a - b) + EPSILON), a + b - EPSILON); + const reach = Math.min(Math.max(toTarget.length(), Math.abs(a - b) + EPSILON), reachLimit); // 1) Elbow: rotate the forearm about the current hinge axis to the interior // angle demanded by the law of cosines. @@ -103,5 +176,72 @@ export class TwoBoneIk { } this.upper.updateWorldMatrix(false, true); + if (hold !== null) this.rollElbow(hold.poleRad, postureWeight); + } + + /** + * Swing the shoulder girdle toward the target so a retained elbow bend is + * paid for in scapular travel instead of a hand that stops short of its + * contact. Inert whenever the target already sits inside the bent-arm reach, + * which is every pose of every weapon whose support socket is authored + * within the arm. + */ + private advanceShoulder( + targetWorld: Vector3, + reachLimit: number, + hold: SupportArmSpec, + weight: number, + ): void { + const root = this.root; + if (root === null || hold.shoulderAdvanceMaxM <= EPSILON) return; + toTarget.subVectors(targetWorld, shoulderPos); + const deficit = toTarget.length() - reachLimit; + if (deficit <= EPSILON) return; + root.getWorldPosition(rootPos); + girdleVec.subVectors(shoulderPos, rootPos); + const girdleLength = girdleVec.length(); + if (girdleLength < EPSILON) return; + // Rotating about girdle x toTarget carries the shoulder along the arc + // toward the target; chord = 2 R sin(angle/2). + advanceAxis.crossVectors(girdleVec, toTarget); + if (advanceAxis.lengthSq() < EPSILON) return; + advanceAxis.normalize(); + const chord = Math.min(hold.shoulderAdvanceMaxM * weight, deficit); + worldRotation.setFromAxisAngle(advanceAxis, 2 * Math.asin(Math.min(1, chord / (2 * girdleLength)))); + this.girdleRestPose.copy(root.quaternion); + applyWorldRotation(root, worldRotation, 1); + this.girdleSwungPose.copy(root.quaternion); + this.girdleSwung = true; + root.updateWorldMatrix(false, true); + } + + /** + * Roll the whole arm about the shoulder->wrist axis until the elbow sits at + * the authored pole angle (from world-down, positive toward axis x down). + * The wrist lies ON that axis, so the authored contact never moves. + */ + private rollElbow(poleRad: number, weight: number): void { + this.upper.getWorldPosition(shoulderPos); + this.lower.getWorldPosition(elbowPos); + this.end.getWorldPosition(wristPos); + poleAxis.subVectors(wristPos, shoulderPos); + if (poleAxis.lengthSq() < EPSILON) return; + poleAxis.normalize(); + // World-down with its along-axis component removed: the pole angle's zero. + poleRef.set(0, -1, 0).addScaledVector(poleAxis, poleAxis.y); + if (poleRef.lengthSq() < POLE_PLANE_EPSILON) return; // arm points straight up/down + poleRef.normalize(); + poleSide.crossVectors(poleAxis, poleRef); + poleVec.subVectors(elbowPos, shoulderPos); + poleVec.addScaledVector(poleAxis, -poleVec.dot(poleAxis)); + if (poleVec.lengthSq() < POLE_PLANE_EPSILON) return; // straight arm: no plane to aim + let delta = poleRad - Math.atan2(poleVec.dot(poleSide), poleVec.dot(poleRef)); + if (delta > Math.PI) delta -= TAU; + else if (delta < -Math.PI) delta += TAU; + delta *= weight; + if (Math.abs(delta) < EPSILON) return; + worldRotation.setFromAxisAngle(poleAxis, delta); + applyWorldRotation(this.upper, worldRotation, 1); + this.upper.updateWorldMatrix(false, true); } } diff --git a/client-3d/src/render/faceDecal.test.ts b/client-3d/src/render/faceDecal.test.ts index 32e44c1f..722ff5e8 100644 --- a/client-3d/src/render/faceDecal.test.ts +++ b/client-3d/src/render/faceDecal.test.ts @@ -27,27 +27,43 @@ afterEach(() => { }); describe("world face decal", () => { - it("cuts and binds a painted overlay from the shipped pawn head geometry", async () => { + it("replaces the static authored panel with a transparent live overlay", async () => { vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("atlas fetch intentionally isolated")))); vi.spyOn(console, "warn").mockImplementation(() => undefined); const glbPath = resolve(process.cwd(), "public/assets/pawn-pack/pawn_male.glb"); const gltf = await new GLTFLoader().parseAsync(exactArrayBuffer(glbPath), ""); - let body: SkinnedMesh | null = null; + let authoredPanel: SkinnedMesh | null = null; gltf.scene.traverse((object) => { - if (!body && object instanceof SkinnedMesh) body = object; + if (!(object instanceof SkinnedMesh)) return; + const materials = Array.isArray(object.material) ? object.material : [object.material]; + if (materials.every((material) => + material.name === "RB_Face" || material.name.startsWith("RB_Face:"))) { + authoredPanel = object; + } }); - expect(body).not.toBeNull(); + expect(authoredPanel).not.toBeNull(); const attachments: Array = []; attachPawnFaceDecal(gltf.scene, face, attachments); expect(attachments).toHaveLength(1); const overlay = attachments[0]!; + expect(authoredPanel!.visible).toBe(false); expect(overlay.name).toBe("appearance:face"); expect(overlay.userData.successorFaceSignature).toBe(faceSignature(face)); - expect(overlay.skeleton).toBe(body!.skeleton); + expect(overlay.skeleton).toBe(authoredPanel!.skeleton); expect(overlay.geometry.index?.count ?? 0).toBeGreaterThan(0); - expect(overlay.geometry.attributes.uv?.count ?? 0).toBeGreaterThan(0); + const uv = overlay.geometry.attributes.uv; + expect(uv?.count ?? 0).toBeGreaterThan(0); + for (let vertex = 0; vertex < (uv?.count ?? 0); vertex += 1) { + expect(uv!.getX(vertex)).toBeGreaterThanOrEqual(0); + expect(uv!.getX(vertex)).toBeLessThanOrEqual(1); + expect(uv!.getY(vertex)).toBeGreaterThanOrEqual(0); + expect(uv!.getY(vertex)).toBeLessThanOrEqual(1); + } expect(overlay.parent).not.toBeNull(); + + attachPawnFaceDecal(gltf.scene, null); + expect(authoredPanel!.visible).toBe(true); }); }); diff --git a/client-3d/src/render/faceDecal.ts b/client-3d/src/render/faceDecal.ts index 3d81a9f9..971db5d1 100644 --- a/client-3d/src/render/faceDecal.ts +++ b/client-3d/src/render/faceDecal.ts @@ -144,6 +144,12 @@ function headWeightOf( return weight; } +function isAuthoredFaceMesh(source: SkinnedMesh): boolean { + const materials = Array.isArray(source.material) ? source.material : [source.material]; + return materials.length > 0 && materials.every((material) => + material.name === "RB_Face" || material.name.startsWith("RB_Face:")); +} + /** * Cut the paintable face patch out of a body's skinned geometry: triangles * whose three vertices are head-dominant and whose mean bind normal faces @@ -158,28 +164,37 @@ function buildFaceOverlayGeometry(source: SkinnedMesh): BufferGeometry | null { const normal = geometry.attributes.normal as BufferAttribute | undefined; const skinIndex = geometry.attributes.skinIndex as BufferAttribute | undefined; const skinWeight = geometry.attributes.skinWeight as BufferAttribute | undefined; + const authoredUv = geometry.attributes.uv as BufferAttribute | undefined; + const authoredPanel = isAuthoredFaceMesh(source); if (!index || !position || !normal || !skinIndex || !skinWeight) return null; - const headBoneIndex = source.skeleton.bones.findIndex((bone) => bone.name.toLowerCase() === "head"); - if (headBoneIndex < 0) return null; + if (authoredPanel && !authoredUv) return null; - const vertexCount = position.count; - const isHeadVertex = new Uint8Array(vertexCount); - for (let vertex = 0; vertex < vertexCount; vertex += 1) { - if (headWeightOf(skinIndex, skinWeight, vertex, headBoneIndex) >= FACE_HEAD_WEIGHT_MIN) { - isHeadVertex[vertex] = 1; + let isHeadVertex: Uint8Array | null = null; + if (!authoredPanel) { + const headBoneIndex = source.skeleton.bones.findIndex((bone) => bone.name.toLowerCase() === "head"); + if (headBoneIndex < 0) return null; + isHeadVertex = new Uint8Array(position.count); + for (let vertex = 0; vertex < position.count; vertex += 1) { + if (headWeightOf(skinIndex, skinWeight, vertex, headBoneIndex) >= FACE_HEAD_WEIGHT_MIN) { + isHeadVertex[vertex] = 1; + } } } - // Select forward-facing all-head triangles; collect the used vertex set. + // A promoted body has an exact, normalised RB_Face panel: use all of it and + // preserve its authored UVs. Legacy bodies fall back to cutting forward-facing + // head triangles and planar-projecting the measured face rectangle. const keptTriangles: number[] = []; const remap = new Map(); for (let triangle = 0; triangle < index.count; triangle += 3) { const a = index.getX(triangle); const b = index.getX(triangle + 1); const c = index.getX(triangle + 2); - if (!isHeadVertex[a] || !isHeadVertex[b] || !isHeadVertex[c]) continue; - const meanNormalZ = (normal.getZ(a) + normal.getZ(b) + normal.getZ(c)) / 3; - if (meanNormalZ < FACE_FORWARD_NORMAL_MIN) continue; + if (!authoredPanel) { + if (!isHeadVertex![a] || !isHeadVertex![b] || !isHeadVertex![c]) continue; + const meanNormalZ = (normal.getZ(a) + normal.getZ(b) + normal.getZ(c)) / 3; + if (meanNormalZ < FACE_FORWARD_NORMAL_MIN) continue; + } for (const vertex of [a, b, c]) { if (!remap.has(vertex)) remap.set(vertex, remap.size); } @@ -200,8 +215,12 @@ function buildFaceOverlayGeometry(source: SkinnedMesh): BufferGeometry | null { positions[overlayVertex * 3] = x + normal.getX(vertex) * FACE_OVERLAY_INFLATE; positions[overlayVertex * 3 + 1] = y + normal.getY(vertex) * FACE_OVERLAY_INFLATE; positions[overlayVertex * 3 + 2] = z + normal.getZ(vertex) * FACE_OVERLAY_INFLATE; - uvs[overlayVertex * 2] = (x + rectHalf) / FACE_RECT_SIZE; - uvs[overlayVertex * 2 + 1] = (y - (FACE_RECT_CENTER_Y - rectHalf)) / FACE_RECT_SIZE; + uvs[overlayVertex * 2] = authoredPanel + ? authoredUv!.getX(vertex) + : (x + rectHalf) / FACE_RECT_SIZE; + uvs[overlayVertex * 2 + 1] = authoredPanel + ? authoredUv!.getY(vertex) + : (y - (FACE_RECT_CENTER_Y - rectHalf)) / FACE_RECT_SIZE; for (let component = 0; component < 4; component += 1) { joints[overlayVertex * 4 + component] = skinIndex.getComponent(vertex, component); weights[overlayVertex * 4 + component] = skinWeight.getComponent(vertex, component); @@ -228,20 +247,40 @@ export function attachPawnFaceDecal( face: PawnFaceConfig | null | undefined, attachedOut?: Object3D[], ): void { - if (!face) return; - let sourceMesh: SkinnedMesh | null = null; + const candidates: SkinnedMesh[] = []; bodyRoot.traverse((object) => { - if (!sourceMesh && object instanceof SkinnedMesh) sourceMesh = object; + if (object instanceof SkinnedMesh) candidates.push(object); }); - if (!sourceMesh) return; - const body = sourceMesh as SkinnedMesh; + const authoredPanels = candidates.filter(isAuthoredFaceMesh); + for (const panel of authoredPanels) panel.visible = true; + if (!face) return; - let overlayGeometry = faceOverlayGeometryCache.get(body.geometry.uuid); - if (overlayGeometry === undefined) { - overlayGeometry = buildFaceOverlayGeometry(body); - faceOverlayGeometryCache.set(body.geometry.uuid, overlayGeometry); + // Prefer the exact authored panel over the legacy head-triangle projection. + // It tracks the reviewed head/neck sculpt and eliminates stale hard-coded + // placement whenever body proportions change. + const ordered = [ + ...authoredPanels, + ...candidates.filter((candidate) => !isAuthoredFaceMesh(candidate)), + ]; + let body: SkinnedMesh | null = null; + let overlayGeometry: BufferGeometry | null = null; + for (const candidate of ordered) { + let candidateGeometry = faceOverlayGeometryCache.get(candidate.geometry.uuid); + if (candidateGeometry === undefined) { + candidateGeometry = buildFaceOverlayGeometry(candidate); + faceOverlayGeometryCache.set(candidate.geometry.uuid, candidateGeometry); + } + if (!candidateGeometry) continue; + body = candidate; + overlayGeometry = candidateGeometry; + break; } - if (!overlayGeometry) return; + if (!body || !overlayGeometry) return; + + // The shipped static panel supplies a default face for plain GLB consumers. + // A live appearance replaces it per pawn; hiding only this mesh preserves the + // opaque skin panel immediately beneath it. + if (isAuthoredFaceMesh(body)) body.visible = false; const entry = facePaintEntry(face, faceSignature(face)); const overlay = new SkinnedMesh(overlayGeometry, entry.material); diff --git a/client-3d/src/render/pawns.rigidEquipment.test.ts b/client-3d/src/render/pawns.rigidEquipment.test.ts index d95cb33a..a37c5a7c 100644 --- a/client-3d/src/render/pawns.rigidEquipment.test.ts +++ b/client-3d/src/render/pawns.rigidEquipment.test.ts @@ -15,7 +15,14 @@ import { Vector3, type Object3D, } from "three"; -import type { PawnEquipmentItem, PawnPack } from "../assets/pawnPack"; +import { + applyPawnBodyZoneMask, + buildPawnEquipmentLookup, + collectPawnBodyZoneMeshes, + resolvePawnBodyZoneMask, + type PawnEquipmentItem, + type PawnPack, +} from "../assets/pawnPack"; import { attachPawnEquipmentSet, type PawnEquipmentMaterialResolver } from "./pawns"; /** @@ -106,18 +113,21 @@ function makeCapScene(): CapFixture { return { scene, atlasTexture, atlasMaterial }; } -function makeSkinnedTankScene(): Group { +function makeSkinnedTankScene(name = "tank"): Group { const sourceBone = new Bone(); sourceBone.name = "pelvis"; const mesh = new SkinnedMesh(new BoxGeometry(0.3, 0.4, 0.2), new MeshStandardMaterial()); - mesh.name = "tank"; + mesh.name = name; mesh.bind(new Skeleton([sourceBone])); const scene = new Group(); scene.add(mesh); return scene; } -function makePack(scenes: ReadonlyMap): PawnPack { +function makePack( + scenes: ReadonlyMap, + femaleScenes?: ReadonlyMap, +): PawnPack { // attachPawnEquipmentSet only consumes pack.equipment; building a full // cooked PawnPack (clips, masks, attach specs) is impossible in a unit test. const pack = { @@ -125,6 +135,7 @@ function makePack(scenes: ReadonlyMap): PawnPack { basePath: "/assets/pawn-pack/equipment", items: [CAP_ITEM, TANK_ITEM], scenes, + ...(femaleScenes ? { femaleScenes } : {}), }, } as unknown as PawnPack; return pack; @@ -222,9 +233,10 @@ describe("rigid authority accessory attachment (7203 field cap)", () => { it("marks and returns the owned attachment root; removal detaches everything", () => { const { bodyRoot, head } = makeBody(); const attached: Object3D[] = []; - attachPawnEquipmentSet(capPack(), bodyRoot, ["hat_field_cap"], () => new MeshBasicMaterial(), attached); + const attachedItemIds = attachPawnEquipmentSet(capPack(), bodyRoot, ["hat_field_cap"], () => new MeshBasicMaterial(), attached); expect(attached).toHaveLength(1); + expect(attachedItemIds).toEqual(["hat_field_cap"]); const root = attached[0]!; expect(root.userData.successorEquipmentItemId).toBe("hat_field_cap"); expect(root.userData.successorEquipmentLayer).toBe("Under"); @@ -239,12 +251,14 @@ describe("rigid authority accessory attachment (7203 field cap)", () => { const { bodyRoot } = makeBody(false); const attached: Object3D[] = []; const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + let attachedItemIds: readonly string[] = []; try { - attachPawnEquipmentSet(capPack(), bodyRoot, ["hat_field_cap"], () => new MeshBasicMaterial(), attached); + attachedItemIds = attachPawnEquipmentSet(capPack(), bodyRoot, ["hat_field_cap"], () => new MeshBasicMaterial(), attached); } finally { warn.mockRestore(); } expect(attached).toHaveLength(0); + expect(attachedItemIds).toEqual([]); expect(findAttachedCap(bodyRoot)).toBeNull(); }); @@ -273,4 +287,70 @@ describe("rigid authority accessory attachment (7203 field cap)", () => { expect(attached).toHaveLength(2); expect(findAttachedCap(bodyRoot)).not.toBeNull(); }); + + it("selects an authored female garment for a female body clone", () => { + const pack = makePack( + new Map([["under_tank", makeSkinnedTankScene("male_tank")]]), + new Map([["under_tank", makeSkinnedTankScene("female_tank")]]), + ); + const { bodyRoot } = makeBody(); + bodyRoot.userData.successorPawnBody = "female"; + attachPawnEquipmentSet(pack, bodyRoot, ["under_tank"], () => new MeshBasicMaterial()); + + const tank = bodyRoot.children.find((child): child is SkinnedMesh => child instanceof SkinnedMesh); + expect(tank?.name).toBe("equipment:under_tank:female_tank"); + }); +}); + +describe("segmented body coverage", () => { + it("hides only the union of exact body-zone primitives and restores removed coverage", () => { + const bodyRoot = new Group(); + const torsoMaterial = new MeshStandardMaterial(); + torsoMaterial.name = "BodyZone_torso"; + const pelvisMaterial = new MeshStandardMaterial(); + pelvisMaterial.name = "BodyZone_pelvis"; + const faceMaterial = new MeshStandardMaterial(); + faceMaterial.name = "RB_Face"; + const unknownMaterial = new MeshStandardMaterial(); + unknownMaterial.name = "BodyZone_unknown"; + const torso = new SkinnedMesh(new BoxGeometry(), torsoMaterial); + const pelvis = new SkinnedMesh(new BoxGeometry(), pelvisMaterial); + const face = new SkinnedMesh(new BoxGeometry(), faceMaterial); + const unknown = new SkinnedMesh(new BoxGeometry(), unknownMaterial); + bodyRoot.add(torso, pelvis, face, unknown); + const bodyZoneMeshes = collectPawnBodyZoneMeshes(bodyRoot); + const apparel = new SkinnedMesh(new BoxGeometry(), torsoMaterial); + bodyRoot.add(apparel); + + const equipment = { + basePath: "/assets/pawn-pack/equipment", + items: [ + { ...TANK_ITEM, id: "cover_torso", hideBodyZones: ["torso"] as const }, + { ...TANK_ITEM, id: "cover_pelvis", hideBodyZones: ["pelvis"] as const }, + ], + scenes: new Map(), + }; + const lookup = buildPawnEquipmentLookup(equipment); + + applyPawnBodyZoneMask( + bodyZoneMeshes, + resolvePawnBodyZoneMask(equipment, ["cover_torso", "cover_pelvis"], lookup), + ); + expect(torso.visible).toBe(false); + expect(pelvis.visible).toBe(false); + expect(face.visible).toBe(true); + expect(unknown.visible).toBe(true); + expect(apparel.visible).toBe(true); + + applyPawnBodyZoneMask( + bodyZoneMeshes, + resolvePawnBodyZoneMask(equipment, ["cover_torso"], lookup), + ); + expect(torso.visible).toBe(false); + expect(pelvis.visible).toBe(true); + + applyPawnBodyZoneMask(bodyZoneMeshes, resolvePawnBodyZoneMask(equipment, [], lookup)); + expect(torso.visible).toBe(true); + expect(pelvis.visible).toBe(true); + }); }); diff --git a/client-3d/src/render/pawns.ts b/client-3d/src/render/pawns.ts index 561a0e32..3dfef7c8 100644 --- a/client-3d/src/render/pawns.ts +++ b/client-3d/src/render/pawns.ts @@ -77,6 +77,11 @@ import { SUCCESSOR_3D_CONFIG } from "../config"; import { requireRuntimePublicPath } from "@successor/client/src/slice-core/runtimePublicPaths"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PlasmaBlade } from "./weapons/plasmaBlade"; +import { + PlasmaPresentation, + PLASMA_SWORD_COLOR, + PLASMA_SWORD_ITEM_ID, +} from "./weapons/plasmaPresentation"; import { makeGlowSprite } from "./fx/particles"; import { ensureEquipmentUv, @@ -88,12 +93,16 @@ import { type EquipmentSlotMaterialSource, } from "../assets/equipmentMaterials"; import { + applyPawnBodyZoneMask, clonePawnBody, cloneSpecialPawnBody, + collectPawnBodyZoneMeshes, equipmentIdsCoverLegs, pawnEquipmentLookupFor, + resolvePawnBodyZoneMask, sane, type PawnBody, + type PawnBodyZoneMesh, type PawnEquipmentItem, type PawnEquipmentLookup, type PawnPack, @@ -163,12 +172,7 @@ const MELEE_BASE_CLIP_NAMES: readonly string[] = [ "melee_run_f", ]; -/** Plasma sword inventory item (equips as vibrosword; look is presentation). */ -const PLASMA_SWORD_ITEM_ID = 3104; -const PLASMA_SWORD_COLOR = 0x63f0ff; -/** Ignition ramp: full blade in ~0.13s, retract slightly faster (~0.10s). */ -const PLASMA_IGNITE_PER_SECOND = 1 / 0.13; -const PLASMA_RETRACT_PER_SECOND = 1 / 0.1; +/** Plasma ignition ramp now lives with the shared presentation module. */ const RIFLE_AIM_UPPER_CLIP = "rifle_aim"; const GUN_GRIP_HAND_CLIP = "gun_grip_trigger_discipline"; @@ -337,9 +341,13 @@ interface PawnVisual extends VisualBase { animator: PawnAnimator; slugthrower: SlugthrowerRig | null; sword: SwordRig | null; - /** Live plasma-sword presentation (weaponItemId 3104). */ - plasma: { blade: PlasmaBlade; hilt: Object3D | null; extension: number; targetExtension: number; debugDrawnOverride: boolean | null } | null; + /** Live plasma-sword blade FX (weaponItemId 3104). */ + plasma: { presentation: PlasmaPresentation; debugDrawnOverride: boolean | null } | null; skinnedMeshes: SkinnedMesh[]; + /** Authored body materials, aligned with skinnedMeshes; runtime tinting never discards their maps. */ + bodySourceMaterials: ReadonlyArray; + /** Exact segmented base-body primitives, collected once before apparel attaches. */ + bodyZoneMeshes: readonly PawnBodyZoneMesh[]; equipmentAttachments: Object3D[]; equipmentMaterialGeneration: number; /** Resolved ids are rebuilt only when gear or authority appearance changes. */ @@ -691,19 +699,16 @@ function slotColorProofEnabled(): boolean { export type PawnEquipmentMaterialResolver = (item: PawnEquipmentItem, source: EquipmentSlotMaterialSource) => Material | Material[]; -export function attachPawnEquipmentSet( - pack: PawnPack, - bodyRoot: Group, - itemIds: readonly string[], - resolveMaterial: PawnEquipmentMaterialResolver, - attachedOut?: Object3D[], -): void { - if (itemIds.length === 0 || pack.equipment.items.length === 0) return; +/** Resolve the single runtime apparel convention: requirement expansion, + * slot winners, then requirement integrity. The result stays in manifest + * order; attachment reports the subset that actually rendered. */ +export function resolvePawnEquipmentIds(pack: PawnPack, itemIds: readonly string[]): string[] { + if (itemIds.length === 0 || pack.equipment.items.length === 0) return []; const requested = new Set(); for (let i = 0; i < itemIds.length; i += 1) { addEquipmentWithRequirements(pack, itemIds[i]!, requested); } - if (requested.size === 0) return; + if (requested.size === 0) return []; // Slot-exclusive AFTER requirement expansion. Appearance hair has its own // pseudo-slot, so helmets and hats never erase the character's saved hair. const winnerBySlot = new Map(); @@ -728,8 +733,25 @@ export function attachPawnEquipmentSet( } } } - if (requested.size === 0) return; + if (requested.size === 0) return []; + const resolved: string[] = []; + for (const item of pack.equipment.items) { + if (requested.has(item.id)) resolved.push(item.id); + } + return resolved; +} +/** Attach the resolved apparel and return only items with visible geometry; + * callers use that exact set for body-zone coverage. */ +export function attachPawnEquipmentSet( + pack: PawnPack, + bodyRoot: Group, + itemIds: readonly string[], + resolveMaterial: PawnEquipmentMaterialResolver, + attachedOut?: Object3D[], +): readonly string[] { + const resolvedItemIds = resolvePawnEquipmentIds(pack, itemIds); + if (resolvedItemIds.length === 0) return resolvedItemIds; const liveBones = new Map(); bodyRoot.traverse((object) => { if (object instanceof Bone) { @@ -737,10 +759,15 @@ export function attachPawnEquipmentSet( liveBones.set(sane(object.name).toLowerCase(), object); } }); - - for (const item of pack.equipment.items) { - if (requested.has(item.id)) attachEquipmentItemToBody(pack, bodyRoot, item, liveBones, resolveMaterial, attachedOut); + const itemById = pawnEquipmentLookupFor(pack.equipment).itemById; + const attachedItemIds: string[] = []; + for (let i = 0; i < resolvedItemIds.length; i += 1) { + const item = itemById.get(resolvedItemIds[i]!); + if (item && attachEquipmentItemToBody(pack, bodyRoot, item, liveBones, resolveMaterial, attachedOut)) { + attachedItemIds.push(item.id); + } } + return attachedItemIds; } function addEquipmentWithRequirements(pack: PawnPack, itemId: string, out: Set): void { @@ -789,11 +816,11 @@ function attachRigidEquipmentItem( anchorBone: string, liveBones: ReadonlyMap, attachedOut?: Object3D[], -): void { +): boolean { const bone = liveBones.get(anchorBone.toLowerCase()) ?? liveBones.get(sane(anchorBone).toLowerCase()); if (!bone) { console.warn(`pawn equipment: rigid anchor bone "${anchorBone}" missing for "${item.id}" — not attached`); - return; + return false; } // clone(true) keeps the whole node tree AND per-node userData/metadata. const root = source.clone(true); @@ -819,6 +846,7 @@ function attachRigidEquipmentItem( root.scale.setScalar(1 / worldScale); root.updateMatrixWorld(true); attachedOut?.push(root); + return true; } function attachEquipmentItemToBody( @@ -828,18 +856,20 @@ function attachEquipmentItemToBody( liveBones: ReadonlyMap, resolveMaterial: PawnEquipmentMaterialResolver, attachedOut?: Object3D[], -): void { - const source = pack.equipment.scenes.get(item.id); - if (!source) return; +): boolean { + const source = bodyRoot.userData.successorPawnBody === "female" + ? pack.equipment.femaleScenes?.get(item.id) ?? pack.equipment.scenes.get(item.id) + : pack.equipment.scenes.get(item.id); + if (!source) return false; if (item.rigidAnchorBone) { - attachRigidEquipmentItem(source, item, item.rigidAnchorBone, liveBones, attachedOut); - return; + return attachRigidEquipmentItem(source, item, item.rigidAnchorBone, liveBones, attachedOut); } const root = source.clone(true); const meshes: SkinnedMesh[] = []; root.traverse((object) => { if (object instanceof SkinnedMesh) meshes.push(object); }); + let attached = false; for (const mesh of meshes) { const bones: Bone[] = []; let missingBone = false; @@ -866,7 +896,9 @@ function attachEquipmentItemToBody( bodyRoot.add(mesh); mesh.bind(skeleton, mesh.bindMatrix.clone()); attachedOut?.push(mesh); + attached = true; } + return attached; } // Live LOD A/B dial (window.__successor3dLod): when non-null, overrides @@ -992,6 +1024,7 @@ export class PawnRenderer { private plasmaPreview: PlasmaBlade | null = null; private plasmaHiltScene: Group | null = null; private plasmaHiltInstance: Object3D | null = null; + private plasmaPreviewMount: Object3D | null = null; private plasmaHiltLoading = false; private plasmaHostSword: SwordRig | null = null; private plasmaPreviewColorIdx = 0; @@ -1102,13 +1135,7 @@ export class PawnRenderer { if (this.plasmaPreview) this.plasmaPreview.update(dtSeconds); for (const visual of this.visuals.values()) { if (visual.kind !== "pawn" || !visual.plasma) continue; - const plasma = visual.plasma; - if (plasma.extension !== plasma.targetExtension) { - const rate = plasma.targetExtension > plasma.extension ? PLASMA_IGNITE_PER_SECOND : -PLASMA_RETRACT_PER_SECOND; - plasma.extension = Math.min(1, Math.max(0, plasma.extension + rate * dtSeconds)); - plasma.blade.setExtension(plasma.extension); - } - plasma.blade.update(dtSeconds); + visual.plasma.presentation.update(dtSeconds); } this.frame += 1; this.lodHiFiCount = 0; @@ -2016,6 +2043,10 @@ export class PawnRenderer { bodyRoot.traverse((object) => { if (object instanceof SkinnedMesh) skinnedMeshes.push(object); }); + const bodySourceMaterials = skinnedMeshes.map((mesh) => mesh.material); + // Do this before any material conversion or apparel attachment: only exact + // base-body `BodyZone_` materials enter the cache. + const bodyZoneMeshes = requestedSpecialBodyKey ? [] : collectPawnBodyZoneMeshes(bodyRoot); const shadow = new Mesh(shadowGeometry, this.shadowMaterial(SUCCESSOR_3D_CONFIG.pawn.defaultTint)); shadow.name = `pawn-shadow:${actorId}`; @@ -2040,8 +2071,10 @@ export class PawnRenderer { plasma: null, shadow, skinnedMeshes, + bodySourceMaterials, equipmentMaterialGeneration: 0, equipmentAttachments: [], + bodyZoneMeshes, resolvedEquipmentIds: itemIds, equipmentAppearance: null, hairMaterialId: null, @@ -2126,8 +2159,9 @@ export class PawnRenderer { const out: Array<{ actorId: string; extension: number }> = []; for (const [actorId, visual] of this.visuals) { if (visual.kind !== "pawn" || !visual.plasma) continue; - if (visual.plasma.extension <= 0.35) continue; - out.push({ actorId, extension: visual.plasma.extension }); + const extension = visual.plasma.presentation.currentExtension(); + if (extension <= 0.35) continue; + out.push({ actorId, extension }); } return out; } @@ -2152,14 +2186,19 @@ export class PawnRenderer { /** * REAL plasma-sword presentation (not the dev preview): when the actor's - * equipped weapon snapshot carries weaponItemId 3104, the modeled vibrosword - * hides and the hilt + blade FX ride the sword rig. Per-visual — any number - * of pawns can ignite at once. energy blade doctrine: hilt + light only. + * equipped weapon snapshot carries weaponItemId 3104, the SwordRig is + * already carrying the authored plasma hilt model (weaponModelRegistry maps + * 3104 -> plasma_sword), so all this adds is the pure-effect blade. + * Per-visual — any number of pawns can ignite at once. */ private syncPlasmaEquip(visual: PawnVisual, weapon: unknown, meleeArmed: boolean, stowed: boolean): void { - const itemId = weapon !== null && typeof weapon === "object" && "weaponItemId" in weapon - ? Number((weapon as { weaponItemId?: unknown }).weaponItemId ?? 0) - : 0; + // `in` narrows weaponItemId to unknown, so the read is actually checked + // rather than asserted against a fabricated shape. + let itemId = 0; + if (weapon !== null && typeof weapon === "object" && "weaponItemId" in weapon) { + const raw = weapon.weaponItemId; + if (typeof raw === "number" && Number.isFinite(raw)) itemId = raw; + } const want = meleeArmed && itemId === PLASMA_SWORD_ITEM_ID && visual.sword !== null; if (!want) { if (visual.plasma) this.detachPlasmaEquip(visual); @@ -2167,43 +2206,20 @@ export class PawnRenderer { } const sword = visual.sword!; if (!visual.plasma) { - sword.setFrameVisible(false); - const blade = new PlasmaBlade(sword.frameRoot(), this.glowSpriteTexture, PLASMA_SWORD_COLOR); - blade.setExtension(0); // ignites via the ramp — never pops in fully lit - visual.plasma = { blade, hilt: null, extension: 0, targetExtension: stowed ? 0 : 1, debugDrawnOverride: null }; + // Ignites via the ramp — never pops in fully lit. + visual.plasma = { + presentation: new PlasmaPresentation(sword, this.glowSpriteTexture, PLASMA_SWORD_COLOR, false), + debugDrawnOverride: null, + }; } const drawn = visual.plasma.debugDrawnOverride ?? !stowed; - visual.plasma.targetExtension = drawn ? 1 : 0; - if (!visual.plasma.hilt) { - if (this.plasmaHiltScene) { - visual.plasma.hilt = this.plasmaHiltScene.clone(true); - sword.frameRoot().add(visual.plasma.hilt); - } else { - this.ensurePlasmaHiltLoaded(); - } - } + visual.plasma.presentation.setIgnited(drawn); } private detachPlasmaEquip(visual: PawnVisual): void { if (!visual.plasma) return; - visual.plasma.blade.dispose(); - visual.plasma.hilt?.parent?.remove(visual.plasma.hilt); + visual.plasma.presentation.dispose(); visual.plasma = null; - visual.sword?.setFrameVisible(true); - } - - private ensurePlasmaHiltLoaded(): void { - if (this.plasmaHiltScene || this.plasmaHiltLoading) return; - this.plasmaHiltLoading = true; - void new GLTFLoader().loadAsync(requireRuntimePublicPath(`${SUCCESSOR_3D_CONFIG.pawnPack.basePath}/plasma_hilt.glb`)).then( - (gltf) => { - this.plasmaHiltScene = gltf.scene; - }, - (error: unknown) => { - console.warn("plasma hilt load failed", error); - this.plasmaHiltLoading = false; - }, - ); } /** @@ -2217,7 +2233,9 @@ export class PawnRenderer { for (const visual of this.visuals.values()) { if (visual.kind !== "pawn" || !visual.plasma) continue; const plasma = visual.plasma; - plasma.debugDrawnOverride = plasma.debugDrawnOverride === null ? plasma.targetExtension < 0.5 : plasma.debugDrawnOverride ? false : true; + plasma.debugDrawnOverride = plasma.debugDrawnOverride === null + ? !plasma.presentation.isIgnited() + : !plasma.debugDrawnOverride; return true; } return false; @@ -2237,11 +2255,18 @@ export class PawnRenderer { const color = colorHex ?? palette[this.plasmaPreviewColorIdx % palette.length]!; this.plasmaPreviewColorIdx += 1; const sword = visual.sword; - // energy blade doctrine: modeled weapon hidden — hilt GLB + light only + // energy blade doctrine: modeled weapon hidden — hilt GLB + light only. + // Both ride a mount at the host weapon's PALM point; parenting them to + // the weaponRoot origin floats them up the blade axis. sword.setFrameVisible(false); this.plasmaHostSword = sword; - this.plasmaPreview = new PlasmaBlade(sword.frameRoot(), this.glowSpriteTexture, color); - this.attachPlasmaHilt(sword); + const mount = new Group(); + mount.name = "plasma:preview-mount"; + sword.gripLocal(mount.position); + sword.frameRoot().add(mount); + this.plasmaPreviewMount = mount; + this.plasmaPreview = new PlasmaBlade(mount, this.glowSpriteTexture, color); + this.attachPlasmaHilt(mount); sword.setStowed(false, { snap: true }); return true; } @@ -2255,25 +2280,45 @@ export class PawnRenderer { this.plasmaHiltInstance.parent?.remove(this.plasmaHiltInstance); this.plasmaHiltInstance = null; } + if (this.plasmaPreviewMount) { + this.plasmaPreviewMount.parent?.remove(this.plasmaPreviewMount); + this.plasmaPreviewMount = null; + } this.plasmaHostSword?.setFrameVisible(true); this.plasmaHostSword = null; } - private attachPlasmaHilt(sword: SwordRig): void { + /** Shared hilt scene for the DEV colour-cycle preview only. The equipped + * plasma sword loads its hilt through the weapons registry like any other + * catalogue model. */ + private ensurePlasmaHiltLoaded(): void { + if (this.plasmaHiltScene || this.plasmaHiltLoading) return; + this.plasmaHiltLoading = true; + void new GLTFLoader().loadAsync(requireRuntimePublicPath(`${SUCCESSOR_3D_CONFIG.pawnPack.basePath}/plasma_hilt.glb`)).then( + (gltf) => { + this.plasmaHiltScene = gltf.scene; + }, + (error: unknown) => { + console.warn("plasma hilt load failed", error); + this.plasmaHiltLoading = false; + }, + ); + } + + private attachPlasmaHilt(mount: Object3D): void { if (this.plasmaHiltScene) { this.plasmaHiltInstance = this.plasmaHiltScene.clone(true); - sword.frameRoot().add(this.plasmaHiltInstance); + mount.add(this.plasmaHiltInstance); return; } this.ensurePlasmaHiltLoaded(); // late attach when the shared scene lands (next preview click also works) - const host = sword; const retry = window.setInterval(() => { if (!this.plasmaHiltScene) return; window.clearInterval(retry); - if (this.plasmaPreview && this.plasmaHostSword === host && !this.plasmaHiltInstance) { + if (this.plasmaPreview && this.plasmaPreviewMount === mount && !this.plasmaHiltInstance) { this.plasmaHiltInstance = this.plasmaHiltScene.clone(true); - host.frameRoot().add(this.plasmaHiltInstance); + mount.add(this.plasmaHiltInstance); } }, 250); } @@ -2682,13 +2727,17 @@ export class PawnRenderer { : null, }); } - attachPawnEquipmentSet( + const attachedItemIds = attachPawnEquipmentSet( this.pack, visual.bodyRoot, itemIds, (item, source) => this.equipmentMaterial(item, source, visual.hairMaterialId, visual.wornColorsByPiece), visual.equipmentAttachments, ); + applyPawnBodyZoneMask( + visual.bodyZoneMeshes, + resolvePawnBodyZoneMask(this.pack.equipment, attachedItemIds, this.equipmentLookup), + ); attachPawnFaceDecal(visual.bodyRoot, appearance?.face ?? null, visual.equipmentAttachments); markSunShadowCaster(visual.bodyRoot); const materialGeneration = visual.equipmentMaterialGeneration; @@ -2763,8 +2812,13 @@ export class PawnRenderer { const colorKey = `${skinTone}|${relation}${selected ? "!s" : ""}`; if (colorKey === visual.colorKey) return; visual.colorKey = colorKey; - const material = this.bodyMaterial(skinTone, relation, selected); - for (const mesh of visual.skinnedMeshes) mesh.material = material; + for (let i = 0; i < visual.skinnedMeshes.length; i += 1) { + const mesh = visual.skinnedMeshes[i]!; + const source = visual.bodySourceMaterials[i]!; + mesh.material = Array.isArray(source) + ? source.map((material) => this.bodyMaterial(material, skinTone, relation, selected)) + : this.bodyMaterial(source, skinTone, relation, selected); + } if (visual.deathState === "none" && !visual.sleeping) { visual.shadow.material = this.shadowMaterial(relation); } @@ -2785,14 +2839,40 @@ export class PawnRenderer { visual.shadow.material = this.shadowMaterial(relation); } - private bodyMaterial(skinTone: string, relationColor: string, selected: boolean): MeshMatcapMaterial { - const key = `${skinTone}|${relationColor}${selected ? "!s" : ""}`; + private bodyMaterial( + source: Material, + skinTone: string, + relationColor: string, + selected: boolean, + ): MeshMatcapMaterial { + const authoredFace = source.name === "RB_Face"; + const key = authoredFace + ? `${source.uuid}|authored` + : `${source.uuid}|${skinTone}|${relationColor}${selected ? "!s" : ""}`; const existing = this.bodyMaterials.get(key); if (existing) return existing; - scratchColor.set(relationColor); - const color = new Color(skinTone).lerp(scratchColor, SUCCESSOR_3D_CONFIG.pawnPack.bodyTintLerp); - if (selected) color.lerp(scratchColor.set(SUCCESSOR_3D_CONFIG.pawn.selectedTint), 0.35); - const material = new MeshMatcapMaterial({ matcap: this.matcap, color }); + const map = "map" in source && source.map instanceof Texture ? source.map : null; + if (map) map.colorSpace = SRGBColorSpace; + let color = "color" in source && source.color instanceof Color + ? source.color.clone() + : new Color(0xffffff); + if (!authoredFace) { + scratchColor.set(relationColor); + color = new Color(skinTone).lerp(scratchColor, SUCCESSOR_3D_CONFIG.pawnPack.bodyTintLerp); + if (selected) color.lerp(scratchColor.set(SUCCESSOR_3D_CONFIG.pawn.selectedTint), 0.35); + } + const material = new MeshMatcapMaterial({ + matcap: this.matcap, + map, + color, + side: source.side, + transparent: source.transparent, + opacity: source.opacity, + alphaTest: source.alphaTest, + depthWrite: source.depthWrite, + vertexColors: "vertexColors" in source && source.vertexColors === true, + }); + material.name = `${source.name || "body"}:successor`; installPawnRim(material); this.bodyMaterials.set(key, material); return material; diff --git a/client-3d/src/render/props-mapping.json b/client-3d/src/render/props-mapping.json index eb463bbd..d92ef6af 100644 --- a/client-3d/src/render/props-mapping.json +++ b/client-3d/src/render/props-mapping.json @@ -10,7 +10,7 @@ "skip entries are non-physical logical constructs (ground fill, gather zones, debug cover overlays).", "randomYaw applies a deterministic per-prop-id yaw for variety on organic items when the prop has no authored rotation.", "megalith_* entries are megalith_kit_v2 bakeoff atoms (pawn-forge/pawnforgev2/_bakeoff/megalith_kit_v2_20260703; banded band_top/mid/base, color-only materials) promoted as camp ruin dressing 2026-07-05.", - "house_shoulder: buildings-20260704 H1 Shoulder House, owner-ratified player-structure language.", + "Approved enterable settlement buildings use authored fitFootprintM occupancy dimensions so decorative aprons and roof overhangs do not shrink gameplay scale.", "Dustgate occupation pack (wave2): everyday-world-props GLBs referenced by absolute public path under /assets/wave-props/ so fixture mapping can promote already-public runtime assets without copying into world-items." ], "entries": { @@ -36,12 +36,36 @@ "megalith_brick_hex_half": { "glb": "megalith_brick_hex_half.glb" }, "megalith_brick_hex_xl": { "glb": "megalith_brick_hex_xl.glb" }, "megalith_plinth": { "glb": "megalith_plinth.glb" }, - "house_shoulder": { - "glb": "house_h1.glb", - "slideDoor": { "node": "door_slide", "axisLocal": [-1, 0, 0], "distance": 1.22 }, + "home_modular_starter": { + "glb": "home_modular_starter.glb", + "fitFootprintM": [7.6, 5.7], + "slideDoor": { "node": "door_slide", "axisLocal": [1, 0, 0], "distance": 0.846 }, "enterable": { - "revealPrefixes": ["roof__"], - "manifest": "house_h1_manifest.json", + "revealPrefixes": ["roof__", "wall_front__", "wall_right__", "wall_back__", "wall_left__", "wall_court__"], + "revealNameIncludes": ["_ceiling_"], + "manifest": "home_modular_starter_manifest.json", + "fadeSeconds": 0.25 + } + }, + "home_modular_court": { + "glb": "home_modular_court.glb", + "fitFootprintM": [9.5, 7.6], + "slideDoor": { "node": "door_slide", "axisLocal": [1, 0, 0], "distance": 0.846 }, + "enterable": { + "revealPrefixes": ["roof__", "wall_front__", "wall_right__", "wall_back__", "wall_left__", "wall_court__"], + "revealNameIncludes": ["_ceiling_"], + "manifest": "home_modular_court_manifest.json", + "fadeSeconds": 0.25 + } + }, + "home_modular_wing": { + "glb": "home_modular_wing.glb", + "fitFootprintM": [11.4, 9.5], + "slideDoor": { "node": "door_slide", "axisLocal": [1, 0, 0], "distance": 0.846 }, + "enterable": { + "revealPrefixes": ["roof__", "wall_front__", "wall_right__", "wall_back__", "wall_left__", "wall_court__"], + "revealNameIncludes": ["_ceiling_"], + "manifest": "home_modular_wing_manifest.json", "fadeSeconds": 0.25 } }, @@ -51,19 +75,20 @@ "bank_terminal_civic": { "glb": "bank_terminal_civic.glb", "interactable": true }, "trade_terminal": { "glb": "trade_terminal.glb", "interactable": true }, "pa_terminal": { "glb": "pa_terminal.glb", "interactable": true }, - "commerce_facility": { - "glb": "commerce_facility.glb", + "valley_market": { + "glb": "valley_market.glb", + "fitFootprintM": [11.4, 8.55], "enterable": { - "revealPrefixes": ["roof__", "wall_front__", "wall_right__"], - "manifest": "commerce_facility_manifest.json", + "revealPrefixes": ["roof__", "wall_front__", "wall_right__", "wall_back__", "wall_left__"], + "manifest": "valley_market_manifest.json", "fadeSeconds": 0.25 }, - "slideDoor": { "node": "door_slide", "axisLocal": [-1, 0, 0], "distance": 2.58 } + "slideDoor": { "node": "door_slide", "axisLocal": [-1, 0, 0], "distance": 2.6 } }, "cloning_facility": { "glb": "cloning_facility.glb", "enterable": { - "revealPrefixes": ["roof__", "wall_front__", "wall_right__"], + "revealPrefixes": ["roof__", "wall_front__", "wall_right__", "wall_back__", "wall_left__"], "manifest": "cloning_facility_manifest.json", "fadeSeconds": 0.25 }, diff --git a/client-3d/src/render/props.cutaway.test.ts b/client-3d/src/render/props.cutaway.test.ts index 9cab2069..29329e68 100644 --- a/client-3d/src/render/props.cutaway.test.ts +++ b/client-3d/src/render/props.cutaway.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { Mesh, MeshBasicMaterial, Object3D } from "three"; import { @@ -15,6 +17,7 @@ import { type RevealFadeMesh, } from "./props"; import { SUN_SHADOW_CASTER_LAYER } from "./environment/sunShadow"; +import propsMapping from "./props-mapping.json"; // One 10x8-cell interior in prop-local milli coordinates. const REGIONS: CutawayRegionMilli[] = [{ xMilli: 0, yMilli: 0, wMilli: 10000, hMilli: 8000 }]; @@ -240,6 +243,21 @@ describe("enterable part classification — floor/door invariant", () => { expect(classifyEnterablePart(meshUnder("Floor_main"), "body", ["Floor_"])).toBe("floor"); }); + it("keeps floor and door precedence over literal reveal-name fragments", () => { + expect(classifyEnterablePart( + meshUnder("interior__floor_ceiling_panel"), + "body", + null, + ["_ceiling_"], + )).toBe("floor"); + expect(classifyEnterablePart( + meshUnder("door_slide_ceiling_panel"), + "body", + null, + ["ceiling"], + )).toBe("door"); + }); + it("keeps roof and camera-facing wall groups in the reveal set", () => { const prefixes = ["roof__", "wall_front__", "wall_right__"]; expect(classifyEnterablePart(meshUnder("roof__a"), "body", prefixes)).toBe("reveal"); @@ -247,4 +265,61 @@ describe("enterable part classification — floor/door invariant", () => { expect(classifyEnterablePart(meshUnder("wall_back__a"), "body", prefixes)).toBe("keep"); expect(classifyEnterablePart(meshUnder("wall_back__a"), "body", null)).toBe("keep"); }); + + it("selects every shipped modular ceiling and courtyard-wall mesh", () => { + const worldItems = resolve(process.cwd(), "public/assets/world-items"); + const expectedCeilings: Record = { + home_modular_starter: 48, + home_modular_court: 54, + home_modular_wing: 70, + }; + const entries = propsMapping.entries as Record; + + for (const [assetKey, expected] of Object.entries(expectedCeilings)) { + const entry = entries[assetKey]; + expect(entry?.enterable?.revealNameIncludes).toEqual(["_ceiling_"]); + expect(entry?.enterable?.revealPrefixes).toContain("wall_court__"); + const bytes = readFileSync(resolve(worldItems, entry?.glb ?? "")); + expect(bytes.toString("ascii", 0, 4)).toBe("glTF"); + const jsonLength = bytes.readUInt32LE(12); + const gltf = JSON.parse( + bytes.subarray(20, 20 + jsonLength).toString("utf8").replace(/\0+$/u, ""), + ) as { nodes?: Array<{ mesh?: number; name?: string }> }; + const meshNames = (gltf.nodes ?? []) + .filter((node) => node.mesh !== undefined) + .map((node) => node.name ?? ""); + const ceilingNames = meshNames.filter((name) => name.includes("_ceiling_")); + expect(ceilingNames).toHaveLength(expected); + expect(ceilingNames.every((name) => classifyEnterablePart( + meshUnder(name), + "body", + entry?.enterable?.revealPrefixes ?? null, + entry?.enterable?.revealNameIncludes ?? null, + ) === "reveal")).toBe(true); + const ordinaryInterior = meshNames.find((name) => ( + name.startsWith("interior__") && !name.includes("_ceiling_") && !name.toLowerCase().includes("floor") + )); + expect(ordinaryInterior).toBeDefined(); + expect(classifyEnterablePart( + meshUnder(ordinaryInterior ?? ""), + "body", + entry?.enterable?.revealPrefixes ?? null, + entry?.enterable?.revealNameIncludes ?? null, + )).toBe("keep"); + + if (assetKey === "home_modular_court") { + const courtyardWalls = meshNames.filter((name) => name.startsWith("wall_court__")); + expect(courtyardWalls).toHaveLength(67); + expect(courtyardWalls.every((name) => classifyEnterablePart( + meshUnder(name), + "body", + entry?.enterable?.revealPrefixes ?? null, + entry?.enterable?.revealNameIncludes ?? null, + ) === "reveal")).toBe(true); + } + } + }); }); diff --git a/client-3d/src/render/props.loading.test.ts b/client-3d/src/render/props.loading.test.ts index 7d4feaf4..2c3b5a2a 100644 --- a/client-3d/src/render/props.loading.test.ts +++ b/client-3d/src/render/props.loading.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { SliceSnapshot } from "@successor/client/src/slice-core/gameState"; import { WORLD_PROP_ASSET_LOAD_TIMEOUT_MS, + resolvePropFitFootprint, WorldPropsRenderer, withWorldPropAssetLoadTimeout, } from "./props"; @@ -20,6 +21,13 @@ interface RendererSeam { afterEach(() => { vi.useRealTimers(); }); +describe("world-prop authored footprint", () => { + it("keeps decorative overhangs from shrinking gameplay scale", () => { + expect(resolvePropFitFootprint(8.056, 7.454, [7.6, 5.7])).toEqual([7.6, 5.7]); + expect(resolvePropFitFootprint(8.056, 7.454)).toEqual([8.056, 7.454]); + }); +}); + describe("world-prop asset load boundary", () => { it("rejects a never-settling load with the exact asset path", async () => { diff --git a/client-3d/src/render/props.ts b/client-3d/src/render/props.ts index 216c6dd8..e73f1b05 100644 --- a/client-3d/src/render/props.ts +++ b/client-3d/src/render/props.ts @@ -79,6 +79,8 @@ interface MappingEnterable { floorHeightM?: number; /** Mesh-node prefixes faded while the local pawn is inside this instance. */ revealPrefixes: string[]; + /** Literal mesh-node or ancestor name fragments faded with the authored shell. */ + revealNameIncludes?: string[]; /** Source manifest filename; kept with mapping for audit/debug tooling. */ manifest?: string; /** Fade duration in seconds; defaults to the contract quarter-second. */ @@ -92,6 +94,11 @@ interface MappingEntry { skip?: boolean; interactable?: boolean; enterable?: MappingEnterable; + /** + * Authored occupancy footprint in GLB metres. Use this instead of the full + * render AABB when decorative eaves/aprons extend beyond gameplay bounds. + */ + fitFootprintM?: readonly number[]; /** * XYZ Euler correction, in degrees, applied to the imported asset root * before bounds/recentering. Use this only to reconcile an authored GLB's @@ -392,13 +399,14 @@ const DOOR_NODE_PREFIX = "door_slide"; /** * Reveal membership is decided here, and floor-named or door nodes can never - * win it — a misconfigured revealPrefixes list cannot hide the walk surface - * or the gameplay door. + * win it — malformed reveal selectors cannot hide the walk surface or the + * gameplay door. */ export function classifyEnterablePart( object: Object3D, role: GlbPartRole, revealPrefixes: readonly string[] | null, + revealNameIncludes: readonly string[] | null = null, ): EnterablePartClass { if (role === "door") return "door"; for (let node: Object3D | null = object; node; node = node.parent) { @@ -407,7 +415,10 @@ export function classifyEnterablePart( for (let node: Object3D | null = object; node; node = node.parent) { if (node.name.toLowerCase().includes("floor")) return "floor"; } - if (revealPrefixes && hasRevealPrefix(object, revealPrefixes)) return "reveal"; + if ( + (revealPrefixes && hasRevealPrefix(object, revealPrefixes)) + || (revealNameIncludes && hasRevealNameInclude(object, revealNameIncludes)) + ) return "reveal"; return "keep"; } @@ -1248,6 +1259,7 @@ export class WorldPropsRenderer { const revealPrefixes = entry?.enterable?.revealPrefixes ?? entry?.interiorRevealPrefixes ?? null; + const revealNameIncludes = entry?.enterable?.revealNameIncludes ?? null; const doorNode = entry?.slideDoor ? root.getObjectByName(entry.slideDoor.node) ?? null : null; if (entry?.slideDoor && !doorNode) { console.warn(`world props: slideDoor node "${entry.slideDoor.node}" not found in ${entry.glb}`); @@ -1307,14 +1319,19 @@ export class WorldPropsRenderer { preHinge = new Matrix4().copy(recenter).multiply(doorNode.matrixWorld); postHinge = new Matrix4().copy(doorNode.matrixWorld).invert().multiply(object.matrixWorld); } - const enterableClass = classifyEnterablePart(object, role, revealPrefixes); + const enterableClass = classifyEnterablePart(object, role, revealPrefixes, revealNameIncludes); const interiorReveal = enterableClass === "reveal"; parts.push({ geometry: object.geometry as BufferGeometry, material, localMatrix, role, preHinge, postHinge, interiorReveal, enterableClass }); }); + const [footprintX, footprintZ] = resolvePropFitFootprint( + tmpSize.x, + tmpSize.z, + entry?.fitFootprintM, + ); return { parts, - footprintX: Math.max(0.001, tmpSize.x), - footprintZ: Math.max(0.001, tmpSize.z), + footprintX, + footprintZ, hasLid, animatedScreen, enterable: entry?.enterable ?? null, @@ -1431,6 +1448,19 @@ export class WorldPropsRenderer { } } +export function resolvePropFitFootprint( + measuredX: number, + measuredZ: number, + authored?: readonly number[], +): [number, number] { + const x = authored?.[0]; + const z = authored?.[1]; + if (typeof x === "number" && typeof z === "number" && Number.isFinite(x) && Number.isFinite(z) && x > 0 && z > 0) { + return [x, z]; + } + return [Math.max(0.001, measuredX), Math.max(0.001, measuredZ)]; +} + function composePlacement( prop: PropSnapshot, randomYaw: boolean, @@ -1607,6 +1637,16 @@ function hasRevealPrefix(object: Object3D, prefixes: readonly string[]): boolean return false; } +/** True when the mesh or any ancestor node name contains an authored fragment. */ +function hasRevealNameInclude(object: Object3D, includes: readonly string[]): boolean { + for (let node: Object3D | null = object; node; node = node.parent) { + for (const include of includes) { + if (include.length > 0 && node.name.includes(include)) return true; + } + } + return false; +} + function lidAncestor(object: Object3D): Object3D | null { for (let node: Object3D | null = object; node; node = node.parent) { if (node.name.includes(LID_MARKER)) return node; diff --git a/client-3d/src/render/weapons/perModelAttach.test.ts b/client-3d/src/render/weapons/perModelAttach.test.ts new file mode 100644 index 00000000..2b928292 --- /dev/null +++ b/client-3d/src/render/weapons/perModelAttach.test.ts @@ -0,0 +1,415 @@ +// perModelAttach.test.ts — the observable contract introduced when weapons +// stopped sharing one global carry transform. +// +// The defect these guard against: every attach json shipped an identical +// placeholder stow_socket that nothing parsed, and both rigs forced one config +// transform onto dissimilar geometry. That laid the +Y-blade melee family +// horizontally through the pawn's neck while the +Z-blade Vibrosword carried +// correctly, and it floated the plasma hilt 15.25 cm out of the hand. +import { Bone, BoxGeometry, Euler, Group, Mesh, MeshBasicMaterial, Quaternion, Vector3 } from "three"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import type { PawnPack, SlugthrowerAttachSpec } from "../../assets/pawnRigTypes"; +import { SUCCESSOR_3D_CONFIG } from "../../config"; +import { SwordRig } from "./swordRig"; +import { SlugthrowerRig } from "./slugthrowerRig"; + +beforeAll(() => { + vi.stubGlobal("window", {}); +}); + +afterAll(() => { + vi.unstubAllGlobals(); +}); + +const DEG_TO_RAD = Math.PI / 180; + +function meleeScene(name: string): Group { + const scene = new Group(); + const frame = new Mesh(new BoxGeometry(0.05, 0.62, 0.04), new MeshBasicMaterial()); + frame.name = name; + scene.add(frame); + return scene; +} + +function meleeSpec(overrides: Partial = {}): SlugthrowerAttachSpec { + return { + mountPos: new Vector3(-0.01, 0.072, 0.016), + mountQuat: new Quaternion(), + sockets: { + grip: new Vector3(), + foregrip: new Vector3(0, 0.06, 0), + muzzle: new Vector3(0, 0.5435, 0), + stock: new Vector3(0, -0.0765, 0), + }, + nodes: { frame: "scrapline_machete" }, + silhouetteClass: "melee", + ...overrides, + }; +} + +/** spine + hand rooted in one tree so world matrices are meaningful. */ +function rig(spec: SlugthrowerAttachSpec): { root: Group; hand: Bone; spine: Bone; sword: SwordRig } { + const root = new Group(); + const spine = new Bone(); + spine.name = "spine_03"; + const hand = new Bone(); + hand.name = "hand_r"; + root.add(spine); + root.add(hand); + hand.position.set(0.25, 1.2, 0.1); + root.updateMatrixWorld(true); + const sword = new SwordRig({} as PawnPack, hand, spine, spec, meleeScene("scrapline_machete"), 1, "melee:test"); + return { root, hand, spine, sword }; +} + +/** Blade direction in spine-local space after the stow has fully settled. */ +function stowedBladeDirection(spec: SlugthrowerAttachSpec): Vector3 { + const { root, spine, sword } = rig(spec); + sword.setStowed(true, { snap: true }); + sword.update(1); + root.updateMatrixWorld(true); + const frame = sword.frameRoot(); + const tipWorld = frame.localToWorld(new Vector3(0, 1, 0)); + const originWorld = frame.localToWorld(new Vector3()); + return spine.worldToLocal(tipWorld).sub(spine.worldToLocal(originWorld)).normalize(); +} + +describe("per-model stow socket", () => { + it("carries a model by its OWN authored socket, not the melee class default", () => { + // Authored socket rotates the +Y blade axis onto spine-local -Y (blade + // down the back). The class default is tuned for a +Z blade frame and + // would leave this model's blade pointing somewhere else entirely. + const authored = meleeSpec({ + stow: { pos: new Vector3(-0.047, 0.296, -0.137), rotDeg: new Vector3(-179.47, -2.96, -9.01), arcLift: 0.14 }, + }); + const dir = stowedBladeDirection(authored); + expect(dir.y).toBeLessThan(-0.9); // blade rakes DOWN the back + + const fallback = stowedBladeDirection(meleeSpec()); + // The legacy default puts this same geometry somewhere else — proving the + // authored socket is what is being consumed, not the shared config. + expect(Math.abs(fallback.y - dir.y)).toBeGreaterThan(0.5); + }); + + it("falls back to the legacy sword carry when a model authors no socket", () => { + const { root, spine, sword } = rig(meleeSpec()); + sword.setStowed(true, { snap: true }); + sword.update(1); + root.updateMatrixWorld(true); + + const legacy = SUCCESSOR_3D_CONFIG.pawnPack.swordStow; + const expected = new Quaternion().setFromEuler(new Euler( + legacy.rotOffsetDeg.x * DEG_TO_RAD, + legacy.rotOffsetDeg.y * DEG_TO_RAD, + legacy.rotOffsetDeg.z * DEG_TO_RAD, + "XYZ", + )); + const actual = spine.getWorldQuaternion(new Quaternion()).invert() + .multiply(sword.frameRoot().getWorldQuaternion(new Quaternion())); + expect(actual.angleTo(expected)).toBeLessThan(1e-3); + + const posLocal = spine.worldToLocal(sword.frameRoot().getWorldPosition(new Vector3())); + expect(posLocal.x).toBeCloseTo(legacy.posOffset.x, 4); + expect(posLocal.y).toBeCloseTo(legacy.posOffset.y, 4); + expect(posLocal.z).toBeCloseTo(legacy.posOffset.z, 4); + }); + + it("exposes the palm point so hand-welded FX never ride the model origin", () => { + // The plasma hilt floated because it was parented at the weaponRoot + // origin, which on a blade-origin model is out in mid-air. + const spec = meleeSpec({ + sockets: { ...meleeSpec().sockets, grip: new Vector3(0, 0, -0.1525) }, + }); + const { sword } = rig(spec); + expect(sword.gripLocal(new Vector3()).toArray()).toEqual([0, 0, -0.1525]); + }); +}); + +describe("gun rig per-model authoring", () => { + function gunSpec(overrides: Partial = {}): SlugthrowerAttachSpec { + return { + mountPos: new Vector3(), + mountQuat: new Quaternion(), + sockets: { + grip: new Vector3(0, -0.091, -0.218), + foregrip: new Vector3(0, 0, 0.07), + muzzle: new Vector3(0, 0, 0.142), + stock: new Vector3(0, 0, -0.5885), + }, + nodes: { frame: "Module_receiver" }, + ...overrides, + }; + } + + function gunScene(): Group { + const scene = new Group(); + const frame = new Mesh(new BoxGeometry(0.1, 0.1, 0.35), new MeshBasicMaterial()); + frame.name = "Module_receiver"; + scene.add(frame); + return scene; + } + + function build(spec: SlugthrowerAttachSpec): { spine: Bone; gun: SlugthrowerRig; root: Group } { + const root = new Group(); + const spine = new Bone(); + const hand = new Bone(); + root.add(spine, hand); + hand.position.set(0.2, 1.2, 0); + root.updateMatrixWorld(true); + const gun = new SlugthrowerRig({} as PawnPack, hand, null, null, null, spine, spec, gunScene(), 1); + return { spine, gun, root }; + } + + it("stows a gun on its own authored socket", () => { + const pos = new Vector3(0.21, -0.05, -0.2); + const { spine, gun, root } = build(gunSpec({ + stow: { pos, rotDeg: new Vector3(70, -30, 10), arcLift: 0.2 }, + })); + gun.setStowed(true, { snap: true }); + gun.update(1, null, "off"); + root.updateMatrixWorld(true); + const local = spine.worldToLocal(gun.weaponObject().getWorldPosition(new Vector3())); + expect(local.x).toBeCloseTo(pos.x, 4); + expect(local.y).toBeCloseTo(pos.y, 4); + expect(local.z).toBeCloseTo(pos.z, 4); + }); + + it("resets live stow dials so a new model never inherits the previous carry", () => { + const dials = window.__successor3dWeapon; + expect(dials).toBeDefined(); + dials!.stowPosDelta.x = 0.4; + dials!.stowRotDeltaDeg.z = 55; + + // Constructing a rig = switching models. + build(gunSpec()); + expect(dials!.stowPosDelta.x).toBe(0); + expect(dials!.stowRotDeltaDeg.z).toBe(0); + }); + + it("applies live stow dials as a DELTA on top of the authored base", () => { + const pos = new Vector3(0.16, 0, -0.14); + const { spine, gun, root } = build(gunSpec({ + stow: { pos, rotDeg: new Vector3(85, -45, 0), arcLift: 0.14 }, + })); + window.__successor3dWeapon!.stowPosDelta.y = 0.1; + gun.setStowed(true, { snap: true }); + gun.update(1, null, "off"); + root.updateMatrixWorld(true); + const local = spine.worldToLocal(gun.weaponObject().getWorldPosition(new Vector3())); + expect(local.y).toBeCloseTo(pos.y + 0.1, 4); + window.__successor3dWeapon!.resetStow(); + }); + + it("aims the support wrist at an authored contact instead of the global offset", () => { + const contact = new Vector3(0, -0.0272, 0.065); + const upper = new Bone(); + const lower = new Bone(); + const handL = new Bone(); + upper.add(lower); + lower.add(handL); + lower.position.set(0, -0.3, 0); + handL.position.set(0, -0.3, 0); + const root = new Group(); + const spine = new Bone(); + const handR = new Bone(); + root.add(spine, handR, upper); + handR.position.set(0.2, 1.2, 0); + upper.position.set(0.2, 1.4, 0); + root.updateMatrixWorld(true); + + const spec = gunSpec({ sockets: { ...gunSpec().sockets, foregripContact: contact } }); + const gun = new SlugthrowerRig({} as PawnPack, handR, upper, lower, handL, spine, spec, gunScene(), 1); + gun.update(1, null, "on"); + root.updateMatrixWorld(true); + // The authored point is used verbatim: no global contact offset added. + const target = gun.weaponObject().getObjectByName("Module_receiver")! + .localToWorld(contact.clone()); + const solved = handL.getWorldPosition(new Vector3()); + const globalOffset = SUCCESSOR_3D_CONFIG.pawnPack.foregripContactOffset; + const legacyTarget = gun.weaponObject().getObjectByName("Module_receiver")! + .localToWorld(spec.sockets.foregrip.clone().add(new Vector3(globalOffset.x, globalOffset.y, globalOffset.z))); + expect(solved.distanceTo(target)).toBeLessThan(solved.distanceTo(legacyTarget)); + }); + + it("uses the model's authored resting yaw over the config default", () => { + const custom = build(gunSpec({ restingYawRad: 0.3 })); + const fallback = build(gunSpec()); + for (const b of [custom, fallback]) { + b.gun.update(1 / 30, null, "off"); + b.root.updateMatrixWorld(true); + } + const yawOf = (g: SlugthrowerRig): number => { + const muzzle = g.getMuzzleWorld(new Vector3()); + const grip = g.getGripWorld(new Vector3()); + return Math.atan2(muzzle.x - grip.x, muzzle.z - grip.z); + }; + expect(Math.abs(yawOf(custom.gun) - yawOf(fallback.gun))).toBeGreaterThan(0.1); + }); +}); + +// The defect: a support socket authored past the arm's own reach made the +// two-bone solver clamp to a+b, collapsing the elbow onto the shoulder->wrist +// line. The support arm became a rod laid along the weapon and its forearm +// crossed the receiver at walk and run on both bodies. +describe("support-arm hold posture", () => { + const DEG = Math.PI / 180; + // The pack skeleton's support arm: 0.325 + 0.259 = 0.584 m of reach. + const UPPER_LEN = 0.325; + const LOWER_LEN = 0.259; + const GIRDLE_LEN = 0.168; + const POSTURE = { minBendRad: 34 * DEG, shoulderAdvanceMaxM: 0.05, poleRad: 56 * DEG }; + + interface Arm { + root: Group; + clavicle: Bone; + upper: Bone; + lower: Bone; + handL: Bone; + gun: SlugthrowerRig; + } + + function gunSpec(contactZ: number, supportArm?: SlugthrowerAttachSpec["supportArm"]): SlugthrowerAttachSpec { + return { + mountPos: new Vector3(), + mountQuat: new Quaternion(), + sockets: { + grip: new Vector3(0, -0.091, -0.218), + foregrip: new Vector3(0, 0, contactZ), + muzzle: new Vector3(0, 0, 0.142), + stock: new Vector3(0, 0, -0.5885), + foregripContact: new Vector3(0, -0.027, contactZ), + }, + nodes: { frame: "Module_receiver" }, + supportArm, + }; + } + + function build(spec: SlugthrowerAttachSpec): Arm { + const root = new Group(); + const spine = new Bone(); + const handR = new Bone(); + const clavicle = new Bone(); + const upper = new Bone(); + const lower = new Bone(); + const handL = new Bone(); + clavicle.name = "clavicle_l"; + clavicle.add(upper); + upper.add(lower); + lower.add(handL); + root.add(spine, handR, clavicle); + clavicle.position.set(0.02, 1.4, 0); + upper.position.set(GIRDLE_LEN, 0, 0); + lower.position.set(0, -UPPER_LEN, 0); + handL.position.set(0, -LOWER_LEN, 0); + handR.position.set(0.2, 1.2, 0); + root.updateMatrixWorld(true); + const scene = new Group(); + const frame = new Mesh(new BoxGeometry(0.1, 0.1, 0.35), new MeshBasicMaterial()); + frame.name = "Module_receiver"; + scene.add(frame); + const gun = new SlugthrowerRig({} as PawnPack, handR, upper, lower, handL, spine, spec, scene, 1); + return { root, clavicle, upper, lower, handL, gun }; + } + + /** Perpendicular distance from the elbow to the shoulder->wrist line. */ + function elbowOffAxis(arm: Arm): number { + const shoulder = arm.upper.getWorldPosition(new Vector3()); + const elbow = arm.lower.getWorldPosition(new Vector3()); + const wrist = arm.handL.getWorldPosition(new Vector3()); + const axis = wrist.clone().sub(shoulder); + const length = axis.length(); + if (length < 1e-9) return 0; + axis.multiplyScalar(1 / length); + const toElbow = elbow.clone().sub(shoulder); + return toElbow.addScaledVector(axis, -toElbow.dot(axis)).length(); + } + + /** A contact 0.65 m down the frame sits ~0.1 m past the arm's 0.584 m reach. */ + const OUT_OF_REACH = 0.65; + const IN_REACH = 0.07; + + it("leaves the elbow collapsed on the shoulder-wrist line without a posture", () => { + const arm = build(gunSpec(OUT_OF_REACH)); + arm.gun.update(1, null, "on"); + arm.root.updateMatrixWorld(true); + expect(elbowOffAxis(arm)).toBeLessThan(0.005); + }); + + it("holds the authored elbow bend when the contact is past the arm", () => { + const arm = build(gunSpec(OUT_OF_REACH, POSTURE)); + arm.gun.update(1, null, "on"); + arm.root.updateMatrixWorld(true); + // chord for a 34 deg bend = sqrt(a^2+b^2-2ab cos(146 deg)) -> the elbow + // stands a*b*sin(34)/chord = 84 mm off the axis. + const chord = Math.sqrt( + UPPER_LEN ** 2 + LOWER_LEN ** 2 - 2 * UPPER_LEN * LOWER_LEN * Math.cos(Math.PI - POSTURE.minBendRad), + ); + const expected = (UPPER_LEN * LOWER_LEN * Math.sin(POSTURE.minBendRad)) / chord; + expect(elbowOffAxis(arm)).toBeGreaterThan(expected * 0.9); + }); + + it("caps the girdle swing at the authored advance and never accumulates it", () => { + const legacy = build(gunSpec(OUT_OF_REACH)); + const posed = build(gunSpec(OUT_OF_REACH, POSTURE)); + legacy.gun.update(1, null, "on"); + legacy.root.updateMatrixWorld(true); + const rest = legacy.upper.getWorldPosition(new Vector3()); + + posed.gun.update(1, null, "on"); + posed.root.updateMatrixWorld(true); + const first = posed.upper.getWorldPosition(new Vector3()); + expect(first.distanceTo(rest)).toBeGreaterThan(0.04); + expect(first.distanceTo(rest)).toBeLessThanOrEqual(POSTURE.shoulderAdvanceMaxM + 1e-4); + + // Post-mixer bones the clips do not animate must not compound: 60 more + // frames may not walk the shoulder any further forward. + for (let i = 0; i < 60; i += 1) posed.gun.update(1 / 60, null, "on"); + posed.root.updateMatrixWorld(true); + expect(posed.upper.getWorldPosition(new Vector3()).distanceTo(first)).toBeLessThan(1e-6); + }); + + it("rolls the elbow to the pole without moving the support wrist", () => { + const down = build(gunSpec(OUT_OF_REACH, { ...POSTURE, poleRad: 0 })); + const flared = build(gunSpec(OUT_OF_REACH, POSTURE)); + for (const arm of [down, flared]) { + arm.gun.update(1, null, "on"); + arm.root.updateMatrixWorld(true); + } + const downWrist = down.handL.getWorldPosition(new Vector3()); + const flaredWrist = flared.handL.getWorldPosition(new Vector3()); + expect(flaredWrist.distanceTo(downWrist)).toBeLessThan(1e-6); + // ...while the elbow genuinely moved to the other pole angle. + const downElbow = down.lower.getWorldPosition(new Vector3()); + const flaredElbow = flared.lower.getWorldPosition(new Vector3()); + expect(flaredElbow.distanceTo(downElbow)).toBeGreaterThan(0.05); + }); + + it("never swings the girdle for a contact the arm can already reach", () => { + const legacy = build(gunSpec(IN_REACH)); + const posed = build(gunSpec(IN_REACH, POSTURE)); + for (const arm of [legacy, posed]) { + arm.gun.update(1, null, "on"); + arm.root.updateMatrixWorld(true); + } + expect(posed.clavicle.quaternion.angleTo(legacy.clavicle.quaternion)).toBeLessThan(1e-9); + }); + + it("hands the shoulder girdle back when the arm stops being driven", () => { + const arm = build(gunSpec(OUT_OF_REACH, POSTURE)); + const rest = arm.clavicle.quaternion.clone(); + arm.gun.update(1, null, "on"); + expect(arm.clavicle.quaternion.angleTo(rest)).toBeGreaterThan(0.05); + + // Stowing on the back stops driving the support arm: an un-relaxed swing + // would leave the pawn permanently shrugged. + arm.gun.setStowed(true, { snap: true }); + arm.gun.update(1, null, "on"); + expect(arm.clavicle.quaternion.angleTo(rest)).toBeLessThan(1e-9); + + arm.gun.setStowed(false, { snap: true }); + arm.gun.update(1, null, "on"); + expect(arm.clavicle.quaternion.angleTo(rest)).toBeGreaterThan(0.05); + arm.gun.dispose(); + expect(arm.clavicle.quaternion.angleTo(rest)).toBeLessThan(1e-9); + }); +}); diff --git a/client-3d/src/render/weapons/plasmaPresentation.ts b/client-3d/src/render/weapons/plasmaPresentation.ts new file mode 100644 index 00000000..5bc80d39 --- /dev/null +++ b/client-3d/src/render/weapons/plasmaPresentation.ts @@ -0,0 +1,104 @@ +// plasmaPresentation.ts — the plasma sword's blade FX, in ONE place. +// +// World pawns, the paper doll and the actor preview all used to hand-roll this +// and drifted apart. They also all inherited the same defect: the hilt was +// parented straight onto the SwordRig's weaponRoot, which for the borrowed +// Vibrosword spec sits 0.1525 m up the blade from the palm, so hilt and blade +// floated in mid-air ahead of hand_r. +// +// The plasma sword is now a real catalogue model (plasma_hilt_attach.json), so +// SwordRig welds the hilt correctly on its own. All that is left here is the +// pure-effect blade, which must emit from the hilt's authored `emitter` +// socket rather than from the weapon root. +import type { Object3D, Texture, Vector3 } from "three"; +import { Group } from "three"; +import { PlasmaBlade } from "./plasmaBlade"; +import type { SlugthrowerAttachSpec, VibroswordAttachSpec } from "../../assets/pawnRigTypes"; + +/** Ignition ramp: full blade in ~0.13 s, retract slightly faster (~0.10 s). */ +export const PLASMA_IGNITE_PER_SECOND = 1 / 0.13; +export const PLASMA_RETRACT_PER_SECOND = 1 / 0.1; +export const PLASMA_SWORD_ITEM_ID = 3104; +/** Registry asset key the plasma item resolves to (weaponModelRegistry). */ +export const PLASMA_SWORD_MODEL_KEY = "plasma_sword"; +export const PLASMA_SWORD_COLOR = 0x63f0ff; + +/** + * Emitter offset baked into PlasmaBlade. The authored hilt puts its emitter at + * exactly this z, so mounting the blade at the emitter socket and letting + * PlasmaBlade apply its own offset would double it. + */ +const BLADE_BUILTIN_EMITTER_OFFSET_M = 0.09; + +/** Minimal surface the presentation needs from a melee rig. */ +export interface PlasmaHost { + frameRoot(): Object3D; +} + +/** + * Blade FX welded to a plasma hilt. One instance per presentation surface; + * `dispose()` releases the blade and detaches the mount. + */ +export class PlasmaPresentation { + private readonly mount = new Group(); + private readonly blade: PlasmaBlade; + private extension: number; + private targetExtension: number; + + constructor(host: PlasmaHost, sprite: Texture, colorHex: number, ignited: boolean) { + const root = host.frameRoot(); + // PlasmaBlade builds along +Z from its parent origin and adds its own + // emitter offset, so the mount sits at the hilt origin, not the emitter + // socket — the authored emitter z and the blade's built-in offset are the + // same 0.09 m by construction. + this.mount.name = "plasma:blade-mount"; + this.mount.position.set(0, 0, 0); + root.add(this.mount); + this.blade = new PlasmaBlade(this.mount, sprite, colorHex); + this.extension = ignited ? 1 : 0; + this.targetExtension = this.extension; + this.blade.setExtension(this.extension); + } + + /** Stowed = retracted into the hilt; wielded = ignited. Ramps, never pops. */ + setIgnited(ignited: boolean, options?: { readonly snap?: boolean }): void { + this.targetExtension = ignited ? 1 : 0; + if (options?.snap === true) { + this.extension = this.targetExtension; + this.blade.setExtension(this.extension); + } + } + + isIgnited(): boolean { + return this.targetExtension > 0.5; + } + + currentExtension(): number { + return this.extension; + } + + update(dtSeconds: number): void { + if (this.extension !== this.targetExtension) { + const rate = this.targetExtension > this.extension + ? PLASMA_IGNITE_PER_SECOND + : -PLASMA_RETRACT_PER_SECOND; + this.extension = Math.min(1, Math.max(0, this.extension + rate * dtSeconds)); + this.blade.setExtension(this.extension); + } + this.blade.update(dtSeconds); + } + + dispose(): void { + this.blade.dispose(); + this.mount.parent?.remove(this.mount); + } +} + +/** + * Blade-tip position in the hilt's own local space, for reach/FX queries. + * Uses the authored muzzle socket when the model supplies one. + */ +export function plasmaTipLocal(spec: SlugthrowerAttachSpec | VibroswordAttachSpec, out: Vector3): Vector3 { + if ("muzzle" in spec.sockets) return out.copy(spec.sockets.muzzle); + return out.set(0, 0, BLADE_BUILTIN_EMITTER_OFFSET_M); +} diff --git a/client-3d/src/render/weapons/slugthrowerRig.ts b/client-3d/src/render/weapons/slugthrowerRig.ts index 5e4f624a..8cb2dead 100644 --- a/client-3d/src/render/weapons/slugthrowerRig.ts +++ b/client-3d/src/render/weapons/slugthrowerRig.ts @@ -13,7 +13,7 @@ // for the effects layer. import { Bone, Euler, Group, Mesh, Object3D, Quaternion, Vector3 } from "three"; import { SUCCESSOR_3D_CONFIG } from "../../config"; -import type { PawnPack, SlugthrowerAttachSpec } from "../../assets/pawnRigTypes"; +import type { PawnPack, SlugthrowerAttachSpec, SupportArmSpec } from "../../assets/pawnRigTypes"; import { TwoBoneIk } from "../anim/twoBoneIk"; interface AxisDials { @@ -30,9 +30,16 @@ export interface Successor3dWeaponDials { restingYawRad: number; /** Weapon-local offset from the foregrip socket to the support-hand WRIST target. */ foregripContactOffset: AxisDials; - /** Spine-local stow socket (out-of-combat back carry) — live-tunable. */ - stowPosOffset: AxisDials; - stowRotOffsetDeg: AxisDials; + /** + * Live stow tuning. These are DELTAS applied on top of the selected model's + * OWN authored stow socket, never a replacement base — otherwise switching + * weapons would silently drag the previous model's carry onto the new one. + * Zero = "use exactly what this model authored". + */ + stowPosDelta: AxisDials; + stowRotDeltaDeg: AxisDials; + /** Drop every live stow delta back to the authored per-model base. */ + resetStow: () => void; } declare global { @@ -72,15 +79,16 @@ const weaponDials: Successor3dWeaponDials = { y: SUCCESSOR_3D_CONFIG.pawnPack.foregripContactOffset.y, z: SUCCESSOR_3D_CONFIG.pawnPack.foregripContactOffset.z, }, - stowPosOffset: { - x: SUCCESSOR_3D_CONFIG.pawnPack.weaponStow.posOffset.x, - y: SUCCESSOR_3D_CONFIG.pawnPack.weaponStow.posOffset.y, - z: SUCCESSOR_3D_CONFIG.pawnPack.weaponStow.posOffset.z, - }, - stowRotOffsetDeg: { - x: SUCCESSOR_3D_CONFIG.pawnPack.weaponStow.rotOffsetDeg.x, - y: SUCCESSOR_3D_CONFIG.pawnPack.weaponStow.rotOffsetDeg.y, - z: SUCCESSOR_3D_CONFIG.pawnPack.weaponStow.rotOffsetDeg.z, + // Deltas start at zero: the authored per-model socket IS the base pose. + stowPosDelta: { x: 0, y: 0, z: 0 }, + stowRotDeltaDeg: { x: 0, y: 0, z: 0 }, + resetStow: () => { + weaponDials.stowPosDelta.x = 0; + weaponDials.stowPosDelta.y = 0; + weaponDials.stowPosDelta.z = 0; + weaponDials.stowRotDeltaDeg.x = 0; + weaponDials.stowRotDeltaDeg.y = 0; + weaponDials.stowRotDeltaDeg.z = 0; }, }; let weaponDialUsers = 0; @@ -155,6 +163,19 @@ export class SlugthrowerRig { private stowT = 0; private stowTarget = 0; private readonly spine: Bone | null; + /** This model's own back carry: authored socket, else the legacy default. */ + private readonly stowBasePos = new Vector3(); + private readonly stowBaseRotDeg = new Vector3(); + private readonly stowArcLift: number; + /** This model's non-aiming bore yaw; authored per weapon, else config. */ + private readonly restingYawRad: number; + private readonly hasAuthoredContact: boolean; + /** + * Authored support-arm hold posture, or null for the legacy unposed solve. + * Only the two-handed foregrip hold uses it — a reload reach is choreography + * and owns its own elbow. + */ + private readonly supportArm: SupportArmSpec | null; constructor( private readonly pack: PawnPack, @@ -214,8 +235,33 @@ export class SlugthrowerRig { let bodyRef: Object3D = handR; while (bodyRef.parent && (bodyRef instanceof Bone || bodyRef.parent instanceof Bone)) bodyRef = bodyRef.parent; this.bodyRef = bodyRef; - this.ik = upperArmL && lowerArmL && handL ? new TwoBoneIk(upperArmL, lowerArmL, handL) : null; + // The shoulder girdle above upperarm_l is what buys reach for a support + // socket authored past the arm; take it from the rig hierarchy rather than + // another ctor argument every presentation surface would have to repeat. + // Name-checked so a body without a clavicle simply gets no reach assist. + const girdle = upperArmL?.parent; + const clavicleL = girdle instanceof Bone && girdle.name === "clavicle_l" ? girdle : null; + this.ik = upperArmL && lowerArmL && handL + ? new TwoBoneIk(upperArmL, lowerArmL, handL, clavicleL) + : null; this.spine = spineBone; + + const stowDefaults = SUCCESSOR_3D_CONFIG.pawnPack.weaponStow; + const authoredStow = this.spec.stow; + if (authoredStow) { + this.stowBasePos.copy(authoredStow.pos); + this.stowBaseRotDeg.copy(authoredStow.rotDeg); + this.stowArcLift = authoredStow.arcLift; + } else { + this.stowBasePos.set(stowDefaults.posOffset.x, stowDefaults.posOffset.y, stowDefaults.posOffset.z); + this.stowBaseRotDeg.set(stowDefaults.rotOffsetDeg.x, stowDefaults.rotOffsetDeg.y, stowDefaults.rotOffsetDeg.z); + this.stowArcLift = stowDefaults.arcLift; + } + this.restingYawRad = this.spec.restingYawRad ?? SUCCESSOR_3D_CONFIG.pawnPack.boreLevel.restingYawRad; + this.hasAuthoredContact = this.spec.sockets.foregripContact !== undefined; + this.supportArm = this.spec.supportArm ?? null; + // A new model must never inherit the previous one's live stow tuning. + (window.__successor3dWeapon ?? weaponDials).resetStow(); retainWeaponDials(); } @@ -320,6 +366,8 @@ export class SlugthrowerRig { ): void { this.updateFallenMag(dtSeconds); if (this.dropped) { + // The corpse's arms belong to the death montage; give the girdle back. + this.ik?.relax(); this.updateDropped(dtSeconds); return; } @@ -327,9 +375,8 @@ export class SlugthrowerRig { this.lastLevelAppliedRad = 0; // set by the leveling block when it corrects // ── Out-of-combat stow ease (owner spec 2026-07-03) ───────────────────── - const stow = config.weaponStow; if (this.stowT !== this.stowTarget) { - const stowStep = dtSeconds / Math.max(1e-3, stow.blendSeconds); + const stowStep = dtSeconds / Math.max(1e-3, config.weaponStow.blendSeconds); this.stowT += Math.min(stowStep, Math.abs(this.stowTarget - this.stowT)) * Math.sign(this.stowTarget - this.stowT); } const stowEase = this.stowT * this.stowT * (3 - 2 * this.stowT); // smoothstep @@ -396,7 +443,11 @@ export class SlugthrowerRig { const dials = window.__successor3dWeapon ?? weaponDials; const yawStrength = finiteDial(dials.yawStrength, level.yawStrength); const maxYawCorrectionRad = Math.max(0, finiteDial(dials.maxYawCorrectionRad, level.maxYawCorrectionRad)); - const restingYawRad = finiteDial(dials.restingYawRad, level.restingYawRad); + // The model's authored resting yaw is the base; the live dial contributes + // its own displacement from the config default so A/B tuning still works + // without erasing per-weapon authoring. + const restingYawRad = this.restingYawRad + + (finiteDial(dials.restingYawRad, level.restingYawRad) - level.restingYawRad); const weldTarget = level.strength > 0 || yawStrength > 0 ? 1 : 0; if (this.weldBlend !== weldTarget) { const weldStep = Math.min(1, dtSeconds / WELD_BLEND_SECONDS); @@ -508,19 +559,21 @@ export class SlugthrowerRig { // spine socket), so the flourish is a pure morph between moving anchors — // the same live-endpoint rule the IK targets follow. if (this.stowT > 0 && this.spine) { - const stowPosDial = dials.stowPosOffset; - const stowRotDial = dials.stowRotOffsetDeg; + // Authored per-model socket + live DELTA. Zero deltas reproduce the + // model's own carry exactly; the dials never impose another model's base. + const stowPosDelta = dials.stowPosDelta; + const stowRotDelta = dials.stowRotDeltaDeg; stowEulerScratch.set( - finiteDial(stowRotDial.x, 0) * DEG_TO_RAD, - finiteDial(stowRotDial.y, 0) * DEG_TO_RAD, - finiteDial(stowRotDial.z, 0) * DEG_TO_RAD, + (this.stowBaseRotDeg.x + finiteDial(stowRotDelta.x, 0)) * DEG_TO_RAD, + (this.stowBaseRotDeg.y + finiteDial(stowRotDelta.y, 0)) * DEG_TO_RAD, + (this.stowBaseRotDeg.z + finiteDial(stowRotDelta.z, 0)) * DEG_TO_RAD, "XYZ", ); stowLocalQuatScratch.setFromEuler(stowEulerScratch); stowWorldPosScratch.set( - finiteDial(stowPosDial.x, 0), - finiteDial(stowPosDial.y, 0), - finiteDial(stowPosDial.z, 0), + this.stowBasePos.x + finiteDial(stowPosDelta.x, 0), + this.stowBasePos.y + finiteDial(stowPosDelta.y, 0), + this.stowBasePos.z + finiteDial(stowPosDelta.z, 0), ); this.spine.localToWorld(stowWorldPosScratch); this.spine.getWorldQuaternion(stowWorldQuatScratch).multiply(stowLocalQuatScratch); @@ -532,7 +585,7 @@ export class SlugthrowerRig { this.weaponRoot.getWorldQuaternion(heldWorldQuatScratch); blendWorldPosScratch.lerpVectors(heldWorldPosScratch, stowWorldPosScratch, stowEase); // Arc over the shoulder, never through the torso. - blendWorldPosScratch.y += stow.arcLift * Math.sin(Math.PI * stowEase); + blendWorldPosScratch.y += this.stowArcLift * Math.sin(Math.PI * stowEase); blendWorldQuatScratch.copy(heldWorldQuatScratch).slerp(stowWorldQuatScratch, stowEase); } this.handR.getWorldQuaternion(handQuatScratch); @@ -552,6 +605,9 @@ export class SlugthrowerRig { this.ikWeight += Math.min(step, Math.abs(ikTargetWeight - this.ikWeight)) * Math.sign(ikTargetWeight - this.ikWeight); } if (!this.ik || this.ikWeight < 1e-3) { + // Stowed / montage-owned / unarmed: hand the shoulder girdle back before + // the arm stops being driven, or a hold posture leaves it shrugged. + this.ik?.relax(); return; } @@ -571,6 +627,10 @@ export class SlugthrowerRig { this.targetBlendRemaining = this.targetBlendSeconds; } this.computeTargetWorld(this.targetMode, targetScratch); + // The hold posture belongs to the two-handed grip on THIS weapon, so it + // rides the same morph as the target: a mag/pouch reach fades it out and + // hands the support elbow back to the reload choreography. + let holdShare = this.targetMode === "foregrip" ? 1 : 0; if (this.targetBlendRemaining > 0) { const blendSeconds = Math.max(1e-3, this.targetBlendSeconds || config.ikEaseSeconds); this.targetBlendRemaining = Math.max(0, this.targetBlendRemaining - dtSeconds); @@ -578,10 +638,12 @@ export class SlugthrowerRig { const blend = linearBlend * linearBlend * (3 - 2 * linearBlend); // smoothstep this.computeTargetWorld(this.previousMode, previousScratch); this.currentTarget.lerpVectors(previousScratch, targetScratch, blend); + const previousShare = this.previousMode === "foregrip" ? 1 : 0; + holdShare = previousShare + (holdShare - previousShare) * blend; } else { this.currentTarget.copy(targetScratch); } - this.ik.solve(this.currentTarget, this.ikWeight); + this.ik.solve(this.currentTarget, this.ikWeight, this.supportArm, this.ikWeight * holdShare); } /** Muzzle socket world position. Requires current world matrices. */ @@ -603,6 +665,7 @@ export class SlugthrowerRig { } dispose(): void { + this.ik?.relax(); this.weaponRoot.parent?.remove(this.weaponRoot); if (this.fallenMag) { this.fallenMag.parent?.remove(this.fallenMag); @@ -713,13 +776,20 @@ export class SlugthrowerRig { this.bodyRef.localToWorld(out); return; } - // Wrist target = foregrip socket + palm-contact offset (weapon-local), - // so the palm cups the foregrip instead of the barrel riding the wrist. - const contact = weaponDials.foregripContactOffset; - out.copy(this.spec.sockets.foregrip); - out.x += contact.x; - out.y += contact.y; - out.z += contact.z; + // Wrist target for the support hand. An authored per-model contact point + // wins outright: the global offset assumes the legacy handguard radius at + // scale 1, so on a fat, scaled or caged shroud it buries the wrist inside + // the housing instead of cupping it. + const authored = this.spec.sockets.foregripContact; + if (this.hasAuthoredContact && authored) { + out.copy(authored); + } else { + const contact = weaponDials.foregripContactOffset; + out.copy(this.spec.sockets.foregrip); + out.x += contact.x; + out.y += contact.y; + out.z += contact.z; + } this.frame.localToWorld(out); } } diff --git a/client-3d/src/render/weapons/swordRig.ts b/client-3d/src/render/weapons/swordRig.ts index d52643fa..78394b86 100644 --- a/client-3d/src/render/weapons/swordRig.ts +++ b/client-3d/src/render/weapons/swordRig.ts @@ -1,10 +1,15 @@ -// swordRig.ts — per-pawn vibrosword attachment (hand weld + back stow). +// swordRig.ts — per-pawn melee attachment (hand weld + back stow). // -// Contract (vibrosword_attach.json + melee animation contract): -// - The vibrosword frame is welded to hand_r with mount_hand_r_local pos/quat -// AS AUTHORED. There is no bore leveling and no IK in melee v1. -// - Out of combat the sword rides a spine_03-relative back socket, sharing the -// Slugthrower stow mechanics but using swordStow tuning for blade-down diagonal carry. +// Contract (_attach.json + melee animation contract): +// - The frame is welded to hand_r with mount_hand_r_local pos/quat AS +// AUTHORED. There is no bore leveling and no IK in melee v1. +// - Out of combat the weapon rides a spine_03-relative back socket. That +// socket is AUTHORED PER MODEL (spec.stow) because the catalogue mixes +// coordinate conventions: the legacy Vibrosword is a +Z-blade frame while +// the melee_early family (machete / saber / chopper) is +Y-blade. Forcing +// one shared rotation onto both laid the +Y blades horizontally across the +// pawn's neck. A model without an authored socket falls back to the legacy +// swordStow tuning, which is what keeps the Vibrosword pose byte-identical. import { Bone, Euler, Group, Mesh, Object3D, Quaternion, Vector3 } from "three"; import { SUCCESSOR_3D_CONFIG } from "../../config"; import type { PawnPack, SlugthrowerAttachSpec, VibroswordAttachSpec } from "../../assets/pawnRigTypes"; @@ -12,7 +17,6 @@ import type { PawnPack, SlugthrowerAttachSpec, VibroswordAttachSpec } from "../. const DEG_TO_RAD = Math.PI / 180; const stowWorldPosScratch = new Vector3(); const stowWorldQuatScratch = new Quaternion(); -const stowLocalQuatScratch = new Quaternion(); const stowEulerScratch = new Euler(); const heldWorldPosScratch = new Vector3(); const heldWorldQuatScratch = new Quaternion(); @@ -27,6 +31,10 @@ export class SwordRig { /** 0 = held in hand_r, 1 = stowed on the back; eases toward stowTarget. */ private stowT = 0; private stowTarget = 0; + /** This model's own carry, resolved once: authored socket or legacy default. */ + private readonly stowPos = new Vector3(); + private readonly stowQuat = new Quaternion(); + private readonly stowArcLift: number; constructor( pack: PawnPack, @@ -44,6 +52,28 @@ export class SwordRig { this.weaponRoot.quaternion.copy(spec.mountQuat); this.weaponRoot.scale.setScalar(weaponScale); handR.add(this.weaponRoot); + + const legacy = SUCCESSOR_3D_CONFIG.pawnPack.swordStow; + const authored = spec.stow; + if (authored) { + this.stowPos.copy(authored.pos); + this.stowQuat.setFromEuler(stowEulerScratch.set( + authored.rotDeg.x * DEG_TO_RAD, + authored.rotDeg.y * DEG_TO_RAD, + authored.rotDeg.z * DEG_TO_RAD, + "XYZ", + )); + this.stowArcLift = authored.arcLift; + } else { + this.stowPos.set(legacy.posOffset.x, legacy.posOffset.y, legacy.posOffset.z); + this.stowQuat.setFromEuler(stowEulerScratch.set( + legacy.rotOffsetDeg.x * DEG_TO_RAD, + legacy.rotOffsetDeg.y * DEG_TO_RAD, + legacy.rotOffsetDeg.z * DEG_TO_RAD, + "XYZ", + )); + this.stowArcLift = legacy.arcLift; + } } setVisible(visible: boolean): void { @@ -60,6 +90,18 @@ export class SwordRig { this.frame.visible = visible; } + /** + * Weapon-local palm point. Anything welded into the hand (the dev plasma + * hilt preview) must sit HERE, not at the weaponRoot origin: on a model + * whose origin is up the blade — the Vibrosword's is 0.1525 m up — the + * origin is out in mid-air ahead of the hand. + */ + gripLocal(out: Vector3): Vector3 { + const sockets = this.spec.sockets; + if ("grip" in sockets) return out.copy(sockets.grip); + return out.copy(sockets.wrapMid); + } + /** * Spine-local back carry. The root remains hand_r-parented; every update * converts the live spine socket world pose back into hand_r local space, so @@ -75,9 +117,8 @@ export class SwordRig { } update(dtSeconds: number): void { - const stow = SUCCESSOR_3D_CONFIG.pawnPack.swordStow; if (this.stowT !== this.stowTarget) { - const stowStep = dtSeconds / Math.max(1e-3, stow.blendSeconds); + const stowStep = dtSeconds / Math.max(1e-3, SUCCESSOR_3D_CONFIG.pawnPack.swordStow.blendSeconds); this.stowT += Math.min(stowStep, Math.abs(this.stowTarget - this.stowT)) * Math.sign(this.stowTarget - this.stowT); } const stowEase = this.stowT * this.stowT * (3 - 2 * this.stowT); @@ -89,16 +130,10 @@ export class SwordRig { if (this.stowT <= 0 || !this.spine) return; - stowEulerScratch.set( - stow.rotOffsetDeg.x * DEG_TO_RAD, - stow.rotOffsetDeg.y * DEG_TO_RAD, - stow.rotOffsetDeg.z * DEG_TO_RAD, - "XYZ", - ); - stowLocalQuatScratch.setFromEuler(stowEulerScratch); - stowWorldPosScratch.set(stow.posOffset.x, stow.posOffset.y, stow.posOffset.z); + // Per-model socket, resolved in the ctor — no per-frame euler rebuild. + stowWorldPosScratch.copy(this.stowPos); this.spine.localToWorld(stowWorldPosScratch); - this.spine.getWorldQuaternion(stowWorldQuatScratch).multiply(stowLocalQuatScratch); + this.spine.getWorldQuaternion(stowWorldQuatScratch).multiply(this.stowQuat); if (stowEase >= 1 - 1e-4) { blendWorldPosScratch.copy(stowWorldPosScratch); @@ -107,7 +142,7 @@ export class SwordRig { this.weaponRoot.getWorldPosition(heldWorldPosScratch); this.weaponRoot.getWorldQuaternion(heldWorldQuatScratch); blendWorldPosScratch.lerpVectors(heldWorldPosScratch, stowWorldPosScratch, stowEase); - blendWorldPosScratch.y += stow.arcLift * Math.sin(Math.PI * stowEase); + blendWorldPosScratch.y += this.stowArcLift * Math.sin(Math.PI * stowEase); blendWorldQuatScratch.copy(heldWorldQuatScratch).slerp(stowWorldQuatScratch, stowEase); } diff --git a/client-3d/src/ui/charDollPreview.ts b/client-3d/src/ui/charDollPreview.ts index 2817b8d3..e069f5f9 100644 --- a/client-3d/src/ui/charDollPreview.ts +++ b/client-3d/src/ui/charDollPreview.ts @@ -13,7 +13,16 @@ import { Vector3, WebGLRenderer, } from "three"; -import { clonePawnBody, loadPawnPack, type PawnBody, type PawnEquipmentItem, type PawnPack } from "../assets/pawnPack"; +import { + applyPawnBodyZoneMask, + clonePawnBody, + collectPawnBodyZoneMeshes, + loadPawnPack, + resolvePawnBodyZoneMask, + type PawnBody, + type PawnEquipmentItem, + type PawnPack, +} from "../assets/pawnPack"; import { attachPawnEquipmentSet, createPawnMatcapTexture } from "../render/pawns"; import { attachPawnFaceDecal, faceSignature, type PawnFaceConfig } from "../render/faceDecal"; import { @@ -163,13 +172,6 @@ export async function mountCharDollPreview(host: HTMLElement): Promise { - if (object instanceof SkinnedMesh) object.material = skin; - }); // Worn gear: same equipment attach/material resolver as in-world pawns. // Appearance hair is independent from inventory headwear. const equipmentIds: string[] = []; @@ -182,13 +184,25 @@ export async function mountCharDollPreview(host: HTMLElement): Promise { + if (object instanceof SkinnedMesh) object.material = skin; + }); + const attachedItemIds = attachPawnEquipmentSet( loaded, body, attachIds, equipmentMaterial, equipmentAttachments, ); + applyPawnBodyZoneMask( + bodyZoneMeshes, + resolvePawnBodyZoneMask(loaded.equipment, attachedItemIds), + ); attachPawnFaceDecal(body, dollFaceToWire(appearance.face ?? null), equipmentAttachments); const materialGeneration = equipmentMaterialGeneration; void getEquipmentMaterialSets().then(() => { diff --git a/client-3d/src/ui/characterSelect.creatorDraft.test.ts b/client-3d/src/ui/characterSelect.creatorDraft.test.ts index f0df2ba4..b51b678f 100644 --- a/client-3d/src/ui/characterSelect.creatorDraft.test.ts +++ b/client-3d/src/ui/characterSelect.creatorDraft.test.ts @@ -28,7 +28,7 @@ const DRESSED_RECORD: CharacterApiRecord = { id: "char-dressed", ownerRef: "acct-1", name: "Chungas", - appearance: { skinTone: "#4a3223", hair: "hair_afro2", hairMat: "hair_crimson", face: null }, + appearance: { body: "female", skinTone: "#4a3223", hair: "hair_afro2", hairMat: "hair_crimson", face: null }, worn: [{ item: "top_rigged_tank", colors: ["#804040", "#3f7472"] }], position: null, lastLogoutAt: null, @@ -99,6 +99,7 @@ describe("character creator blank draft", () => { root.querySelector('[data-ref="newButton"]')!.click(); const probe = window.__successor3dCharacterSelect!; + expect(dollAppearances.at(-1)?.body).toBe("male"); expect(probe.mode).toBe("create"); // Blank canonical draft: SHAVED — no selected appearance, hair, or dye // state retained. Clothing is not draft state at all: the doll previews @@ -162,6 +163,8 @@ describe("character creator blank draft", () => { await vi.waitFor(() => expect(window.__successor3dCharacterSelect?.characterCount).toBe(1)); root.querySelector('[data-ref="newButton"]')!.click(); + root.querySelectorAll('[data-ref="bodyStepper"] button')[1]!.click(); + expect(dollAppearances.at(-1)?.body).toBe("female"); const createButton = root.querySelector('[data-ref="createButton"]')!; expect(createButton.disabled).toBe(true); expect(window.__successor3dCharacterSelect?.draftInitialProfessionId).toBeNull(); @@ -179,6 +182,7 @@ describe("character creator blank draft", () => { name: "Bruiser", initialProfessionId: "brawler", appearance: { + body: "female", face: { eyes: "stoic", brows: "stoic", @@ -220,9 +224,10 @@ describe("character creator blank draft", () => { expect(root.querySelector('[data-ref="wardrobeSections"]')).toBeNull(); expect(root.querySelectorAll(".sc3d-cs-stepper[data-slot]")).toHaveLength(0); expect(root.querySelectorAll(".sc3d-cs-zonelabel")).toHaveLength(0); - // Appearance steppers: HAIR plus the four FACE feature steppers — still - // zero clothing steppers ([data-slot] stays empty). - expect(root.querySelectorAll(".sc3d-cs-stepper")).toHaveLength(5); + // Appearance steppers: BODY, HAIR, and the four FACE feature steppers — + // still zero clothing steppers ([data-slot] stays empty). + expect(root.querySelectorAll(".sc3d-cs-stepper")).toHaveLength(6); + expect(root.querySelector('[data-ref="bodyStepper"]')).not.toBeNull(); expect(root.querySelector('[data-ref="hairStepper"]')).not.toBeNull(); for (const ref of ["faceEyesStepper", "faceBrowsStepper", "faceNoseStepper", "faceMouthStepper"]) { expect(root.querySelector(`[data-ref="${ref}"]`)).not.toBeNull(); diff --git a/client-3d/src/ui/characterSelect.ts b/client-3d/src/ui/characterSelect.ts index 55eb1c10..87e98c92 100644 --- a/client-3d/src/ui/characterSelect.ts +++ b/client-3d/src/ui/characterSelect.ts @@ -124,6 +124,7 @@ const FIXED_STARTER_WORN: readonly CharacterWornPiece[] = FIXED_STARTER_ITEM_IDS * masquerade as the "new" draft when defaults matched its appearance). * hairMat stays preset so picking any style lands on a sane color. */ const DEFAULT_APPEARANCE: CharDollAppearance = { + body: "male", skinTone: SKIN_TONES[1], hair: null, hairMat: "hair_umber", @@ -215,6 +216,8 @@ export function renderCharacterSelect(root: HTMLElement, injectedPort?: Characte
INITIAL PROFESSION16 SKILL POINTS

A normal novice allocation. Skill points only — no gear comes with it. You can unlearn it later.

+
BODY2 TYPES
+
SKIN
HAIR
@@ -265,6 +268,7 @@ export function renderCharacterSelect(root: HTMLElement, injectedPort?: Characte nameInput: required(root, '[data-ref="nameInput"]'), nameNote: required(root, '[data-ref="nameNote"]'), createProfessionGrid: required(root, '[data-ref="createProfessionGrid"]'), + bodyStepper: required(root, '[data-ref="bodyStepper"]'), skinRow: required(root, '[data-ref="skinRow"]'), hairCount: required(root, '[data-ref="hairCount"]'), hairStepper: required(root, '[data-ref="hairStepper"]'), @@ -511,6 +515,13 @@ export function renderCharacterSelect(root: HTMLElement, injectedPort?: Characte }; const renderCreatorRows = (): void => { + const body = draft.body ?? "male"; + stepper(refs.bodyStepper, body === "female" ? "BODY TYPE 2" : "BODY TYPE 1", () => { + draft.body = body === "female" ? "male" : "female"; + renderCreatorRows(); + syncDoll(); + }); + refs.skinRow.textContent = ""; for (const tone of SKIN_TONES) { refs.skinRow.appendChild(swatchButton(tone, tone, draft.skinTone === tone, () => { diff --git a/client-3d/src/ui/hostedCharacterCreator.test.ts b/client-3d/src/ui/hostedCharacterCreator.test.ts index f0cc233e..3a369e2f 100644 --- a/client-3d/src/ui/hostedCharacterCreator.test.ts +++ b/client-3d/src/ui/hostedCharacterCreator.test.ts @@ -36,14 +36,14 @@ const minimalSiteCharacter = { name: "Mara", initialProfessionId: "scout" as const, worldEntryClaimed: false, - appearance: { skinTone: "#d1a679", hair: null, hairMat: "hair_umber", face: null }, + appearance: { body: "female" as const, skinTone: "#d1a679", hair: null, hairMat: "hair_umber", face: null }, worn: [], }; const character = { id: "char-1", name: "Marlow", - appearance: { skinTone: "#d1a679", hair: null, hairMat: "hair_umber", face: null }, + appearance: { body: "male" as const, skinTone: "#d1a679", hair: null, hairMat: "hair_umber", face: null }, position: null, lastLogoutAt: null, lastSeenAt: null, @@ -73,6 +73,7 @@ describe("hosted creator protocol", () => { fake.emit({ source: fake.parent, origin: "https://attacker.example", data: { type: CREATOR_STATE, characters: [character] } } as MessageEvent); fake.emit({ source: {} as Window, origin: ORIGIN, data: { type: CREATOR_STATE, characters: [character] } } as MessageEvent); fake.emit({ source: fake.parent, origin: ORIGIN, data: { type: CREATOR_STATE, characters: [{ ...character, ownerRef: "secret" }] } } as MessageEvent); + fake.emit({ source: fake.parent, origin: ORIGIN, data: { type: CREATOR_STATE, characters: [{ ...character, appearance: { ...character.appearance, body: "synthetic" } }] } } as MessageEvent); fake.emit({ source: fake.parent, origin: ORIGIN, data: { type: CREATOR_STATE, characters: [minimalSiteCharacter] } } as MessageEvent); await expect(pending).resolves.toMatchObject({ characters: [{ id: "char-min", position: null, totalPlayMs: 0, liveState: "offline" }] }); expect(fake.sent[0]).toEqual({ value: { type: "successor.creator.ready.v1" }, origin: ORIGIN }); diff --git a/client-3d/src/ui/hostedCharacterCreator.ts b/client-3d/src/ui/hostedCharacterCreator.ts index 1a4f086e..f3c7a08c 100644 --- a/client-3d/src/ui/hostedCharacterCreator.ts +++ b/client-3d/src/ui/hostedCharacterCreator.ts @@ -237,8 +237,8 @@ function normalizeHostedCharacter(value: unknown): HostedCharacterRecord | null } function isHostedAppearance(value: unknown): value is CharDollAppearance { - if (!isPlainRecord(value) || Object.keys(value).some((key) => FORBIDDEN_KEYS.has(key) || !["skinTone", "hair", "hairMat", "face"].includes(key))) return false; - if (typeof value.skinTone !== "string" || value.skinTone.length > 64 || (value.hair !== null && typeof value.hair !== "string") || (typeof value.hair === "string" && value.hair.length > 64) || typeof value.hairMat !== "string" || value.hairMat.length > 64) return false; + if (!isPlainRecord(value) || Object.keys(value).some((key) => FORBIDDEN_KEYS.has(key) || !["body", "skinTone", "hair", "hairMat", "face"].includes(key))) return false; + if ((value.body !== "male" && value.body !== "female") || typeof value.skinTone !== "string" || value.skinTone.length > 64 || (value.hair !== null && typeof value.hair !== "string") || (typeof value.hair === "string" && value.hair.length > 64) || typeof value.hairMat !== "string" || value.hairMat.length > 64) return false; if (!Object.prototype.hasOwnProperty.call(value, "face")) return false; if (value.face === null) return true; if (!isPlainRecord(value.face) || Object.keys(value.face).some((key) => !["eyes", "brows", "nose", "mouth", "eyeColor", "browColor", "lipColor"].includes(key))) return false; diff --git a/client-3d/src/ui/hud/chatPane.ts b/client-3d/src/ui/hud/chatPane.ts index e04c4b10..bd527f5d 100644 --- a/client-3d/src/ui/hud/chatPane.ts +++ b/client-3d/src/ui/hud/chatPane.ts @@ -103,6 +103,7 @@ export function createChatPaneClient( }, zoneId: identity.zoneId, authTicket: identity.standalone ? identity.chatTicket : undefined, + authReleaseId: identity.standalone ? identity.clientReleaseId : undefined, onFailure: identity.standalone ? onLaunchFailure : undefined, // The bubble message passes through unchanged — no actorId; the shared // bubble system's fallback rule diff --git a/client-3d/src/ui/inventory/actorPreview.ts b/client-3d/src/ui/inventory/actorPreview.ts index 2ff4a1c0..dd81ca92 100644 --- a/client-3d/src/ui/inventory/actorPreview.ts +++ b/client-3d/src/ui/inventory/actorPreview.ts @@ -1,4 +1,5 @@ import { + type CanvasTexture, Color, Group, Material, @@ -21,8 +22,11 @@ import { } from "../../assets/equipmentMaterials"; import { installPawnRim } from "../../render/pawnRim"; import { + applyPawnBodyZoneMask, clonePawnBody, cloneSpecialPawnBody, + collectPawnBodyZoneMeshes, + resolvePawnBodyZoneMask, type PawnBody, type PawnEquipmentItem, type PawnPack, @@ -53,8 +57,17 @@ import { attachPawnFaceDecal, faceSignature, type PawnFaceConfig } from "../../r import { clampTurntableZoom } from "../turntableInteraction"; import { SlugthrowerRig } from "../../render/weapons/slugthrowerRig"; import { SwordRig } from "../../render/weapons/swordRig"; +import { + PlasmaPresentation, + PLASMA_SWORD_COLOR, + PLASMA_SWORD_MODEL_KEY, +} from "../../render/weapons/plasmaPresentation"; +import { makeGlowSprite } from "../../render/fx/particles"; import { resolveWieldPose } from "./wieldPose"; +/** Glow sprite shared by every preview rebuild (module-lifetime, like the doll's). */ +let previewGlowSprite: CanvasTexture | null = null; + export interface ActorPreviewRenderVM { open: boolean; actorId: string | null; @@ -192,6 +205,7 @@ export class ActorPreviewRenderer { private animator: PawnAnimator | null = null; private slugthrowerRig: SlugthrowerRig | null = null; private swordRig: SwordRig | null = null; + private plasma: PlasmaPresentation | null = null; private holdingWeapon = false; private weaponStowed = false; // Pack convention: yaw-0 IS the rig's authored FRONT (pawnYaw.ts law). @@ -250,6 +264,7 @@ export class ActorPreviewRenderer { this.slugthrowerRig?.update(Math.min(0.05, Math.max(0, dtSeconds)), null, this.holdingWeapon ? "auto" : "off"); this.swordRig?.setStowed(this.weaponStowed); this.swordRig?.update(Math.min(0.05, Math.max(0, dtSeconds))); + this.plasma?.update(Math.min(0.05, Math.max(0, dtSeconds))); renderer.setScissorTest(true); renderer.setViewport(0, 0, width, height); @@ -289,6 +304,7 @@ export class ActorPreviewRenderer { const bodyRoot = look.specialBodyKey ? cloneSpecialPawnBody(this.pack, look.specialBodyKey) ?? clonePawnBody(this.pack, look.body) : clonePawnBody(this.pack, look.body); + const bodyZoneMeshes = look.specialBodyKey ? [] : collectPawnBodyZoneMeshes(bodyRoot); const specialBodyLoaded = Boolean(look.specialBodyKey && this.pack.specialBodies.has(look.specialBodyKey)); bodyRoot.name = `successor-examine-preview:${actorId}`; bodyRoot.rotation.set(0, 0, 0); @@ -347,13 +363,17 @@ export class ActorPreviewRenderer { if (isDowned) this.animator.playMontage("death_b", { holdEnd: true, startAtEnd: true }); if (!specialBodyLoaded) { - attachPawnEquipmentSet( + const attachedItemIds = attachPawnEquipmentSet( this.pack, bodyRoot, look.equipmentIds, (item, source) => this.equipmentMaterial(item, source), this.equipmentAttachments, ); + applyPawnBodyZoneMask( + bodyZoneMeshes, + resolvePawnBodyZoneMask(this.pack.equipment, attachedItemIds), + ); attachPawnFaceDecal(bodyRoot, look.face, this.equipmentAttachments); } const materialGeneration = ++this.equipmentMaterialGeneration; @@ -397,6 +417,12 @@ export class ActorPreviewRenderer { ); this.swordRig.setVisible(true); this.swordRig.setStowed(stowed, { snap: true }); + if (look.weaponModelKey === PLASMA_SWORD_MODEL_KEY) { + // Same presentation object the world pawn and paper doll use, so + // the hilt/blade relationship cannot drift between the three. + previewGlowSprite ??= makeGlowSprite(); + this.plasma = new PlasmaPresentation(this.swordRig, previewGlowSprite, PLASMA_SWORD_COLOR, !stowed); + } } } } @@ -405,6 +431,8 @@ export class ActorPreviewRenderer { this.equipmentMaterialGeneration += 1; this.slugthrowerRig?.dispose(); this.slugthrowerRig = null; + this.plasma?.dispose(); + this.plasma = null; this.swordRig?.dispose(); this.swordRig = null; this.animator?.dispose(); diff --git a/client-3d/src/ui/inventory/data.test.ts b/client-3d/src/ui/inventory/data.test.ts index dce755d2..e3e007ae 100644 --- a/client-3d/src/ui/inventory/data.test.ts +++ b/client-3d/src/ui/inventory/data.test.ts @@ -165,6 +165,55 @@ describe("buildInventoryViewModel weapon items", () => { expect(weaponIdForInventoryItemId(3103)).toBe("vibrosword"); expect(modelPathForItemId(3103)).toBe("/assets/pawn-pack/vibrosword.glb"); }); + + it("classifies every concrete weapon presentation and emits its exact inventory identity", () => { + const weaponPresentations = [ + [3101, "Slugthrower", "slugthrower", "/assets/pawn-pack/weapons/custom/wpn_smg_sten_mk2.glb"], + [3103, "Vibrosword", "vibrosword", "/assets/pawn-pack/vibrosword.glb"], + [3104, "Plasma Sword", "vibrosword", "/assets/pawn-pack/plasma_hilt.glb"], + [3105, "Scrapline Machete", "scrapline-machete", "/assets/pawn-pack/weapons/custom/scrapline_machete.glb"], + [3106, "Field Saber", "field-saber", "/assets/pawn-pack/weapons/custom/field_saber.glb"], + [3107, "Quarry Chopper", "quarry-chopper", "/assets/pawn-pack/weapons/custom/quarry_chopper.glb"], + [3111, "STEN Mk II", "wpn-smg", "/assets/pawn-pack/weapons/custom/wpn_smg_sten_mk2.glb"], + [3112, "Kiln Energy Cell Carbine", "wpn-carbine", "/assets/pawn-pack/weapons/custom/wpn_carbine_kiln.glb"], + [3121, "Lightning Carbine", "lightning-carbine", "/assets/pawn-pack/weapons/custom/lightning_carbine.glb"], + [3122, "Badge Bolt Pistol", "wpn-pistol", "/assets/pawn-pack/weapons/custom/wpn_pistol_badge_bolt.glb"], + [3123, "Slagrail Vanguard", "wpn-assault", "/assets/pawn-pack/weapons/custom/wpn_rifle_slagrail_vanguard.glb"], + [3124, "Coilgate Scatter", "wpn-shotgun", "/assets/pawn-pack/weapons/custom/wpn_shotgun_coilgate_scatter.glb"], + [3125, "Kiln Long Pattern", "wpn-sniper", "/assets/pawn-pack/weapons/custom/wpn_carbine_kiln.glb"], + [3126, "Bastion LMG", "wpn-heavy", "/assets/pawn-pack/weapons/custom/wpn_heavy_bastion_lmg.glb"], + [3127, "Flare Net Launcher", "wpn-launcher", "/assets/pawn-pack/weapons/custom/wpn_launcher_flare_net.glb"], + ] as const; + const state = playState(weaponPresentations.map(([itemId, item]) => ({ + container: "player:field-pack", + item, + itemId, + variantId: 0, + quantity: 1, + reserved: 0, + available: 1, + stackId: itemId, + }))); + state.serverAuthority.enabled = true; + state.loadout.activeWeaponId = null; + state.loadout.equipped.longGun = null; + const slice = sliceWithInventory(state.inventory); + const inventory = buildInventoryViewModel(state, { open: true, selectedKey: null, hoveredKey: null }); + + expect(inventory.items).toHaveLength(15); + for (const [itemId, label, weaponId, glb] of weaponPresentations) { + const item = inventory.items.find((candidate) => candidate.itemId === itemId); + expect(item).toMatchObject({ label, category: "weapon", glb, equipped: false }); + expect(item ? applyInventoryAction(state, slice, item) : "missing", `equip ${itemId}`).toBe("equipped"); + expect(state.authorityCommands.pending.at(-1)?.command).toEqual({ + SetEquippedWeapon: { + weapon_id: weaponId, + weapon_item_id: itemId, + weapon_variant_id: 0, + }, + }); + } + }); }); describe("inventory model safety path", () => { diff --git a/client-3d/src/ui/inventory/data.ts b/client-3d/src/ui/inventory/data.ts index c5ed91de..e94b28ad 100644 --- a/client-3d/src/ui/inventory/data.ts +++ b/client-3d/src/ui/inventory/data.ts @@ -82,9 +82,15 @@ for (const [itemId, modelPath] of Object.entries(itemModels)) { /** Canonical base item per authority weapon — the row marked equipped for legacy zero-item snapshots. */ const canonicalItemIdByWeaponId: Partial> = { slugthrower: 3101, + "wpn-pistol": 3122, "wpn-smg": 3111, "wpn-carbine": 3112, "lightning-carbine": 3121, + "wpn-assault": 3123, + "wpn-shotgun": 3124, + "wpn-sniper": 3125, + "wpn-heavy": 3126, + "wpn-launcher": 3127, vibrosword: 3103, "scrapline-machete": 3105, "field-saber": 3106, @@ -102,6 +108,12 @@ const weaponIdByItemId: Partial> = { 3111: "wpn-smg", 3112: "wpn-carbine", 3121: "lightning-carbine", + 3122: "wpn-pistol", + 3123: "wpn-assault", + 3124: "wpn-shotgun", + 3125: "wpn-sniper", + 3126: "wpn-heavy", + 3127: "wpn-launcher", }; const consumableCommandIdByItemId: Partial> = { diff --git a/client-3d/src/ui/inventory/itemCopy.ts b/client-3d/src/ui/inventory/itemCopy.ts index cd5500dc..66a6ba94 100644 --- a/client-3d/src/ui/inventory/itemCopy.ts +++ b/client-3d/src/ui/inventory/itemCopy.ts @@ -51,8 +51,19 @@ export const ITEM_DESCRIPTION_BY_ID: Record = { // weapons 3101: "Coil-driven slug rifle", 3103: "Powered melee blade", + 3104: "Plasma-edged sword with powered hilt", + 3105: "Reclaimed-steel field blade", + 3106: "Militia saber balanced for reach", + 3107: "Forward-weighted quarry work blade", 3111: "Compact STEN-pattern submachine gun", 3112: "Energy carbine built on the STEN frame", + 3121: "Storm-pattern carbine with electric discharge", + 3122: "Compact Badge Bolt sidearm", + 3123: "Slagrail-pattern assault rifle", + 3124: "Coilgate close-range scatter weapon", + 3125: "Long-pattern Kiln precision rifle", + 3126: "Bastion light machine gun", + 3127: "Flare Net area-denial launcher", // items / redeemable voucher 4001: "Bounty chit. Pays on harvest", 9002: "Physical voucher. Redeems into wallet credits", diff --git a/client-3d/src/ui/inventory/itemModels.json b/client-3d/src/ui/inventory/itemModels.json index df92a920..8a4e217b 100644 --- a/client-3d/src/ui/inventory/itemModels.json +++ b/client-3d/src/ui/inventory/itemModels.json @@ -33,6 +33,12 @@ "3111": "/assets/pawn-pack/weapons/custom/wpn_smg_sten_mk2.glb", "3112": "/assets/pawn-pack/weapons/custom/wpn_carbine_kiln.glb", "3121": "/assets/pawn-pack/weapons/custom/lightning_carbine.glb", + "3122": "/assets/pawn-pack/weapons/custom/wpn_pistol_badge_bolt.glb", + "3123": "/assets/pawn-pack/weapons/custom/wpn_rifle_slagrail_vanguard.glb", + "3124": "/assets/pawn-pack/weapons/custom/wpn_shotgun_coilgate_scatter.glb", + "3125": "/assets/pawn-pack/weapons/custom/wpn_carbine_kiln.glb", + "3126": "/assets/pawn-pack/weapons/custom/wpn_heavy_bastion_lmg.glb", + "3127": "/assets/pawn-pack/weapons/custom/wpn_launcher_flare_net.glb", "3201": "/assets/items/custom/field-tools/extractor_battery.glb", "5001": "/assets/items/custom/chits-currency/item_5001_travel_ticket.glb", "5002": "/assets/items/custom/chits-currency/item_5002_looted_schematic.glb", diff --git a/client-3d/src/ui/inventory/paperDoll.ts b/client-3d/src/ui/inventory/paperDoll.ts index 6ae45864..41be6627 100644 --- a/client-3d/src/ui/inventory/paperDoll.ts +++ b/client-3d/src/ui/inventory/paperDoll.ts @@ -1,5 +1,6 @@ import { AmbientLight, + type CanvasTexture, Color, DirectionalLight, Group, @@ -18,13 +19,25 @@ import { resolveEquipmentSlotMaterial, type EquipmentSlotMaterialSource, } from "../../assets/equipmentMaterials"; -import { clonePawnBody, type PawnBody, type PawnEquipmentItem, type PawnPack } from "../../assets/pawnPack"; +import { + applyPawnBodyZoneMask, + clonePawnBody, + collectPawnBodyZoneMeshes, + resolvePawnBodyZoneMask, + type PawnBody, + type PawnEquipmentItem, + type PawnPack, +} from "../../assets/pawnPack"; import { authoritativeWornKey } from "../../render/equipmentSlots"; import { MaskedClipCache } from "../../render/anim/maskedClips"; import { PawnAnimator } from "../../render/anim/PawnAnimator"; import { attachPawnEquipmentSet, resolveRifleBaseLaneAvailable } from "../../render/pawns"; import { SlugthrowerRig } from "../../render/weapons/slugthrowerRig"; -import { PlasmaBlade } from "../../render/weapons/plasmaBlade"; +import { + PlasmaPresentation, + PLASMA_SWORD_COLOR, + PLASMA_SWORD_MODEL_KEY, +} from "../../render/weapons/plasmaPresentation"; import { makeGlowSprite } from "../../render/fx/particles"; import { SUCCESSOR_3D_CONFIG } from "../../config"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; @@ -58,22 +71,11 @@ function dollSkinMaterialFor(skinTone: string): MeshStandardMaterial { return material; } -const PLASMA_SWORD_ITEM_ID = 3104; -const PLASMA_SWORD_COLOR = 0x63f0ff; -// Hilt scene + glow sprite cached for module lifetime (same policy as the -// skin material cache); every doll rebuild clones from these. -let plasmaHiltScenePromise: Promise | null = null; -let dollGlowSprite: ReturnType | null = null; - -function loadPlasmaHiltScene(): Promise { - plasmaHiltScenePromise ??= new GLTFLoader() - .loadAsync(`${SUCCESSOR_3D_CONFIG.pawnPack.basePath}/plasma_hilt.glb`) - .then((gltf) => gltf.scene, (error: unknown) => { - console.warn("paper doll plasma hilt load failed", error); - return null; - }); - return plasmaHiltScenePromise; -} +// Glow sprite cached for module lifetime (same policy as the skin material +// cache); every doll rebuild reuses it. The hilt MODEL is no longer loaded +// here — the plasma sword is a catalogue weapon, so SwordRig welds its hilt +// through the shared registry exactly as the world pawn does. +let dollGlowSprite: CanvasTexture | null = null; export interface PaperDollThemeColors { accent: string; @@ -93,8 +95,7 @@ export class PaperDollRenderer implements TurntableTarget { private animator: PawnAnimator | null = null; private slugthrower: SlugthrowerRig | null = null; private sword: SwordRig | null = null; - private plasmaBlade: PlasmaBlade | null = null; - private plasmaHilt: Group | null = null; + private plasma: PlasmaPresentation | null = null; private currentWeaponItemId = 0; private currentBody: PawnBody | null = null; private currentWeaponId: string | null = null; @@ -159,6 +160,7 @@ export class PaperDollRenderer implements TurntableTarget { // foregrip); the legacy lane needs the support hand posed by IK. this.slugthrower?.update(dtSeconds, null, this.rifleBaseLaneAvailable ? "auto" : "on"); this.sword?.update(dtSeconds); + this.plasma?.update(dtSeconds); const x = Math.max(0, Math.floor(rect.x)); const y = Math.max(0, Math.floor(canvasHeight - rect.y - rect.height)); @@ -258,7 +260,12 @@ export class PaperDollRenderer implements TurntableTarget { : null; this.currentEquipmentIds.length = 0; for (let i = 0; i < vm.equipmentIds.length; i += 1) this.currentEquipmentIds.push(vm.equipmentIds[i]!); + // Appearance hair is independent from inventory headwear. + const attachIds = hairId && !vm.equipmentIds.includes(hairId) + ? [...vm.equipmentIds, hairId] + : vm.equipmentIds; const bodyRoot = clonePawnBody(this.pack, vm.body); + const bodyZoneMeshes = collectPawnBodyZoneMeshes(bodyRoot); bodyRoot.name = `inventory-paper-doll-body:${vm.body}`; bodyRoot.scale.setScalar(this.pack.scale); this.root.add(bodyRoot); @@ -276,16 +283,16 @@ export class PaperDollRenderer implements TurntableTarget { object.material = skinMaterial; }); - // Appearance hair is independent from inventory headwear. - const attachIds = hairId && !vm.equipmentIds.includes(hairId) - ? [...vm.equipmentIds, hairId] - : vm.equipmentIds; - attachPawnEquipmentSet( + const attachedItemIds = attachPawnEquipmentSet( this.pack, bodyRoot, attachIds, (item, source) => this.equipmentMaterial(item, source), ); + applyPawnBodyZoneMask( + bodyZoneMeshes, + resolvePawnBodyZoneMask(this.pack.equipment, attachedItemIds), + ); attachPawnFaceDecal(bodyRoot, vm.appearance?.face); void getEquipmentMaterialSets().then(() => { if (this.bodyRoot !== bodyRoot) return; @@ -355,21 +362,13 @@ export class PaperDollRenderer implements TurntableTarget { this.sword = sword; sword.setVisible(true); sword.setStowed(pose.stowed, { snap: true }); - if ((vm.weaponItemId ?? 0) === PLASMA_SWORD_ITEM_ID) { - // Plasma presentation (world-pawn mirror, pawns.ts ensurePlasma): - // hide the vibro frame, pure-FX blade + hilt clone in the same - // weapon frame. Blade lights only when wielded — stowed = retracted. - sword.setFrameVisible(false); + if (assetKey === PLASMA_SWORD_MODEL_KEY) { + // Plasma presentation (world-pawn mirror, pawns.ts syncPlasmaEquip). + // The SwordRig already carries the authored plasma hilt model, so + // this only adds the pure-effect blade. Blade lights only when + // wielded — stowed = retracted. dollGlowSprite ??= makeGlowSprite(); - const blade = new PlasmaBlade(sword.frameRoot(), dollGlowSprite, PLASMA_SWORD_COLOR); - blade.setExtension(pose.stowed ? 0 : 1); - this.plasmaBlade = blade; - void loadPlasmaHiltScene().then((scene) => { - if (!scene || this.plasmaBlade !== blade) return; - const hilt = scene.clone(true); - sword.frameRoot().add(hilt); - this.plasmaHilt = hilt; - }); + this.plasma = new PlasmaPresentation(sword, dollGlowSprite, PLASMA_SWORD_COLOR, !pose.stowed); } } } @@ -382,12 +381,8 @@ export class PaperDollRenderer implements TurntableTarget { } private clearDoll(): void { - this.plasmaBlade?.dispose(); - this.plasmaBlade = null; - if (this.plasmaHilt) { - this.plasmaHilt.parent?.remove(this.plasmaHilt); - this.plasmaHilt = null; - } + this.plasma?.dispose(); + this.plasma = null; this.slugthrower?.dispose(); this.slugthrower = null; this.sword?.dispose(); diff --git a/client-3d/src/ui/inventory/paperDollPresentation.test.ts b/client-3d/src/ui/inventory/paperDollPresentation.test.ts index c846c163..c6912146 100644 --- a/client-3d/src/ui/inventory/paperDollPresentation.test.ts +++ b/client-3d/src/ui/inventory/paperDollPresentation.test.ts @@ -44,6 +44,20 @@ describe("paper-doll weapon presentation parity", () => { modelKey: "lightning_carbine", visible: true, }); + for (const [weaponId, itemId, modelKey] of [ + ["wpn-pistol", 3122, "wpn_pistol"], + ["wpn-assault", 3123, "wpn_assault"], + ["wpn-shotgun", 3124, "wpn_shotgun"], + ["wpn-sniper", 3125, "wpn_sniper"], + ["wpn-heavy", 3126, "wpn_heavy"], + ["wpn-launcher", 3127, "wpn_launcher"], + ] as const) { + expect(resolvePaperDollWeaponPresentation(weaponId, itemId)).toMatchObject({ + lane: "rifle", + modelKey, + visible: true, + }); + } }); it("resolves every current catalog melee model on the melee rig", () => { diff --git a/client-3d/src/viewer/lab/postPanel.ts b/client-3d/src/viewer/lab/postPanel.ts index 218246e0..bf246c3b 100644 --- a/client-3d/src/viewer/lab/postPanel.ts +++ b/client-3d/src/viewer/lab/postPanel.ts @@ -70,12 +70,13 @@ const POST_BOOL_SPECS: readonly { key: keyof Ps2PostDials; label: string }[] = [ const BONE_TINT_CHANNELS = ["R", "G", "B"] as const; -const WEAPON_AXIS_GROUPS: readonly { key: "posOffset" | "rotOffsetDeg" | "foregripContactOffset" | "stowPosOffset" | "stowRotOffsetDeg"; label: string; min: number; max: number; step: number }[] = [ +const WEAPON_AXIS_GROUPS: readonly { key: "posOffset" | "rotOffsetDeg" | "foregripContactOffset" | "stowPosDelta" | "stowRotDeltaDeg"; label: string; min: number; max: number; step: number }[] = [ { key: "posOffset", label: "mount pos", min: -0.2, max: 0.2, step: 0.001 }, { key: "rotOffsetDeg", label: "mount rot °", min: -45, max: 45, step: 0.5 }, { key: "foregripContactOffset", label: "foregrip contact", min: -0.2, max: 0.2, step: 0.001 }, - { key: "stowPosOffset", label: "stow pos", min: -0.6, max: 0.6, step: 0.005 }, - { key: "stowRotOffsetDeg", label: "stow rot °", min: -180, max: 180, step: 1 }, + // Deltas ON TOP of the equipped model's authored stow socket; 0 = authored. + { key: "stowPosDelta", label: "stow pos Δ", min: -0.4, max: 0.4, step: 0.005 }, + { key: "stowRotDeltaDeg", label: "stow rot Δ°", min: -180, max: 180, step: 1 }, ]; const WEAPON_SCALAR_SPECS: readonly { key: "yawStrength" | "maxYawCorrectionRad" | "restingYawRad"; label: string; min: number; max: number; step: number }[] = [ diff --git a/client-3d/src/viewer/lab/stage.ts b/client-3d/src/viewer/lab/stage.ts index fb9d07e9..2d237e30 100644 --- a/client-3d/src/viewer/lab/stage.ts +++ b/client-3d/src/viewer/lab/stage.ts @@ -41,7 +41,17 @@ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js"; import { SUCCESSOR_3D_CONFIG } from "../../config"; -import { clonePawnBody, cloneSpecialPawnBody, type PawnBody, type PawnPack, type SlugthrowerAttachSpec } from "../../assets/pawnPack"; +import { + applyPawnBodyZoneMask, + clonePawnBody, + cloneSpecialPawnBody, + collectPawnBodyZoneMeshes, + resolvePawnBodyZoneMask, + type PawnBody, + type PawnBodyZoneMesh, + type PawnPack, + type SlugthrowerAttachSpec, +} from "../../assets/pawnPack"; import { resolveEquipmentSlotMaterial, type EquipmentSlotMaterialSource } from "../../assets/equipmentMaterials"; import type { PawnEquipmentItem } from "../../assets/pawnRigTypes"; import { attachPawnEquipmentSet } from "../../render/pawns"; @@ -51,12 +61,21 @@ import { Ps2PostRenderer } from "../../render/post"; import { paintTerrainPixel } from "../../render/terrain/procgen"; import { SlugthrowerRig } from "../../render/weapons/slugthrowerRig"; import { SwordRig } from "../../render/weapons/swordRig"; +import { + PlasmaPresentation, + PLASMA_SWORD_COLOR, + PLASMA_SWORD_MODEL_KEY, +} from "../../render/weapons/plasmaPresentation"; +import { makeGlowSprite } from "../../render/fx/particles"; import type { WorldEnvironment } from "../../render/environment"; import { LabFx } from "./fx"; export type LabBodyKey = PawnBody | "droid_grok_humanoid"; export const DROID_BODY_KEY = "droid_grok_humanoid"; +/** Glow sprite shared by every lab plasma rebuild (module lifetime). */ +let labGlowSprite: CanvasTexture | null = null; + export type CameraPresetName = "quarter" | "front" | "side" | "close" | "grip"; export const CAMERA_PRESET_ORDER: readonly CameraPresetName[] = ["quarter", "front", "side", "close", "grip"]; @@ -248,6 +267,8 @@ export class LabPawn { private readonly animator: PawnAnimator; private readonly maskedClips = new MaskedClipCache(); private readonly attachedEquipment: Object3D[] = []; + /** Base-body zones cached before the lab swaps body materials or attaches apparel. */ + private readonly bodyZoneMeshes: readonly PawnBodyZoneMesh[]; /** Read-only view of attached equipment roots — crease-method plugins * (shader swaps, per-garment drivers) traverse these; never mutate. */ get attachments(): readonly Object3D[] { @@ -255,6 +276,7 @@ export class LabPawn { } private gunRig: SlugthrowerRig | null = null; private swordRig: SwordRig | null = null; + private plasma: PlasmaPresentation | null = null; private weapon: LabWeaponSelection | null = null; private selectedClip = "idle"; private selectedLayer: "base" | "upper" | "hand" | "montage" | "arm" = "base"; @@ -277,6 +299,7 @@ export class LabPawn { const cloned = cloneSpecialPawnBody(pack, bodyKey); if (!cloned) throw new Error(`special pawn body unavailable: ${bodyKey}`); this.bodyRoot = cloned; + this.bodyZoneMeshes = []; const heightM = pack.specialBodies.get(bodyKey)?.heightM ?? 1.7; this.bodyRoot.scale.setScalar(SUCCESSOR_3D_CONFIG.pawnPack.heightTargetUnits / heightM); // Droid chassis keeps its authored surface detail; convert to matcap so @@ -291,6 +314,7 @@ export class LabPawn { }); } else { this.bodyRoot = clonePawnBody(pack, bodyKey, { bare: this.bareBody }); + this.bodyZoneMeshes = collectPawnBodyZoneMeshes(this.bodyRoot); this.bodyRoot.scale.setScalar(pack.scale); const skin = new MeshMatcapMaterial({ matcap, color: new Color(BODY_SKIN_COLOR) }); this.bodyRoot.traverse((object) => { @@ -325,8 +349,8 @@ export class LabPawn { setWorn(itemIds: readonly string[]): string[] { for (const attached of this.attachedEquipment) attached.removeFromParent(); this.attachedEquipment.length = 0; - if (this.isDroid || itemIds.length === 0) return []; - attachPawnEquipmentSet( + if (this.isDroid) return []; + const attachedItemIds = attachPawnEquipmentSet( this.pack, this.bodyRoot, itemIds, @@ -334,6 +358,10 @@ export class LabPawn { resolveEquipmentSlotMaterial(source, item, item.mat, { kind: "world", matcap: this.matcap }), this.attachedEquipment, ); + applyPawnBodyZoneMask( + this.bodyZoneMeshes, + resolvePawnBodyZoneMask(this.pack.equipment, attachedItemIds), + ); const worn = new Set(); for (const attached of this.attachedEquipment) { const itemId = attached.userData.successorEquipmentItemId; @@ -355,6 +383,8 @@ export class LabPawn { this.gunRig = null; this.swordRig?.dispose(); this.swordRig = null; + this.plasma?.dispose(); + this.plasma = null; this.weapon = selection; if (!selection) { this.syncWeaponContext(true); @@ -389,6 +419,12 @@ export class LabPawn { this.swordRig = new SwordRig(this.pack, handR, spine); } else { this.swordRig = new SwordRig(this.pack, handR, spine, selection.spec, selection.scene, selection.scale, selection.id); + if (selection.id === PLASMA_SWORD_MODEL_KEY) { + // Lab mirrors the runtime presentation exactly: the hilt is a normal + // catalogue model welded by SwordRig, plus the pure-effect blade. + labGlowSprite ??= makeGlowSprite(); + this.plasma = new PlasmaPresentation(this.swordRig, labGlowSprite, PLASMA_SWORD_COLOR, true); + } } this.syncWeaponContext(true); } @@ -508,6 +544,9 @@ export class LabPawn { if (this.swordRig) { for (let i = 0; i < 8; i += 1) this.swordRig.update(1 / 30); } + if (this.plasma) { + for (let i = 0; i < 8; i += 1) this.plasma.update(1 / 30); + } } private reloadProgress(): { elapsedS: number; totalS: number } | null { @@ -625,6 +664,7 @@ export class LabPawn { this.weaponMixer?.update(dtSeconds); } this.swordRig?.update(dtSeconds); + this.plasma?.update(dtSeconds); } dispose(): void { diff --git a/client-3d/tools/generate-production-assets.mjs b/client-3d/tools/generate-production-assets.mjs index bc73be42..dffa424f 100755 --- a/client-3d/tools/generate-production-assets.mjs +++ b/client-3d/tools/generate-production-assets.mjs @@ -9,7 +9,10 @@ const packageDir = path.resolve(here, ".."); const defaultRepoRoot = path.resolve(packageDir, ".."); export const DEFAULT_STAGING_DIR = path.join(packageDir, ".generated", "production-assets"); export const PRODUCTION_MANIFEST_NAME = "production-asset-manifest.json"; -export const PUBLIC_RELEASE_BUDGET = Object.freeze({ maxFiles: 420, maxBytes: 230_000_000 }); +// Canonical male/female bodies, their fitted runtime wardrobes, and the current +// authored world/weapon closure ship together. Keep less than 1 MiB headroom so +// newly reachable assets still fail the production build instead of drifting in. +export const PUBLIC_RELEASE_BUDGET = Object.freeze({ maxFiles: 510, maxBytes: 296_000_000 }); const PAWN_CORE_FILES = [ "game_pack.json", @@ -301,10 +304,16 @@ function addPawnAssets(entries, repoRoot, fixture) { const equipmentManifest = readJson(path.join(base, "equipment", "manifest.json")); for (const item of equipmentManifest.items ?? []) { if (item?.viewerOnly === true) continue; - if (typeof item?.glb !== "string") fail("equipment manifest item has no GLB path"); - const relative = path.posix.normalize(path.posix.join("assets", "pawn-pack", "equipment", item.glb)); - if (relative === "assets" || relative.startsWith("../") || !relative.startsWith("assets/")) fail(`equipment path escapes assets root: ${item.glb}`); - addClientAsset(entries, relative.slice("assets/".length), `registry.pawnEquipment:${item.id}`); + if (typeof item?.glb !== "string" || item.glb.length === 0) fail("equipment manifest item has no GLB path"); + if (item.glbFemale !== undefined && (typeof item.glbFemale !== "string" || item.glbFemale.length === 0)) { + fail("equipment manifest item has an invalid female GLB path"); + } + const variants = new Set([item.glb, item.glbFemale].filter((value) => typeof value === "string")); + for (const glb of variants) { + const relative = path.posix.normalize(path.posix.join("assets", "pawn-pack", "equipment", glb)); + if (relative === "assets" || relative.startsWith("../") || !relative.startsWith("assets/")) fail(`equipment path escapes assets root: ${glb}`); + addClientAsset(entries, relative.slice("assets/".length), `registry.pawnEquipment:${item.id}`); + } } const weaponsManifestPath = path.join(base, "weapons", "weapons_manifest.json"); diff --git a/client-3d/tools/generate-production-assets.test.mjs b/client-3d/tools/generate-production-assets.test.mjs index 359cb496..48035748 100644 --- a/client-3d/tools/generate-production-assets.test.mjs +++ b/client-3d/tools/generate-production-assets.test.mjs @@ -121,6 +121,7 @@ test("production plan reruns deterministically and omits catalogs/labs", () => { assert.ok(second.manifest.entries.some((item) => item.path === "assets/pawn-pack/plasma_hilt.glb")); assert.ok(second.manifest.entries.some((item) => item.path === "assets/pawn-pack/weapons/custom/wpn_launcher_flare_net.glb")); assert.ok(second.manifest.entries.some((item) => item.path === "assets/pawn-pack/equipment/Under/top_frayed_tunic.glb")); + assert.ok(second.manifest.entries.some((item) => item.path === "assets/pawn-pack/equipment/Female/Under/top_frayed_tunic.glb")); assert.ok(second.manifest.entries.some((item) => item.path === "assets/items/custom/crops/world/nightplum/laden.glb")); assert.equal(second.manifest.entries.some((item) => item.path.endsWith("food/dishes/ashgrain_hearth_loaf.glb")), false); assert.ok(second.manifest.entries.every((item) => Array.isArray(item.category) && item.category.length > 0 && Array.isArray(item.reason) && item.reason.length > 0)); diff --git a/client-rust/Cargo.lock b/client-rust/Cargo.lock new file mode 100644 index 00000000..14919005 --- /dev/null +++ b/client-rust/Cargo.lock @@ -0,0 +1,1602 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "az" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" + +[[package]] +name = "bevy_mikktspace" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bff34eb29ff4b8a8688bc7299f14fb6b597461ca80fec03ed7d22939ab33e48f" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "blake3" +version = "1.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8ee0c1824c4dea5b5f81736aff91bae041d2c07ee1192bec91054e10e3e601e" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixed" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c6e0b89bf864acd20590dbdbad56f69aeb898abfc9443008fd7bd48b2cc85a" +dependencies = [ + "az", + "bytemuck", + "half", + "typenum", +] + +[[package]] +name = "fontdue" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7894823fa221401399e2598f8b63f81ac77ff5c63248b7656779bff1632d7d3d" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rmp3" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d9fb2c89a819645c57007782fd4db5bfa1abe290e1df5f530a441dc21ab1ad" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "successor-client" +version = "0.0.1" +dependencies = [ + "fontdue", + "libm", + "rmp3", + "serde", + "serde_json", + "successor-client-proto", + "successor-engine-core", + "successor-engine-render", + "successor-movement", + "successor-net", + "successor-platform", +] + +[[package]] +name = "successor-client-proto" +version = "0.0.1" +dependencies = [ + "rmp-serde", + "serde", + "serde_json", + "successor-net", + "tungstenite", +] + +[[package]] +name = "successor-core" +version = "0.0.1" +dependencies = [ + "blake3", + "fixed", + "rand", + "rand_chacha", +] + +[[package]] +name = "successor-engine-core" +version = "0.0.1" +dependencies = [ + "bevy_mikktspace", + "libm", + "miniz_oxide", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "successor-engine-render" +version = "0.0.1" +dependencies = [ + "criterion", + "libm", + "successor-engine-core", +] + +[[package]] +name = "successor-movement" +version = "0.0.1" + +[[package]] +name = "successor-net" +version = "0.0.1" +dependencies = [ + "serde", + "successor-core", +] + +[[package]] +name = "successor-platform" +version = "0.0.1" +dependencies = [ + "native-tls", + "parking_lot", + "successor-engine-core", + "successor-engine-render", + "tungstenite", + "url", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773" + +[[package]] +name = "zune-jpeg" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc90edb93a24f57d041e871256001a0f6e20c2c56393495a0f96df8c0b33856" +dependencies = [ + "zune-core", +] diff --git a/client-rust/Cargo.toml b/client-rust/Cargo.toml new file mode 100644 index 00000000..cde5c4e5 --- /dev/null +++ b/client-rust/Cargo.toml @@ -0,0 +1,44 @@ +[workspace] +resolver = "2" +members = [ + "source/engine-core", + "source/engine-render", + "source/platform", + "source/client-proto", + "source/app", +] + +[workspace.package] +version = "0.0.1" +edition = "2021" +license = "MIT" + +[workspace.dependencies] +successor-engine-core = { path = "source/engine-core" } +successor-engine-render = { path = "source/engine-render" } +successor-platform = { path = "source/platform" } +successor-client-proto = { path = "source/client-proto" } +successor-movement = { path = "../crates/successor-movement" } +# no_std float math (only external dep of the engine crates) +libm = "0.2" +# std-side crates (platform / proto / app shell only) +serde = { version = "1", features = ["derive"] } +serde_json = "1" +rmp-serde = "1" +tungstenite = { version = "0.24", features = ["native-tls"] } +url = "2" + +# Size-first profile: the client is gated on stripped binary size (budgets.json). +# Deliberately NOT sharing the root workspace, whose release profile is opt-level=3 +# for sim determinism and whose clippy.toml bans HashMap/Instant workspace-wide. +[profile.release] +opt-level = "z" +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = "symbols" + +# Debug builds must also abort: the no_std engine crates cannot unwind. +# Ignored by test/bench profiles, which Cargo forces back to panic="unwind". +[profile.dev] +panic = "abort" diff --git a/client-rust/Makefile b/client-rust/Makefile new file mode 100644 index 00000000..212e84bf --- /dev/null +++ b/client-rust/Makefile @@ -0,0 +1,175 @@ +# Successor Rust client — build orchestration and regression gates. +# Outputs land in out/bin and out/web. Mirrors ~/code/sandbox/voxel_engine/Makefile. + +CARGO ?= cargo +WASM_TARGET := wasm32-unknown-unknown +NO_STD_TARGET := thumbv7em-none-eabihf +PYTHON ?= python3 +WASM_STRIP := $(shell command -v wasm-strip || command -v llvm-strip || echo /opt/homebrew/opt/llvm/bin/llvm-strip) +STATS_JSON := out/stats.json +RENDER_STATS_JSON := out/material-parity-gpu.json +TERRAIN_STATS_JSON := out/terrain-material-gpu.json +NATIVE_BIN := out/bin/successor +DEV_BIN := target/release/successor +DEV_NATIVE_BIN := out/bin/successor-dev +CONTROL_BIN := out/bin/successor-control + +DEV_FEATURES := --features dev-tools + +WASM_OUT := out/web/successor.wasm +NATIVE_STRIPPED := /tmp/successor-port/successor +WASM_STRIPPED := /tmp/successor-port/successor.wasm +WEB_RELEASE_OUT ?= out/web-release +SOURCE_COMMIT ?= +CLIENT_RELEASE_ID ?= +SERVER_RELEASE_ID ?= +STOREFRONT_ORIGIN ?= https://www.successorgame.com +GAME_ORIGIN ?= wss://world.successorgame.com +CHAT_ORIGIN ?= wss://world.successorgame.com + +.PHONY: all native dev web web-release run serve serve-dev connect-local strip-port size-check bench bench-check \ + bench-baseline check-allocs connected-check-allocs runtime-check render-check terrain-check model-check test-unit nostd verify clean + +all: native web + +native: + $(CARGO) build --release -p successor-client --bin successor + $(CARGO) build --release -p successor-client --bin successor-control $(DEV_FEATURES) + mkdir -p out/bin + cp target/release/successor $(NATIVE_BIN) + cp target/release/successor-control $(CONTROL_BIN) + +# Developer-capability client. `native` deliberately ships without `dev-tools`, +# so the shipped binary refuses demo modes, `--endpoint` identity, and the +# loopback control server. Agent control journeys and the observation harnesses +# drive this binary instead. +dev: + $(CARGO) build --release -p successor-client --bin successor $(DEV_FEATURES) + $(CARGO) build --release -p successor-client --bin successor-control $(DEV_FEATURES) + mkdir -p out/bin + cp target/release/successor $(DEV_NATIVE_BIN) + cp target/release/successor-control $(CONTROL_BIN) + + +web: + $(CARGO) build --release --target $(WASM_TARGET) -p successor-client --lib + mkdir -p out/web + cp target/$(WASM_TARGET)/release/successor_client.wasm $(WASM_OUT) + cp web/index.html web/successor.js out/web/ +# Assets are served from repo sources by tools/dev-serve.mjs; nothing else is +# copied here. `make serve` (or `make serve-dev`) wires URLs to repo paths. + +web-release: + $(CARGO) build --release --target $(WASM_TARGET) -p successor-client --lib + node tools/web-release.mjs \ + --source-commit "$(SOURCE_COMMIT)" \ + --client-release-id "$(CLIENT_RELEASE_ID)" \ + --server-release-id "$(SERVER_RELEASE_ID)" \ + --storefront-origin "$(STOREFRONT_ORIGIN)" \ + --game-origin "$(GAME_ORIGIN)" \ + --chat-origin "$(CHAT_ORIGIN)" \ + --out "$(WEB_RELEASE_OUT)" + +run: native + ./$(NATIVE_BIN) $(ARGS) + +# Loopback dev server: serves out/web and maps asset URLs to repo sources. +# Injects a dev-identity launch context for the local authority on :28093. +serve: web + node tools/dev-serve.mjs --root out/web --port 8080 + +serve-dev: serve + +# Native connected loop against the local authority: starts it when absent, +# then joins through the durable roster character managed by the script (the +# shard rejects bare dev identities for ids the character store owns). +PLAYER ?= local-dev +connect-local: dev + tools/connect-local.sh --name "$(PLAYER)" -- $(ARGS) + +# no_std purity proof: the engine crates must build for a bare-metal target. +nostd: + $(CARGO) build -p successor-engine-core --target $(NO_STD_TARGET) + $(CARGO) build -p successor-engine-render --target $(NO_STD_TARGET) + +# Stripped copies of both binaries (input to the size gate). +strip-port: all + mkdir -p /tmp/successor-port + cp $(NATIVE_BIN) $(NATIVE_STRIPPED) + cp $(WASM_OUT) $(WASM_STRIPPED) + strip -x $(NATIVE_STRIPPED) 2>/dev/null || strip $(NATIVE_STRIPPED) + $(WASM_STRIP) --strip-all $(WASM_STRIPPED) 2>/dev/null || wasm-strip $(WASM_STRIPPED) + +model-check: + $(CARGO) build --release -p successor-client --bin successor $(DEV_FEATURES) + git -C .. ls-files -z -- '*.glb' '*.gltf' '*.obj' '*.fbx' '*.blend' ':(exclude)tools/successor/assets/cloning-facility-opus5/blend/*.blend' | ./$(DEV_BIN) --model-corpus + +test-unit: + $(CARGO) test -p successor-engine-core --features std + $(CARGO) test -p successor-engine-render --features std + $(CARGO) test -p successor-client-proto + $(CARGO) test -p successor-platform + $(CARGO) test -p successor-client + +# Criterion microbenches; `std` gates off the no_std runtime items so libtest links. +bench: + $(CARGO) bench -p successor-engine-render --features std + +bench-check: bench + $(PYTHON) bench/compare.py check --perf + +size-check: strip-port + $(PYTHON) bench/compare.py check --size --native $(NATIVE_STRIPPED) --wasm $(WASM_STRIPPED) +# Allocation and runtime probes are built only with the developer capability. +# Production binaries are intentionally unable to select demo/probe modes; +# connected-scene probes can use the same instrumented binary and caps. +check-allocs: + $(CARGO) build --release -p successor-client --bin successor --features dev-tools,alloc-count + $(DEV_BIN) --demo parity-basic --frames 600 --assert-zero-allocs + +# Live connected-frame allocation gate. Caller supplies a disposable authority +# identity; 240 warmup frames cover deferred presentation initialization. +connected-check-allocs: + test -n "$(CONNECTED_ENDPOINT)" -a -n "$(CONNECTED_PLAYER)" -a -n "$(CONNECTED_ACTOR)" + $(CARGO) build --release -p successor-client --bin successor --features dev-tools,alloc-count + $(DEV_BIN) --dev-identity --endpoint $(CONNECTED_ENDPOINT) --player-id $(CONNECTED_PLAYER) --actor-id $(CONNECTED_ACTOR) --frames 600 --assert-zero-allocs + +runtime-check: + mkdir -p out + $(CARGO) build --release -p successor-client --bin successor $(DEV_FEATURES) + $(DEV_BIN) --demo parity-basic --frames 600 --stats-json $(STATS_JSON) + $(PYTHON) bench/compare.py check --runtime $(STATS_JSON) +# Material parity GPU p99, measured after 120 warmup frames. +render-check: + mkdir -p out + $(CARGO) build --release -p successor-client --bin successor $(DEV_FEATURES) + $(DEV_BIN) --demo material-parity --quality high --frames 840 --gpu-stats-json $(RENDER_STATS_JSON) + $(PYTHON) bench/compare.py check --render $(RENDER_STATS_JSON) + +# Deterministic terrain material probes plus GPU p99 after 120 warmup frames. +terrain-check: + mkdir -p out + $(CARGO) build --release -p successor-client --bin successor $(DEV_FEATURES) + $(DEV_BIN) --demo terrain-material --biome desert --quality high --frames 120 --assert-terrain-material + $(DEV_BIN) --demo terrain-material --biome forest --quality high --frames 120 --assert-terrain-material + $(DEV_BIN) --demo terrain-material --biome desert --quality high --frames 840 --gpu-stats-json $(TERRAIN_STATS_JSON) + $(PYTHON) bench/compare.py check --terrain $(TERRAIN_STATS_JSON) + +# Rewrites this machine's baseline (perf medians, stripped sizes, runtime, and render stats). +bench-baseline: strip-port bench + mkdir -p out + $(CARGO) build --release -p successor-client --bin successor $(DEV_FEATURES) + $(DEV_BIN) --demo parity-basic --frames 600 --stats-json $(STATS_JSON) + $(DEV_BIN) --demo material-parity --quality high --frames 840 --gpu-stats-json $(RENDER_STATS_JSON) + $(DEV_BIN) --demo terrain-material --biome desert --quality high --frames 840 --gpu-stats-json $(TERRAIN_STATS_JSON) + $(PYTHON) bench/compare.py capture --native $(NATIVE_STRIPPED) --wasm $(WASM_STRIPPED) --runtime $(STATS_JSON) --render $(RENDER_STATS_JSON) --terrain $(TERRAIN_STATS_JSON) + +# Pre-acceptance gate: unit tests + perf gate + size gate. +# (check-allocs and runtime-check open no window but still run the demo headless-timed; +# they are separate targets so `verify` stays fast and display-independent.) +verify: model-check test-unit bench-check size-check + @echo "VERIFY: PASS" + +clean: + $(CARGO) clean + rm -rf out diff --git a/client-rust/PARITY.md b/client-rust/PARITY.md new file mode 100644 index 00000000..37dcac04 --- /dev/null +++ b/client-rust/PARITY.md @@ -0,0 +1,260 @@ +# Rust client — parity matrix + +Tracks parity with the checked-in `client-3d` surface. Native GL and WebGL2 now +share the connected authority projection, presentation, command, HUD/window, +and failure paths described below. This is **source-proven but unshipped**: +`client-3d`, desktop, and TUI remain the supported surfaces until a separate +product promotion. + +Status legend: **done** means delivered and gated from the current source. + +## Foundation (this milestone) + +| Capability | Web-client reference | Engine mechanism | Status | +| --- | --- | --- | --- | +| ECS (entities/components/systems) | `client/src/slice-core/*` | `successor-engine-core::ecs` (`world!`, dense/sparse storage, `Query1/2`) | done | +| Prefab / JSON / asset layer | `client/src/slice-core/specs`, `client-3d/.../props-mapping.json`, `runtimePublicPaths.ts` | `engine-core::{json, assets, prefab}` (versioned `schema`/`format`, manifest-by-id, fail-closed path resolver, `world_prefab!`) | done | +| Renderer core | `client-3d/src/render/` | `engine-render` `Gpu` trait + `Renderer` (generic, monomorphized) | done | +| Multiple cameras | `client-3d/src/render/` cameras | `Camera` component (`viewport_id`, `order`) | done | +| Render-to-texture + compositing | portrait/minimap/overlay renders | `CamTarget::Texture` + `CompositeQuad` | done | +| Viewport tagging (entity in many views) | n/a (new model) | `MeshRenderer.viewport_mask` bitmask vs `Camera.viewport_id` | done | +| 3D mesh rendering (GLB) | GLB meshes | `engine-core::glb` parser + `renderer::upload_mesh` (+ procedural `primitives`) | done (Wave 1) | +| Skeletal skinning + animation | `render/anim/PawnAnimator` | `engine-core::anim` (clip sampling, layered mixer, `Skeleton` palette) + skinned mesh program | done (Wave 1) | +| GPU instancing | instanced props/flora | `Gpu::draw_instanced` + `INSTANCE_MAT4_LAYOUT` | done (Wave 1; consumed in Wave 2) | +| PNG image decode | texture loads | `engine-core::image` (miniz_oxide inflate + unfilter) | done (Wave 1) | +| Asset IO (fs + http) | Vite fetch | `platform::{fs_read, http_get}` + web `js_fetch_get` shim | done (Wave 1) | +| Directional lighting | `client-3d/src/render/environment` | Cook-Torrance PBR (GGX + Smith + Schlick), metallic-roughness from GLB, deferred sun pass | done (PBR upgrade) | +| Shadows | sun shadow | texel-snapped ortho map (512/1024/2048/4096² preset option) + rotated-Poisson PCF + High-tier PCSS, with live range/bias/normal-offset/penumbra controls | done (soft shadows + mastering) | +| Deferred rendering | n/a (new) | G-buffer (2×RGBA8 + D24) → deferred sun + point-light volumes → HDR scene RT (RGBA16F/RGBA8) → ACES tonemap + environment/master grade + optional palette quantization; forward path kept for RTT cameras | done (PBR + mastering) | +| Global illumination (VXGI) | n/a (new) | `engine-render::gi` CPU-voxelized albedo volume (64³) + GPU sun-radiance injection (layered `framebufferTextureLayer`) + 3D mipmaps + diffuse/specular cone tracing; amortized, `RenderQuality`-gated | done (PBR upgrade) | +| Local/point lights | n/a (new) | `PointLight` component + instanced deferred light volumes (additive HDR); explicit `KHR_lights_punctual` point lights plus bounded emissive-geometry inference in `app::assets::model` | done (local native/WebGL2 connected proof; 256 lights/camera, nearest 32/transparent draw; not published) | +| Render quality tiers | n/a (new) | `RenderQuality {Low,Medium,High}` over one deferred path (shadow filter, GI cones, HDR target); `--quality` / `?quality=` | done (PBR upgrade) | +| Graphics mastering | renderer/environment controls | Backquote overlay over existing `UiBuilder`; versioned Low/Medium/High presets in `assets/render/settings.json`; live sun/shadow/AO/emissive/bloom/FXAA/color/palette controls with atomic native save/reload/reset | done (native tooling; web applies embedded settings read-only) | +| Transparency via dithering | dithered fades | 4×4 Bayer screen-door `discard` | done | +| HUD / text overlay | `client-3d/src/overlay`, `ui/` | baked 5×7 font (`engine-render::font`) → per-pixel quads; immediate-mode `engine-render::ui::UiBuilder` (panels/borders/text/icons, alpha-blended) | done (Wave 5; readable text + panels) | +| UI icon vocabulary | `client-3d/src/ui/icons.ts` (39 SVGs) | `tools/bake-assets` distance-field SVG stroker → committed A8 atlas (`app/assets/ui/icons.*`), sampled via `Renderer::render_ui` | done (Wave 5) | +| Alpha blending | CSS/DOM compositing | `PipelineState.blend` → GL `SRC_ALPHA,ONE_MINUS_SRC_ALPHA` | done (Wave 5) | +| Wire protocol (Colyseus) | `@colyseus/sdk` in `gameAuthoritySystem.ts` | `client-proto::{colyseus, session}` (sans-IO) | done | +| Command vocabulary | `crates/successor-net` | reuse `ClientCommand`/`ClientCommandEnvelope` (117 cmds) | done | +| Snapshot/delta/receipt projection | `gameAuthoritySystem.ts` | transactional `AuthorityStore` plus full/compact packet application and exact receipt settlement | done | +| Movement input → command | `authorityMovementSystem.ts` | queued/coalesced `SetMoveIntent`, prediction, interpolation, reconciliation, and receipt feedback | done | +| Orthographic world, picking, minimap (live) | renderer and camera rig | shared `ConnectedScene` on native GL and WebGL2 | done | +| Chat lifecycle and presentation | `client/src/chat/chatClient.ts` HUD | bounded `game/chat_net.rs` connection state and connected HUD/bubbles | done | +| Platform abstraction (desktop/web) | Vite/Electron | `successor-platform` native GL/WebGL2, sockets, assets, input, audio, and context recovery | done | +| Agent input, screenshots, record/replay | headless automation + graphical journey tooling | loopback `successor-platform::native::control` + pipeable `successor-control`; `successor.input.v1` frame replay | done (native developer-only) | +| Size / alloc / perf / RSS gates | n/a | `budgets.json` + `bench/compare.py` + Makefile | done | + +## Promotion boundary + +The parity implementation does not itself replace or publish a supported +client. Remaining work is a product/release decision: select an immutable +source, run release-bound proof, package and sign supported artifacts, update +the site/download ledger, and promote the Rust surface in canonical context. +TUI/mobile backends are outside this parity milestone. + +## Live playable-slice run (DEMONSTRATED) + +The full bidirectional round-trip now runs on macOS against a disposable +loopback authority in both native GL and Chromium WebGL2. The session reaches +`Ready`; full/compact snapshots, deltas, receipts, acks, and events update the +shared projection; typed commands move the player and surface accepted or +rejected receipts; and `exit_world` closes intentionally. Native proof uses GLB +pawns and equipment, streamed terrain/props, weather, HUD/windows, positional +audio, and record/replay rather than placeholder capsules. + +### Recipe (macOS host + Linux container authority) + +```bash +# 1. Build + run the dev authority container (legacy mode, dev identity, +# util-linux flock, persistence on). Native arm64 — no emulation. +docker build -f ops/docker/Dockerfile.dev -t successor-authority:dev . +docker run -d --name successor-authority -p 28093:28093 successor-authority:dev +curl -fsS http://127.0.0.1:28093/game/status | jq .readiness # all true + +# 2. Run the native client against the container (auto-exits after N frames). +make -C client-rust native +./client-rust/out/bin/successor \ + --endpoint ws://127.0.0.1:28093 --player-id dev-1 --actor-id dev-1 \ + --frames 900 --auto-walk --screenshot client-rust/out/live.bmp +# omit --auto-walk for interactive WASD; add --gl is implied for connected mode. +``` + +`--auto-walk` drives a constant north `SetMoveIntent` so the round-trip is +exercised without a keyboard; the run prints a `connected summary` with the +authority-streamed player position and writes a screenshot of the live scene. + +### Alternative: host ephemeral authority (no container, no flock) + +`legacy` mode + `GAME_SHARD_PERSISTENCE=0` skips the state lock entirely, so the +authority also runs directly on macOS (no persistence across relog): + +```bash +cargo build -q -p successor-sim --example authority_bridge_server +PORT=28093 HOST=127.0.0.1 GAME_ALLOW_DEV_IDENTITY=1 GAME_SHARD_PERSISTENCE=0 \ + SUCCESSOR_CONTROL_PLANE_MODE=legacy \ + GAME_SLICE_PATH=client/public/successor-slice/open-desert-slice.json \ + GAME_RUST_AUTHORITY_BRIDGE_BIN=target/debug/examples/authority_bridge_server \ + node server/dist/index.js +``` + +### Client protocol notes (learned from the live bring-up) + +- Send `game.ready` immediately on room join (the server emits `game.hello` in + response); do not wait for hello first. +- Declare AOI `game.view` interest once `Ready`, or the shard streams nothing + after the hello. +- Strip `null` fields from the command payload — the server's zod command + schema uses `.optional()` (absent), not `.nullable()`; `Option::None` + serialized as `null` is rejected. +- `SetMoveIntent` is a per-tick input: resend it while the intent is nonzero, + not only on change, for continuous movement. + +`ops/docker/Dockerfile.dev` is a LOCAL dev image only — `ops/deploy` owns the +production (amd64, standalone) publication path. + +## Parity build progress (wave execution) + +Tracking the ordered parity waves from `local://rust-client-parity-plan.md`. + +- **Wave 1 — Asset foundation: DONE + verified.** `engine-core::glb` (GLB + parser, 3 fixture tests), `engine-core::image` (PNG decoder), `engine-core:: + anim` (clip sampling + layered mixer + `Skeleton` palette), renderer skinning + (`SKINNED_MESH_LAYOUT` + `mesh_skinned.vert` + `Gpu::set_joints`) and + instancing (`Gpu::draw_instanced` + `INSTANCE_MAT4_LAYOUT`), platform asset IO + (`fs_read`/`http_get` + web `js_fetch_get`). `--demo glb-view --glb

+ [--clip ]` renders any repo GLB; verified via screenshots of + `bank_terminal.glb` (static multi-material) and `pawn_male.glb --clip idle` + (skinned, animated). Current fidelity-first budgets are 6 MiB native / 4 MiB + wasm / 512 MiB RSS, with zero steady-state frame allocations. +- **Wave 2 — World rendering: DONE + verified.** One authority cell, one + renderer world unit, and one metre are the canonical scale. PawnForge bodies + normalize to a 1.8-metre adult height; fixture prop footprints remain metric; + non-building props and pawns sample terrain elevation; building footprints + are flattened with feathered transitions and reject detail scatter. Terrain + uses continuous deterministic multi-octave displacement on pooled + tessellated chunks, matching displaced G-buffer and depth/shadow paths. + Desert and forest use slope-aware three-surface PBR blending with dry, damp, + puddled, rough, smooth, and clear-coated regions plus world-space + non-repeating macro variation. Three fixed-capacity global instance batches + render deterministic rocks, ground cover, and shrubs with distance/viewport + culling and no steady-state allocation. Fixed biome beauty views pass native + and WebGL2 ROI/non-repetition probes, including resize and half-float-disabled + fallback. The live camera frames a terrain-elevated 1.8-metre pawn from a + metric 14-metre-up/21-metre-back offset. +- **Wave 3 — Pawns: DONE + verified.** Actor protocol extended to the + render-relevant field set + compact move/ref fast path (`client-proto`, + decode-tested). Pawn pack loader (`pawn/pack.rs` — `PawnTemplate` from a real + PawnForge body GLB: skeleton + clip index + baked skinned parts, tested + against `pawn_male.glb` = 50 joints). PawnAnimator locomotion lane + (idle/walk/run/backpedal/death gait + hysteresis, weapon lanes). Equipment/ + appearance/weapons (skin/faction tint, hand-bone weapon socketing), face-kit + compositor (`pawn/face.rs`), creatures registry (`pawn/creatures.rs`), LOD + tiers (`pawn/lod.rs`). Demo `--demo pawns` screenshot-verified. +- **Wave 4 — Projection: DONE + verified.** All snapshot/delta sections typed + (`client-proto::packets`), `AuthorityStore` apply/merge with lifecycleSeq + staleness + receipt dedupe (`game/authority.rs`), `MovePredictor` lead-capped + prediction from `tuning.v1.json` (`game/prediction.rs`), `ActorInterp` remote + interpolation ring (`game/interp.rs`), `CommandQueue` priority flush + (`game/command_queue.rs`). Unit-tested (26 tests across the four modules). +- **Wave 5 — UI: DONE + verified.** Asset bake tool (`tools/bake-assets`, + detached crate) rasterizes the 39-icon SVG vocabulary into a committed A8 + atlas via distance-field stroking; baked 5×7 bitmap font (`engine-render:: + font`) renders readable HUD text. Immediate-mode UI (`engine-render::ui` + `UiBuilder`) draws panels/borders/text/icons/bars over an alpha-blended pass + (`PipelineState.blend`, `Renderer::render_ui`), with hover/press/click + interaction, a `TextField` line editor, and pointer/text-input routing from + the platform (`mouse_position`/`mouse_button_down`/`poll_text_input`). Desktop + window manager (`engine-render::window` — move/resize/focus-to-front/close, + z-order, viewport clamp; unit-tested). HUD panels (`app::hud`) bind a + `HudState` to a vitals panel (HP/AP/shield bars), minimap+coords, target + frame, search field, and a 12-button action bar that toggles windows. + `--demo ui` screenshot-verified end to end; gates green, baseline refreshed + (native 1.03 MB, wasm 132 KB — far under 4/3 MiB ceilings; p99 3.35 ms; 0 + frame allocs). +- **Wave 6 — Windows: DONE + verified.** Immediate-mode content for the full + game-window suite under `app::windows` (18 windows: inventory, character, + skills, options, loot, bank, trade, craft, survey, converse, travel, datapad, + clone, pa, splice, macros, actions, bug-report). Each is a self-contained + module (typed view model + `sample()` + `draw(ui, rect, model, icons, out)`) + behind a `content()` dispatch, emitting `WindowAction`s the host maps to + commands (live binding is Wave 11). Reuses the Wave-5 `UiBuilder`/`WindowManager`. + 92 app unit tests (24 window interaction tests) pass; `--demo ui` renders the + windows over the scene (inventory grid + examine sidebar and the craft + recipe/CRAFT-button screenshot-verified). Gates green, baseline refreshed + (native 1.07 MB, wasm 132 KB; p99 3.54 ms; 0 frame allocs). +- **Wave 7 — Combat FX: DONE + verified.** Zero-per-frame-alloc particle pool + (`engine-render::fx`, port of `render/fx/particles.ts`): three ring-buffered + layers (additive/normal/residue), CPU integration with drag+gravity+ground + splat, size/alpha/color-over-life, camera-facing billboard emission + a + procedural glow sprite. Emitters ported: spark burst, blood burst, muzzle + flash, tracer (deterministic xorshift RNG). New GL additive blend + (`PipelineState.additive`) + a `Renderer::render_particles` pass. Combat-event + driver (`game::combat_fx::CombatFx`) projects the wire `events: + Vec` into typed `CombatEvent`s and fires muzzle/tracer + blood/spark by + outcome, deduped by id; wired live in `connected::run` (taps snapshot/delta/ + receipts events, renders billboards over the scene each frame). Weapon rigs + reuse Wave-3 hand-bone socketing. 28 render + 96 app tests; `--demo fx` + screenshot shows sparks + a blood cone. Gates green (native 1.16 MB, wasm + 133 KB; 0 frame allocs). +- **Wave 8 — Audio: DONE + verified.** Software mixer + spatialization + (`engine-core::audio`, port of `sfx.ts` mixing): fixed voice pool, equal-power + pan, pitch resample, polyphony + concurrency-overload attenuation, and an + exact `spatial_mix` (distance rolloff + smooth far cutoff + clamped pan). + Native MP3 decode via `rmp3` (pure Rust, native-only — web decodes through + Web Audio) verified against real 44.1 kHz manifest clips. `app::audio` + manifest-driven clip registry with buses + a game-event trigger map (UI cues, + combat weapon/impact from the shared `CombatEvent`, footsteps). Output sinks: + a WAV render (`app::audio::wav`, verified end to end — manifest→decode→mixer→ + non-silent stereo WAV) and a live CoreAudio `AudioQueue` device sink + (`platform::native::audio`, links AudioToolbox; audible playback confirmed + interactively). 8 core + 9 app audio tests. Gates green (native 1.16 MB, wasm + 133 KB — decoder is native-only; 0 frame allocs). +- **Wave 9 — Environment: DONE + verified.** Day-night sampler + (`engine-render::environment`, port of config `environment`): sun dir/color/ + elevation + a time-of-day color grade (fog/tint/desaturate/darken/black-lift/ + bloom) interpolating the authored anchors, wrapping midnight. Weather system + (`engine-render::weather`: clear/rain/dust-storm, eased intensity, wind + wander/gust, particle emission). PS2 post-grade fullscreen pass + (`Renderer::render_post` + `post.frag`) + `PipelineState.additive`. Flora + scatter (`app::world::flora`: deterministic, density-scaled, exclusion, + instance matrices). `--demo env` screenshot-verified: noon reads warm+bright + (max 103, R>B), night cool+dim (max 38, B≥R). 6 env + 4 weather + flora tests. +- **Wave 10 — Chat: DONE + verified.** Sans-IO chat protocol + (`game::chat_net`: 9 channels, encode/decode JSON, bounded history) + a chat + pane and world-space bubbles (`game::chat_ui`, immediate-mode). Unit-tested + (channel round-trip, compose↔decode, history bound, pane/bubble render). +- **Wave 11 — Auth/entry: DONE + verified.** Connect-URL + join-options parse + (`net::connect`), entry + character screens (`app::screens`, UiBuilder), and + release identity + reconnect policy (`net::release`: unlisted channel guard + + exponential backoff). 15 net + 5 screen tests. Per AGENTS.md the client stays + UNALLOWLISTED (channel `unlisted`; `is_production()` guard). +- **Wave 12 — Web + audit: DONE + verified.** Wasm networking runtime + (`lib::web_runtime` `net_connect`/`net_poll`/`net_state`) reuses the + target-agnostic `Session` + Colyseus matchmake/framing over the browser + WebSocket/fetch shim; the JS page drives connect+poll each frame. Full + suite: 223 unit/fixture tests across 4 crates, `frame-allocs 0`, `VERIFY: + PASS` (native 1.18 MB, wasm 479 KB — under the 4/3 MiB ceilings). All render + surfaces smoke-tested (`--demo ui/fx/env/pawns/props/terrain`). + +All parity tasks are implemented and source-proven. Native and Chromium +journeys exercise the live authority, current world fixture, command families, +window/HUD surfaces, receipts, failure lifecycle, and visual composition. +Deterministic native replays reproduce each journey group. The complete Rust +workspace, no-std, corpus, allocation, runtime, RSS, size, render, terrain, and +WebGL2 fallback/recovery gates are the acceptance boundary. + +## Connected-scene integration + +`game::connected_scene::ConnectedScene` composes one `GameWorld` and renderer +from the transactional `AuthorityStore`: area-scoped terrain and GLB props, +PawnForge bodies/equipment, structures and stateful world objects, +orthographic camera/minimap, streamed environment, combat/status effects, +labels, HUD, toolbar, and live windows. Native and wasm shells supply only +platform services and feed the same scene; neither owns gameplay simulation. + +Live proof against the checked-in open-desert fixture reaches `Ready`, streams +the accepted area, moves through queued `SetMoveIntent`, settles receipts, +renders full inventory/workflow state, and exits cleanly. The Chromium build +also proves resized rendering, forced RGBA8 fallback, gesture-gated audio, and +WebGL context recovery while retaining the network session and authority +projection. diff --git a/client-rust/README.md b/client-rust/README.md new file mode 100644 index 00000000..3c83bc6a --- /dev/null +++ b/client-rust/README.md @@ -0,0 +1,287 @@ +# Successor Rust client (`client-rust/`) + +In-development native + web Rust client. Standalone Cargo workspace, +deliberately **outside** the root Rust workspace and the pnpm workspace — root +repo gates do not cover it; the gates below are mandatory for any change here. + +Not a supported player surface yet: do not publish it, link it from the site, +or add it to the download ledger until parity is proven and a product promotion +happens (see the repo `AGENTS.md`). + +## Prerequisites + +### Rust toolchain (automatic) + +`rust-toolchain.toml` pins stable with `rustfmt` + `clippy` and the two +cross-compile targets (`wasm32-unknown-unknown`, `thumbv7em-none-eabihf`). +Install [`rustup`](https://rustup.rs); it provisions the toolchain and targets +on the first `cargo`/`make` invocation. No manual `rustup target add` needed. + +### System packages + +| Need | Why | Provides | +|------|-----|----------| +| C toolchain (`gcc`/`cc`) | build scripts + final link | `cc` | +| `pkg-config` | locate `glfw3` at build time | `pkg-config` | +| GLFW 3 | native desktop window + input (`glfw3.pc`) | `libglfw`, `glfw3.pc` | +| OpenGL loader | native GL backend (`-lGL`) | `libGL` | +| OpenSSL | native TLS for the WebSocket transport (`native-tls`) | `libssl` | +| Python 3 | perf/size/runtime gate scripts (`bench/compare.py`) | `python3` | +| WABT **or** LLVM | `wasm-strip` / `llvm-strip` for the wasm size gate | `wasm-strip` | + +MP3 decode (native) and the web GL/WebSocket/fetch surface are pure Rust / the +JS shim — no extra system packages. + +#### Arch Linux (verified on this machine) + +```sh +sudo pacman -S --needed rustup base-devel pkg-config glfw mesa openssl python wabt +``` + +`base-devel` covers `gcc`/`pkg-config`; `mesa` provides `libGL`; `glfw` ships +`glfw3.pc`; `wabt` provides `wasm-strip`. + +#### Debian / Ubuntu + +```sh +sudo apt install build-essential pkg-config libglfw3-dev libgl1-mesa-dev \ + libssl-dev python3 wabt +``` + +#### Fedora + +```sh +sudo dnf install gcc pkgconf-pkg-config glfw-devel mesa-libGL-devel \ + openssl-devel python3 wabt +``` + +Verify the two package deps that are easy to miss: + +```sh +pkg-config --modversion glfw3 # expect 3.x +command -v wasm-strip llvm-strip # at least one must resolve +``` + +## Build & run + +Everything is driven by the `Makefile`; artifacts land in `out/`. + +```sh +make native # release desktop binaries -> out/bin/successor{,-control} +make web # release wasm module -> out/web/successor.wasm (+ shim) +make all # native + web + +make run # build native, then launch it +./out/bin/successor --demo parity-basic --gl # windowed visual QA +./out/bin/successor --demo terrain --frames 5 --screenshot /tmp/shot.bmp + +make serve # build web, serve repo assets + out/web on http://127.0.0.1:18081 +``` + +`make serve` runs `tools/dev-serve.mjs`, a loopback dev server that maps the +repository's authoritative asset directories straight from disk — `make web` +copies no assets. `make serve-dev` additionally injects a development launch +envelope (`dev-identity` tickets) so the page enters the world directly against +a loopback authority (`pnpm server:local:persistent`, port 28093). +`make connect-local PLAYER=name` does the native equivalent. +`--release

` serves an assembled release directory instead, including its +`/objects/` content-addressed namespace. + +The production-capability native binary does not yet own account login. An +ordinary launch requires `--launch-context `; `--demo pregame` +is a `dev-tools` presentation harness with no account or roster authority. +Continue the real callsign/password, roster, creator, and play-ticket path from +`../docs/HANDOFF_NATIVE_FIRST_ENTRY_20260807.md`. + +## Web release assembly and staged loading + +`make web-release` (or `node tools/web-release.mjs ...`) assembles the +immutable release directory under `out/web-release/`: + +- `index.html`, `successor.js` (release shim), `successor.wasm`, + `release-manifest.json`, the content packs under `packs/`, and every + standalone asset file (pawn-pack wardrobe trees stay standalone for + on-demand streaming). +- The release manifest is `successor.rust-web-release.v2`: `files[]` carry + `{path, bytes, sha256}`, `boot[]` lists the boot-closure asset ids, and + `packIndex` maps packed asset id → `{pack, offset, bytes}`. +- Pack format (`tools/boot-closure.mjs` owns the boot set): `SPAK1\n` magic, + u32le index length, JSON index (`successor.assetpack.v1`, entries with + `{path, offset, bytes, sha256}`), then concatenated payloads. Packs are + immutable once published; `boot.spak`, `audio-boot.spak`, `audio-rest.spak`, + and `world-rest.spak` are emitted today. +- The shim fetches `boot.spak` (+ standalone boot ids) and the + `bootPacks`-listed spawn-region packs before launch, then `audio-boot.spak` + and the shared remainder packs in the background after the first world + frame. Region packs, creature GLBs, and the pawn-pack wardrobe are **not** + prefetched: they stream on demand through the async asset channel. +- Phase 5 streaming: `Platform::begin_asset`/`poll_asset` (FFI imports + `js_asset_begin`/`js_asset_poll` on web, immediate resolution on native) + back an `AssetStreamer` (`source/app/src/assets/stream.rs`, 64 in-flight + slots, terminal-failure set, completion epoch). Consumers: pawn bodies + (specials/creatures), weapon rigs + hand specs, equipment pieces, world + props, and the item preview. `Pending` renders without the piece and + retries on the completion epoch; `Missing` (terminal) maps to the typed + fallback or explicit missing-asset marker — never a refetch loop. +- World props group into region packs (`packs/region//-.spak`, + region = 128×128 cells) plus a `sparse.spak` for sub-256KiB mergers. A + region watcher streams the player's 3×3 neighborhood; the travel hold keeps + the transition up until the destination **spawn region** settles (30s + deadline forces fail-closed marker placement), with the wider neighborhood + streaming in right after release. Only the spawn region's packs are staged + in `bootPacks`, keeping the stage-1 wire ≈24MB gzipped. +- Custom weapon attach specs resolve lazily: `warm_custom_specs` fills the + item-id alias table in the background; an unmapped `weaponItemId` pends its + rig until the table settles rather than mis-binding a legacy rig. +- Audio clips decode on first trigger; no synchronous fallback paths remain + (the pack-aware sync fallback is only reachable when the async channel is + absent, e.g. tests). +- `node tools/web-release.mjs --local-dev --out out/web-release-local ...` + builds a loopback-only variant that keeps the development launch path for + full release-path verification against a local authority. The published + beta never uses it; the release shim otherwise strips URL/dev launches and + requires hosted tickets. + +The publisher (`ops/deploy/scripts/publish-client-assets.mjs --object-store`) +uploads this tree into the content-addressed object store: object key = +`objects/`, gzip level 9 for compressible +types (including `.spak`), 16-wide upload pool, and the publish order +objects → release-scoped files → manifest → `current.json`. Re-publication of +an unchanged client is six small requests. + +Headless entry points (no window, used by the gates): + +```sh +./out/bin/successor --demo parity-basic --frames 600 --stats-json out/stats.json +``` + +## Agent control, screenshots, and input replay + +The native client has a developer-only loopback control server. It is disabled +by default and never listens on a non-loopback address. Enable it explicitly +with `--control`, `--control-port N`, or `SUCCESSOR_CONTROL=1` (with optional +`SUCCESSOR_CONTROL_PORT=N`). Port `0` requests an ephemeral port; the client +prints `successor_control_server=127.0.0.1:` after binding. + +`make native` also builds `out/bin/successor-control`. It accepts one command +from argv, a script with `--file`, or commands from stdin while retaining one +connection for the whole stream: + +```sh +./out/bin/successor \ + --endpoint ws://127.0.0.1:28093 --player-id agent-1 --actor-id agent-1 \ + --control-port 47778 + +printf '%s\n' \ + 'key down w' \ + 'wait 750' \ + 'key up w' \ + 'screenshot /tmp/agent-view.bmp' \ + | ./out/bin/successor-control --port 47778 +``` + +Requests are UTF-8 lines and responses are one JSON object per line. The CLI's +`wait ` is local script timing; server commands are: + +```text +key +mouse move +mouse +text +scroll +screenshot +record start +record stop +status +quit +``` + +While a control connection is active, or a remote key/button remains held, +remote state replaces local GLFW input. A screenshot is read from the rendered +frame before swap and acknowledged only after the BMP is written. This makes +the JSON response a completion boundary an agent can trust. + +Start a frame-indexed recording with `--record-input PATH` or the `record +start` command. The current-only file begins with `successor.input.v1`; each +following row is `frameNcommand`. Native and remote key transitions, +pointer moves/buttons, text, and scroll are captured. Replay with +`--replay-input PATH`; replay owns input for the run, rejects malformed or +out-of-order files, and rejects live input mutation while still allowing +`status`, `screenshot`, and `quit`. + +The server, recorder, replay loader, and screenshot writer are native +developer tooling. They are not compiled into the web backend, do not bypass +the Colyseus command path, and do not become gameplay authority. + +## Graphics mastering + +Press Backquote (`` ` ``) in the native connected client or the windowed +`parity-basic` demo to toggle the developer graphics-mastering overlay. Its +Lighting, Post / AA, and Color / Palette pages edit the active Low, Medium, or +High preset live. Controls cover sun direction/color/intensity, ambient and +material AO/emissive response, shadow resolution/range/bias/penumbra, bloom +threshold/intensity/radius, exposure, FXAA thresholds, lift/gamma/gain, +temperature/tint, saturation/contrast, and final-output palette quantization +with ordered dithering. + +`SAVE` atomically replaces `assets/render/settings.json`; `RELOAD` validates +and reapplies that file; `RESET PRESET` restores the selected built-in preset. +Startup validates the current-only `successor.render-settings.v1` document and +falls back to compiled defaults if the asset is missing or invalid. Browser +builds embed and apply the checked-in settings but do not expose native file +writes. Preset shader topology (HDR/GI/filter tier) is selected at startup; +uniform and shadow-target controls apply immediately. + +The overlay uses ordinary platform input, including remote `key tap +backquote`, so the loopback controller, screenshot completion boundary, and +`successor.input.v1` replay can verify the same UI a developer uses. + + + +## Gates (mandatory for changes under `client-rust/`) + +```sh +make verify # unit tests + perf gate + stripped-size gate -> "VERIFY: PASS" +make check-allocs # steady-state frame loop must report frame-allocs 0 +make runtime-check # frame p50/p99, peak RSS, allocs vs baseline + ceilings +make render-check # material-parity GPU p99 +make terrain-check # biome probes, non-repetition, and terrain GPU p99 +make nostd # engine crates still build for thumbv7em-none-eabihf +``` + +Authoritative budgets live in `budgets.json`; regression thresholds are checked +against a **per-machine baseline** in `bench/baselines/.json`. + +The fidelity-first absolute caps are 6 MiB stripped native, 4 MiB stripped +WebAssembly, 8.33 ms runtime/terrain p99, 16.67 ms generic render p99, zero +steady-state frame allocations, and 512 MiB peak RSS. Baseline headroom is +`max(512 KiB, 25%)` for size and 100% for performance; absolute caps still +bound the result. This deliberately leaves room for full multi-surface PBR +terrain, displacement, and living detail instead of optimizing presentation +features away. + +### First run on a new machine + +`make verify` / `runtime-check` fail with `no baseline for this machine` until +one exists. Capture it once, then review/commit the resulting file like code: + +```sh +make bench-baseline # writes bench/baselines/.json +``` + +An intentional perf/size regression ships an updated baseline in the same change +with a written justification (see `AGENTS.md`). + +## Troubleshooting + +- **`The system library 'glfw3' required by crate 'successor-platform' was not + found`** or link error `-lglfw` — GLFW dev package missing; install `glfw` + (see above) and confirm `pkg-config --modversion glfw3`. +- **`rust-lld: error: ... undefined symbol: glClear` (wasm build)** — the raw + `extern "C"` GL/JS import blocks need `#[link(wasm_import_module = "env")]`; + the checked-in web modules already declare it. If you add a new JS import + block under `source/platform/src/web/`, annotate it the same way so rust-lld + emits it as an `env` import instead of an undefined symbol. +- **`FAIL: no baseline for this machine`** — run `make bench-baseline` (above). +- **`wasm-strip: command not found`** during `make size-check`/`verify` — + install `wabt` (or `llvm` for `llvm-strip`). diff --git a/client-rust/assets/render/settings.json b/client-rust/assets/render/settings.json new file mode 100644 index 00000000..764563df --- /dev/null +++ b/client-rust/assets/render/settings.json @@ -0,0 +1,202 @@ +{ + "schema": "successor.render-settings.v1", + "version": 1, + "selected_preset": "medium", + "presets": { + "low": { + "ambient_intensity": 0.5, + "emissive_scalar": 1.0, + "auto_point_light_scale": 1.0, + "exposure": 1.0, + "bloom": { + "threshold": 1.0, + "intensity": 0.25, + "radius": 0.75 + }, + "sun": { + "azimuth_degrees": -45.0, + "elevation_degrees": 55.0, + "color": [ + 1.0, + 0.97, + 0.9 + ], + "intensity": 1.0 + }, + "aa": { + "enabled": true, + "edge_threshold_min": 0.0312, + "edge_threshold": 0.125, + "subpixel_blend": 0.5 + }, + "ao": { + "intensity": 1.0 + }, + "shadows": { + "map_size": 1024, + "world_radius": 48.0, + "depth_bias": 0.0015, + "normal_bias": 1.5, + "penumbra": 24.0 + }, + "color_grading": { + "saturation": 1.0, + "contrast": 1.0, + "gamma": 1.0, + "temperature": 0.0, + "tint": 0.0, + "lift": [ + 0.0, + 0.0, + 0.0 + ], + "color_gamma": [ + 1.0, + 1.0, + 1.0 + ], + "gain": [ + 1.0, + 1.0, + 1.0 + ] + }, + "palette": { + "enabled": false, + "levels": 16, + "strength": 0.0, + "dither": 0.0 + } + }, + "medium": { + "ambient_intensity": 0.78, + "emissive_scalar": 1.0, + "auto_point_light_scale": 0.37007576, + "exposure": 1.08, + "bloom": { + "threshold": 1.0, + "intensity": 0.55, + "radius": 1.0 + }, + "sun": { + "azimuth_degrees": -45.0, + "elevation_degrees": 55.0, + "color": [ + 1.0, + 0.97, + 0.9 + ], + "intensity": 2.45 + }, + "aa": { + "enabled": true, + "edge_threshold_min": 0.0312, + "edge_threshold": 0.125, + "subpixel_blend": 0.75 + }, + "ao": { + "intensity": 1.0 + }, + "shadows": { + "map_size": 2048, + "world_radius": 48.0, + "depth_bias": 0.0015, + "normal_bias": 1.5, + "penumbra": 40.0 + }, + "color_grading": { + "saturation": 1.1, + "contrast": 1.0, + "gamma": 1.0, + "temperature": 0.02, + "tint": 0.0, + "lift": [ + 0.03, + 0.025, + 0.02 + ], + "color_gamma": [ + 1.0, + 1.0, + 1.0 + ], + "gain": [ + 1.0, + 1.0, + 1.0 + ] + }, + "palette": { + "enabled": false, + "levels": 16, + "strength": 0.0, + "dither": 0.0 + } + }, + "high": { + "ambient_intensity": 0.5, + "emissive_scalar": 1.0, + "auto_point_light_scale": 1.0, + "exposure": 1.0, + "bloom": { + "threshold": 1.0, + "intensity": 0.7, + "radius": 1.35 + }, + "sun": { + "azimuth_degrees": -45.0, + "elevation_degrees": 55.0, + "color": [ + 1.0, + 0.97, + 0.9 + ], + "intensity": 1.0 + }, + "aa": { + "enabled": true, + "edge_threshold_min": 0.02, + "edge_threshold": 0.09, + "subpixel_blend": 0.85 + }, + "ao": { + "intensity": 1.0 + }, + "shadows": { + "map_size": 4096, + "world_radius": 48.0, + "depth_bias": 0.0015, + "normal_bias": 1.5, + "penumbra": 55.0 + }, + "color_grading": { + "saturation": 1.0, + "contrast": 1.0, + "gamma": 1.0, + "temperature": 0.0, + "tint": 0.0, + "lift": [ + 0.0, + 0.0, + 0.0 + ], + "color_gamma": [ + 1.0, + 1.0, + 1.0 + ], + "gain": [ + 1.0, + 1.0, + 1.0 + ] + }, + "palette": { + "enabled": false, + "levels": 16, + "strength": 0.0, + "dither": 0.0 + } + } + } +} diff --git a/client-rust/assets/shaders/bloom_blur.frag b/client-rust/assets/shaders/bloom_blur.frag new file mode 100644 index 00000000..8c6a609c --- /dev/null +++ b/client-rust/assets/shaders/bloom_blur.frag @@ -0,0 +1,12 @@ +in vec2 v_uv; +uniform sampler2D u_source; +uniform vec2 u_direction; +out vec4 frag; +void main() { + vec3 color = texture(u_source, v_uv).rgb * 0.2270270270; + color += texture(u_source, v_uv + u_direction * 1.3846153846).rgb * 0.3162162162; + color += texture(u_source, v_uv - u_direction * 1.3846153846).rgb * 0.3162162162; + color += texture(u_source, v_uv + u_direction * 3.2307692308).rgb * 0.0702702703; + color += texture(u_source, v_uv - u_direction * 3.2307692308).rgb * 0.0702702703; + frag = vec4(color, 1.0); +} diff --git a/client-rust/assets/shaders/bloom_extract.frag b/client-rust/assets/shaders/bloom_extract.frag new file mode 100644 index 00000000..628264c4 --- /dev/null +++ b/client-rust/assets/shaders/bloom_extract.frag @@ -0,0 +1,8 @@ +in vec2 v_uv; +uniform sampler2D u_scene; +uniform float u_threshold; +out vec4 frag; +void main() { + vec3 scene = texture(u_scene, v_uv).rgb; + frag = vec4(max(scene - vec3(u_threshold), vec3(0.0)), 1.0); +} diff --git a/client-rust/assets/shaders/composite.frag b/client-rust/assets/shaders/composite.frag new file mode 100644 index 00000000..b01fd6fe --- /dev/null +++ b/client-rust/assets/shaders/composite.frag @@ -0,0 +1,7 @@ +// Composite/RTT pass: sample a render target's color texture. +in vec2 v_uv; +uniform sampler2D u_tex; +out vec4 frag; +void main() { + frag = texture(u_tex, v_uv); +} diff --git a/client-rust/assets/shaders/composite.vert b/client-rust/assets/shaders/composite.vert new file mode 100644 index 00000000..38f3bb5b --- /dev/null +++ b/client-rust/assets/shaders/composite.vert @@ -0,0 +1,8 @@ +// Composite/RTT pass: a_pos is already in NDC. +layout(location = 0) in vec2 a_pos; +layout(location = 1) in vec2 a_uv; +out vec2 v_uv; +void main() { + v_uv = a_uv; + gl_Position = vec4(a_pos, 0.0, 1.0); +} diff --git a/client-rust/assets/shaders/copy.frag b/client-rust/assets/shaders/copy.frag new file mode 100644 index 00000000..2611e9c5 --- /dev/null +++ b/client-rust/assets/shaders/copy.frag @@ -0,0 +1,9 @@ +precision highp float; + +in vec2 v_uv; +uniform sampler2D u_source; +out vec4 out_color; + +void main() { + out_color = texture(u_source, v_uv); +} diff --git a/client-rust/assets/shaders/deferred_light.frag b/client-rust/assets/shaders/deferred_light.frag new file mode 100644 index 00000000..fdd5d82c --- /dev/null +++ b/client-rust/assets/shaders/deferred_light.frag @@ -0,0 +1,268 @@ +// Deferred sun lighting: reconstruct world position from depth, decode the +// G-buffer, evaluate a Cook-Torrance PBR sun term with soft shadows, add +// ambient (VXGI cone trace when GI_CONES>0, else a hemisphere floor), then fog. +// Tier behavior is compile-time via SHADOW_TAPS / PCSS / GI_CONES / GI_SPECULAR +// (#define lines prepended by the renderer). Pairs with post.vert. +#ifndef SHADOW_TAPS +#define SHADOW_TAPS 12 +#endif +#ifndef PCSS +#define PCSS 0 +#endif +#ifndef GI_CONES +#define GI_CONES 0 +#endif +#ifndef GI_SPECULAR +#define GI_SPECULAR 0 +#endif + +in vec2 v_uv; + +uniform sampler2D u_gb0; +uniform sampler2D u_gb1; +uniform sampler2D u_gb2; +uniform sampler2D u_gb3; +uniform sampler2D u_depth; +uniform sampler2D u_shadowMap; +#if GI_CONES > 0 +uniform sampler3D u_gi; +uniform vec3 u_giOrigin; +uniform int u_giReady; +uniform vec3 u_giValidMin; +uniform vec3 u_giValidMax; +uniform float u_giBlend; +uniform float u_giCell; +uniform float u_giStrength; +#endif + +uniform mat4 u_invViewProj; +uniform mat4 u_lightViewProj; +uniform vec3 u_lightDir; +uniform vec3 u_lightColor; +uniform vec3 u_camEye; +uniform float u_ambient; +uniform vec3 u_fogColor; +uniform float u_fogNear; +uniform float u_fogFar; +uniform float u_shadowTexelUV; // 1.0 / shadow map size +uniform float u_shadowWorldTexel; // world units per shadow texel (normal offset) +uniform float u_sunPenumbraScale; // PCSS penumbra gain +uniform float u_exposure; +uniform float u_shadowDepthBias; +uniform float u_shadowNormalBias; +uniform float u_emissiveScalar; +uniform float u_aoIntensity; + +out vec4 frag; + +const float GI_SIZE = 64.0; + +vec3 decodeOctahedral(vec2 encoded) { + vec2 f = encoded * 2.0 - 1.0; + vec3 normal = vec3(f, 1.0 - abs(f.x) - abs(f.y)); + float fold = clamp(-normal.z, 0.0, 1.0); + normal.xy += vec2(normal.x >= 0.0 ? -fold : fold, normal.y >= 0.0 ? -fold : fold); + return normalize(normal); +} +// 16-entry Poisson disk (unit radius). +const vec2 POISSON[16] = vec2[16]( + vec2(-0.94201624, -0.39906216), vec2(0.94558609, -0.76890725), + vec2(-0.094184101, -0.92938870), vec2(0.34495938, 0.29387760), + vec2(-0.91588581, 0.45771432), vec2(-0.81544232, -0.87912464), + vec2(-0.38277543, 0.27676845), vec2(0.97484398, 0.75648379), + vec2(0.44323325, -0.97511554), vec2(0.53742981, -0.47373420), + vec2(-0.26496911, -0.41893023), vec2(0.79197514, 0.19090188), + vec2(-0.24188840, 0.99706507), vec2(-0.81409955, 0.91437590), + vec2(0.19984126, 0.78641367), vec2(0.14383161, -0.14100790) +); + +float ign(vec2 p) { + // Interleaved gradient noise -> [0, 2pi) rotation. + return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))) * 6.2831853; +} + + +float sampleShadow(vec2 uv, float compare) { + float d = texture(u_shadowMap, uv).r; + return compare > d ? 0.0 : 1.0; +} + +float softShadow(vec3 P, vec3 N, float NdotL) { + vec4 lp = u_lightViewProj * vec4(P + N * u_shadowWorldTexel * u_shadowNormalBias, 1.0); + vec3 proj = lp.xyz / lp.w; + proj = proj * 0.5 + 0.5; + if (proj.z > 1.0) return 1.0; + float bias = clamp(u_shadowDepthBias * tan(acos(clamp(NdotL, 0.0, 1.0))), 0.0, 0.05); + float zR = proj.z - bias; + float ang = ign(gl_FragCoord.xy); + float ca = cos(ang), sa = sin(ang); + mat2 rot = mat2(ca, -sa, sa, ca); + + float radius = 2.0 * u_shadowTexelUV; +#if PCSS + // Blocker search (8 taps) -> average blocker depth -> penumbra. + float bsum = 0.0; float bcount = 0.0; + for (int i = 0; i < 8; i++) { + vec2 o = rot * POISSON[i] * (4.0 * u_shadowTexelUV); + float d = texture(u_shadowMap, proj.xy + o).r; + if (d < zR) { bsum += d; bcount += 1.0; } + } + if (bcount < 0.5) return 1.0; + float zB = bsum / bcount; + float pen = (zR - zB) / max(zB, 1e-4) * u_sunPenumbraScale; + radius = clamp(pen, 0.5, 6.0) * u_shadowTexelUV; +#endif + + float sum = 0.0; + for (int i = 0; i < SHADOW_TAPS; i++) { + vec2 o = rot * POISSON[i] * radius; + sum += sampleShadow(proj.xy + o, zR); + } + return sum / float(SHADOW_TAPS); +} + +#if GI_CONES > 0 +vec4 coneTrace(vec3 origin, vec3 dir, float aperture) { + vec4 acc = vec4(0.0); + float t = u_giCell; + for (int i = 0; i < 5; i++) { + float d = max(aperture * t, u_giCell); + float mip = max(log2(d / u_giCell), 0.0); + vec3 pos = origin + dir * t; + if (u_giReady == 0 || any(lessThan(pos, u_giValidMin)) || any(greaterThan(pos, u_giValidMax))) break; + vec3 uvw = vec3( + fract(pos.x / (GI_SIZE * u_giCell)), + (pos.y - u_giOrigin.y) / (GI_SIZE * u_giCell), + fract(pos.z / (GI_SIZE * u_giCell)) + ); + if (uvw.y < 0.0 || uvw.y > 1.0) break; + vec4 s = textureLod(u_gi, uvw, mip); + acc.rgb += (1.0 - acc.a) * s.a * s.rgb; + acc.a += (1.0 - acc.a) * s.a; + if (acc.a > 0.95) break; + t *= 1.7; + } + return acc; +} + +// Distance (world units) from P to the nearest currently valid face. +float volumeBorderDist(vec3 P) { + vec3 lo = P - u_giValidMin; + vec3 hi = u_giValidMax - P; + return min(min(min(lo.x, lo.y), lo.z), min(min(hi.x, hi.y), hi.z)); +} + +vec3 diffuseGI(vec3 P, vec3 N, out float ao) { + vec3 up = abs(N.y) < 0.95 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0); + vec3 T = normalize(cross(up, N)); + vec3 B = cross(N, T); + vec3 origin = P + N * (0.5 * u_giCell); + vec3 irr = vec3(0.0); + float occ = 0.0; + // Central cone along the normal. + vec4 c0 = coneTrace(origin, N, 0.577); + irr += c0.rgb; occ += c0.a; + // Side cones tilted ~55deg, uniformly around the normal. + float tilt = 0.9599; // ~55deg + for (int i = 1; i < GI_CONES; i++) { + float az = 6.2831853 * float(i - 1) / float(GI_CONES - 1); + vec3 dir = normalize(N * cos(tilt) + (T * cos(az) + B * sin(az)) * sin(tilt)); + vec4 c = coneTrace(origin, dir, 0.577); + irr += c.rgb * 0.7; occ += c.a * 0.7; + } + float norm = 1.0 + 0.7 * float(GI_CONES - 1); + ao = clamp(1.0 - occ / norm, 0.0, 1.0); + return irr / norm; +} +#endif + +void main() { + float depth = texture(u_depth, v_uv).r; + if (depth >= 1.0) { + frag = vec4(u_fogColor * u_exposure, 1.0); + return; + } + vec4 clip = vec4(v_uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0); + vec4 wp = u_invViewProj * clip; + vec3 P = wp.xyz / wp.w; + + vec4 g0 = texture(u_gb0, v_uv); + vec4 g1 = texture(u_gb1, v_uv); + vec4 g2 = texture(u_gb2, v_uv); + vec4 g3 = texture(u_gb3, v_uv); + vec3 emission = g2.rgb * g2.a * 32.0 * u_emissiveScalar; + float materialAo = g1.a; + float clearcoat = g3.r; + float clearcoatRoughness = clamp(g3.g, 0.045, 1.0); + float dielectricF0 = g3.b; + vec3 albedo = g0.rgb; + float metallic = g0.a; + vec3 N = decodeOctahedral(g1.rg); + float roughness = clamp(g1.b, 0.045, 1.0); + vec3 V = normalize(u_camEye - P); + vec3 L = normalize(-u_lightDir); + float NdotL = max(dot(N, L), 0.0); + vec3 F; + vec3 baseLobe = pbrBaseLobe( + albedo, + metallic, + roughness, + dielectricF0, + N, + V, + L, + F + ); + float clearcoatFresnel = 0.0; + vec3 clearcoatLobe = pbrClearcoatLobe( + clearcoat, + clearcoatRoughness, + N, + V, + L, + clearcoatFresnel + ); + float shadow = (NdotL > 0.0) ? softShadow(P, N, NdotL) : 1.0; + vec3 direct = ( + baseLobe * (1.0 - clearcoat * clearcoatFresnel) + + clearcoatLobe + ) * u_lightColor * NdotL * shadow; + + // Ambient / GI. + float ao = 1.0; + vec3 ambient; +#if GI_CONES > 0 + vec3 hemi = u_ambient * albedo; + if (u_giReady != 0) { + float gao; + vec3 irr = diffuseGI(P, N, gao); + ao = gao; + vec3 giAmbient = irr * albedo * u_giStrength + hemi * mix(0.7, 1.0, ao); + float border = clamp(volumeBorderDist(P) / (4.0 * u_giCell), 0.0, 1.0); + float giWeight = border * u_giBlend; + ambient = mix(hemi, giAmbient, giWeight); +#if GI_SPECULAR + vec3 R = reflect(-V, N); + vec4 sgi = coneTrace(P + N * (0.5 * u_giCell), R, mix(0.05, 0.6, roughness)); + ambient += sgi.rgb * F * u_giStrength * giWeight; +#endif + } else { + ambient = hemi; + } +#else + ambient = u_ambient * albedo; +#endif + + ambient *= pow(clamp(materialAo * ao, 0.001, 1.0), u_aoIntensity); + // Sun occlusion also attenuates broad indirect light enough for small, + // animated casters to remain readable against bright gameplay terrain. + ambient *= mix(0.60, 1.0, shadow); + + vec3 color = direct + ambient + emission; + + float fogD = distance(P, u_camEye); + float fogF = clamp((fogD - u_fogNear) / max(1.0, u_fogFar - u_fogNear), 0.0, 1.0); + color = mix(color, u_fogColor, fogF); + + frag = vec4(color * u_exposure, 1.0); +} diff --git a/client-rust/assets/shaders/depth.frag b/client-rust/assets/shaders/depth.frag new file mode 100644 index 00000000..100279aa --- /dev/null +++ b/client-rust/assets/shaders/depth.frag @@ -0,0 +1,9 @@ +// Shadow depth pass: height-cut geometry must not retain an opaque shadow. +in vec3 v_worldPos; +uniform vec2 u_cutaway; +void main() { + if (v_worldPos.y > u_cutaway.x && u_cutaway.y > 0.0) { + float dither = fract(sin(dot(floor(gl_FragCoord.xy), vec2(12.9898, 78.233))) * 43758.5453); + if (dither < u_cutaway.y) discard; + } +} diff --git a/client-rust/assets/shaders/depth.vert b/client-rust/assets/shaders/depth.vert new file mode 100644 index 00000000..47f0063a --- /dev/null +++ b/client-rust/assets/shaders/depth.vert @@ -0,0 +1,40 @@ +// Shadow depth pass. The renderer prepends SKINNED or INSTANCED. +layout(location = 0) in vec3 a_pos; +layout(location = 1) in vec3 a_normal; +layout(location = 2) in vec2 a_uv; +#ifdef SKINNED +layout(location = 5) in vec4 a_joints; +layout(location = 6) in vec4 a_weights; +uniform mat4 u_joints[50]; +#endif +#ifdef INSTANCED +layout(location = 7) in vec4 a_instance0; +layout(location = 8) in vec4 a_instance1; +layout(location = 9) in vec4 a_instance2; +layout(location = 10) in vec4 a_instance3; +#endif + +uniform mat4 u_model; +uniform mat4 u_lightViewProj; +out vec3 v_worldPos; + +void main() { +#ifdef SKINNED + mat4 skin = + a_weights.x * u_joints[int(a_joints.x)] + + a_weights.y * u_joints[int(a_joints.y)] + + a_weights.z * u_joints[int(a_joints.z)] + + a_weights.w * u_joints[int(a_joints.w)]; + vec4 local = skin * vec4(a_pos, 1.0); +#else + vec4 local = vec4(a_pos, 1.0); +#endif +#ifdef INSTANCED + mat4 instanceModel = mat4(a_instance0, a_instance1, a_instance2, a_instance3); + vec4 world = u_model * instanceModel * local; +#else + vec4 world = u_model * local; +#endif + v_worldPos = world.xyz; + gl_Position = u_lightViewProj * world; +} diff --git a/client-rust/assets/shaders/fxaa.frag b/client-rust/assets/shaders/fxaa.frag new file mode 100644 index 00000000..eb66f108 --- /dev/null +++ b/client-rust/assets/shaders/fxaa.frag @@ -0,0 +1,63 @@ +// FXAA 3.11 quality preset 12, post-tonemap spatial antialiasing. +in vec2 v_uv; +uniform sampler2D u_ldr; +uniform sampler2D u_depth; +uniform vec2 u_invResolution; +uniform int u_enabled; +uniform float u_edgeThresholdMin; +uniform float u_edgeThreshold; +uniform float u_subpixelBlend; +out vec4 frag; + +float luma(vec3 rgb) { return dot(rgb, vec3(0.299, 0.587, 0.114)); } + +void main() { + vec3 rgbM = texture(u_ldr, v_uv).rgb; + if (u_enabled == 0) { + frag = vec4(rgbM, 1.0); + gl_FragDepth = texture(u_depth, v_uv).r; + return; + } + float lumaM = luma(rgbM); + float lumaN = luma(texture(u_ldr, v_uv + vec2(0.0, u_invResolution.y)).rgb); + float lumaS = luma(texture(u_ldr, v_uv - vec2(0.0, u_invResolution.y)).rgb); + float lumaE = luma(texture(u_ldr, v_uv + vec2(u_invResolution.x, 0.0)).rgb); + float lumaW = luma(texture(u_ldr, v_uv - vec2(u_invResolution.x, 0.0)).rgb); + float rangeMin = min(lumaM, min(min(lumaN, lumaS), min(lumaE, lumaW))); + float rangeMax = max(lumaM, max(max(lumaN, lumaS), max(lumaE, lumaW))); + float range = rangeMax - rangeMin; + if (range < max(u_edgeThresholdMin, rangeMax * u_edgeThreshold)) { + frag = vec4(rgbM, 1.0); + gl_FragDepth = texture(u_depth, v_uv).r; + return; + } + float edgeH = abs(lumaN + lumaS - 2.0 * lumaM); + float edgeV = abs(lumaE + lumaW - 2.0 * lumaM); + vec2 direction = edgeH >= edgeV ? vec2(u_invResolution.x, 0.0) : vec2(0.0, u_invResolution.y); + float gradientA = abs((edgeH >= edgeV ? lumaW : lumaS) - lumaM); + float gradientB = abs((edgeH >= edgeV ? lumaE : lumaN) - lumaM); + if (gradientA > gradientB) direction = -direction; + const float STEPS[5] = float[5](1.0, 1.5, 2.0, 4.0, 12.0); + vec2 uvA = v_uv; + vec2 uvB = v_uv; + float edgeLuma = 0.5 * (lumaM + (gradientA > gradientB ? (edgeH >= edgeV ? lumaW : lumaS) : (edgeH >= edgeV ? lumaE : lumaN))); + float endA = lumaM; + float endB = lumaM; + for (int i = 0; i < 5; i++) { + uvA -= direction * STEPS[i]; + uvB += direction * STEPS[i]; + endA = luma(texture(u_ldr, uvA).rgb) - edgeLuma; + endB = luma(texture(u_ldr, uvB).rgb) - edgeLuma; + if (abs(endA) >= range * 0.25 && abs(endB) >= range * 0.25) break; + } + float distanceA = length(v_uv - uvA); + float distanceB = length(uvB - v_uv); + float span = max(distanceA + distanceB, 1e-5); + float offset = 0.5 - min(distanceA, distanceB) / span; + vec2 normal = edgeH >= edgeV ? vec2(0.0, u_invResolution.y) : vec2(u_invResolution.x, 0.0); + vec3 aa = texture(u_ldr, v_uv + normal * offset).rgb; + float subpixel = clamp((lumaN + lumaS + lumaE + lumaW) * 0.25 - lumaM, -range, range); + float blend = clamp(abs(subpixel) / max(range, 1e-5), 0.0, 1.0); + frag = vec4(mix(aa, (rgbM + aa) * 0.5, blend * u_subpixelBlend), 1.0); + gl_FragDepth = texture(u_depth, v_uv).r; +} diff --git a/client-rust/assets/shaders/gbuffer.frag b/client-rust/assets/shaders/gbuffer.frag new file mode 100644 index 00000000..72da6af8 --- /dev/null +++ b/client-rust/assets/shaders/gbuffer.frag @@ -0,0 +1,97 @@ +// glTF material G-buffer: base/metal, normal/roughness/AO, RGBM emission, clearcoat/F0. +in vec3 v_normal; +in vec2 v_uv; +in vec3 v_worldPos; +in vec4 v_color; +in vec4 v_tangent; + +uniform vec4 u_color; +uniform sampler2D u_albedo; +uniform sampler2D u_mrTex; +uniform sampler2D u_normalTex; +uniform sampler2D u_aoTex; +uniform sampler2D u_emissiveTex; +uniform int u_hasTex; +uniform int u_hasMrTex; +uniform int u_hasNormalTex; +uniform int u_hasAoTex; +uniform int u_hasTangent; +uniform int u_hasEmissiveTex; +uniform float u_metallic; +uniform float u_roughness; +uniform float u_normalScale; +uniform float u_aoStrength; +uniform vec3 u_emissiveFactor; +uniform float u_emissiveStrength; +uniform float u_clearcoat; +uniform float u_clearcoatRoughness; +uniform float u_dielectricF0; +uniform float u_alphaCutoff; +uniform vec2 u_cutaway; + +layout(location = 0) out vec4 gb0; +layout(location = 1) out vec4 gb1; +layout(location = 2) out vec4 gb2; +layout(location = 3) out vec4 gb3; + +vec4 encodeRgbm(vec3 color) { + vec3 scaled = color / 32.0; + float m = clamp(max(max(scaled.r, scaled.g), scaled.b), 1.0 / 255.0, 1.0); + m = ceil(m * 255.0) / 255.0; + return vec4(scaled / m, m); +} + +vec2 encodeOctahedral(vec3 normal) { + normal /= abs(normal.x) + abs(normal.y) + abs(normal.z); + vec2 encoded = normal.xy; + if (normal.z < 0.0) { + encoded = (1.0 - abs(encoded.yx)) * sign(encoded.xy); + } + return encoded * 0.5 + 0.5; +} + +vec3 mappedNormal(vec3 geometric, float faceSign) { + vec3 n = normalize(geometric); + if (u_hasNormalTex == 0) return n; + vec3 map = texture(u_normalTex, v_uv).xyz * 2.0 - 1.0; + map.xy *= u_normalScale; + map = normalize(map); + vec3 tangent; + vec3 bitangent; + if (u_hasTangent == 1) { + tangent = normalize(v_tangent.xyz * faceSign - n * dot(n, v_tangent.xyz * faceSign)); + bitangent = normalize(cross(n, tangent)) * v_tangent.w; + } else { + vec3 dp1 = dFdx(v_worldPos); + vec3 dp2 = dFdy(v_worldPos); + vec2 duv1 = dFdx(v_uv); + vec2 duv2 = dFdy(v_uv); + tangent = normalize(dp1 * duv2.y - dp2 * duv1.y); + tangent = normalize(tangent - n * dot(n, tangent)); + bitangent = normalize(cross(n, tangent)); + } + return normalize(mat3(tangent, bitangent, n) * map); +} + +void main() { + if (v_worldPos.y > u_cutaway.x && u_cutaway.y > 0.0) { + float dither = fract(sin(dot(floor(gl_FragCoord.xy), vec2(12.9898, 78.233))) * 43758.5453); + if (dither < u_cutaway.y) discard; + } + vec4 base = u_color * v_color; + if (u_hasTex == 1) base *= texture(u_albedo, v_uv); + if (base.a < u_alphaCutoff) discard; + vec4 mr = u_hasMrTex == 1 ? texture(u_mrTex, v_uv) : vec4(1.0); + float metallic = clamp(u_metallic * mr.b, 0.0, 1.0); + float roughness = clamp(u_roughness * mr.g, 0.045, 1.0); + float aoSample = u_hasAoTex == 1 ? texture(u_aoTex, v_uv).r : 1.0; + float ao = 1.0 + u_aoStrength * (aoSample - 1.0); + vec3 emission = u_emissiveFactor * u_emissiveStrength; + if (u_hasEmissiveTex == 1) emission *= texture(u_emissiveTex, v_uv).rgb; + float faceSign = gl_FrontFacing ? 1.0 : -1.0; + vec3 normal = mappedNormal(v_normal * faceSign, faceSign); + gb0 = vec4(base.rgb, metallic); + gb1 = vec4(encodeOctahedral(normal), roughness, ao); + gb2 = encodeRgbm(emission); + gb3 = vec4(u_clearcoat, u_clearcoatRoughness, u_dielectricF0, 0.0); +} diff --git a/client-rust/assets/shaders/gbuffer.vert b/client-rust/assets/shaders/gbuffer.vert new file mode 100644 index 00000000..a45537f7 --- /dev/null +++ b/client-rust/assets/shaders/gbuffer.vert @@ -0,0 +1,57 @@ +// Deferred G-buffer vertex shader. The GL backend prepends the target header +// (`#version 330 core` / `#version 300 es` + precision); the renderer prepends +// `#define SKINNED 1` or `#define INSTANCED 1` for specialized variants. +layout(location = 0) in vec3 a_pos; +layout(location = 1) in vec3 a_normal; +layout(location = 2) in vec2 a_uv; +layout(location = 3) in vec4 a_tangent; +layout(location = 4) in vec4 a_color; +uniform int u_hasVertexColor; +uniform int u_hasTangent; +#ifdef SKINNED +layout(location = 5) in vec4 a_joints; +layout(location = 6) in vec4 a_weights; +uniform mat4 u_joints[50]; +#endif +#ifdef INSTANCED +layout(location = 7) in vec4 a_instance0; +layout(location = 8) in vec4 a_instance1; +layout(location = 9) in vec4 a_instance2; +layout(location = 10) in vec4 a_instance3; +#endif + +uniform mat4 u_model; +uniform mat4 u_viewProj; + +out vec3 v_normal; +out vec2 v_uv; +out vec3 v_worldPos; +out vec4 v_color; +out vec4 v_tangent; + +void main() { + mat4 deform = u_model; +#ifdef SKINNED + mat4 skin = + a_weights.x * u_joints[int(a_joints.x)] + + a_weights.y * u_joints[int(a_joints.y)] + + a_weights.z * u_joints[int(a_joints.z)] + + a_weights.w * u_joints[int(a_joints.w)]; + deform = u_model * skin; +#endif +#ifdef INSTANCED + mat4 instanceModel = mat4(a_instance0, a_instance1, a_instance2, a_instance3); + deform = u_model * instanceModel; +#endif + vec4 world = deform * vec4(a_pos, 1.0); + gl_Position = u_viewProj * world; + mat3 normalMatrix = transpose(inverse(mat3(deform))); + v_normal = normalMatrix * a_normal; + v_uv = a_uv; + v_color = u_hasVertexColor == 1 ? a_color : vec4(1.0); + vec3 tangent = u_hasTangent == 1 + ? normalize(mat3(deform) * a_tangent.xyz) + : vec3(1.0, 0.0, 0.0); + v_tangent = vec4(tangent, u_hasTangent == 1 ? a_tangent.w : 1.0); + v_worldPos = world.xyz; +} diff --git a/client-rust/assets/shaders/mesh.frag b/client-rust/assets/shaders/mesh.frag new file mode 100644 index 00000000..40070838 --- /dev/null +++ b/client-rust/assets/shaders/mesh.frag @@ -0,0 +1,131 @@ +// Forward material shader used by RTT and sorted transparent scene draws. +in vec3 v_normal; +in vec4 v_lightPos; +in vec2 v_uv; +in vec3 v_worldPos; + +uniform vec3 u_lightDir; +uniform vec3 u_lightColor; +uniform vec4 u_color; // rgb + alpha (alpha < 1 => dithered) +uniform float u_ambient; +uniform sampler2D u_shadowMap; +uniform int u_useShadow; +uniform sampler2D u_albedo; +uniform int u_hasTex; +uniform sampler2D u_aoTex; +uniform sampler2D u_emissiveTex; +uniform int u_hasAoTex; +uniform int u_hasEmissiveTex; +uniform float u_aoStrength; +uniform float u_aoIntensity; +uniform vec3 u_emissiveFactor; +uniform float u_emissiveStrength; +uniform vec3 u_camEye; +uniform vec3 u_fogColor; +uniform float u_fogNear; +uniform float u_fogFar; +uniform sampler2D u_sceneCopy; +uniform sampler2D u_opaqueDepth; +uniform vec2 u_screenSize; +uniform int u_transparentPass; +uniform float u_transmission; +uniform float u_ior; +uniform float u_metallic; +uniform float u_roughness; +uniform float u_dielectricF0; +uniform float u_clearcoat; +uniform float u_clearcoatRoughness; +uniform int u_pointCount; +uniform vec3 u_pointPositions[32]; +uniform float u_pointRadii[32]; +uniform vec3 u_pointColors[32]; +uniform float u_pointIntensities[32]; +uniform vec2 u_cutaway; + +out vec4 frag; + + +float shadowFactor(vec4 lp) { + vec3 proj = lp.xyz / lp.w; + proj = proj * 0.5 + 0.5; + if (proj.z > 1.0) return 1.0; + float bias = 0.0025; + vec2 texel = vec2(1.0 / 2048.0); + float sum = 0.0; + for (int x = -1; x <= 1; x++) { + for (int y = -1; y <= 1; y++) { + float d = texture(u_shadowMap, proj.xy + vec2(float(x), float(y)) * texel).r; + sum += (proj.z - bias > d) ? 0.0 : 1.0; + } + } + return sum / 9.0; +} + +void main() { + if (v_worldPos.y > u_cutaway.x && u_cutaway.y > 0.0) { + float cutawayDither = fract(sin(dot(floor(gl_FragCoord.xy), vec2(12.9898, 78.233))) * 43758.5453); + if (cutawayDither < u_cutaway.y) discard; + } + vec2 screenUv = gl_FragCoord.xy / u_screenSize; + if (u_transparentPass == 1) { + float opaqueDepth = texture(u_opaqueDepth, screenUv).r; + if (gl_FragCoord.z > opaqueDepth + 0.00001) discard; + } + vec3 n = normalize(v_normal); + vec3 viewDir = normalize(u_camEye - v_worldPos); + vec3 sunDir = normalize(-u_lightDir); + float nDotSun = max(dot(n, sunDir), 0.0); + float sh = (u_useShadow == 1) ? shadowFactor(v_lightPos) : 1.0; + vec4 base = u_color; + if (u_hasTex == 1) base *= texture(u_albedo, v_uv); + float aoSample = u_hasAoTex == 1 ? texture(u_aoTex, v_uv).r : 1.0; + float materialAo = 1.0 + clamp(u_aoStrength, 0.0, 1.0) * (aoSample - 1.0); + float ao = pow(clamp(materialAo, 0.001, 1.0), max(u_aoIntensity, 0.0)); + vec3 emission = u_emissiveFactor * u_emissiveStrength; + if (u_hasEmissiveTex == 1) emission *= texture(u_emissiveTex, v_uv).rgb; + vec3 fresnel; + vec3 sunBrdf = pbrBaseLobe( + base.rgb, u_metallic, u_roughness, u_dielectricF0, + n, viewDir, sunDir, fresnel + ); + float coatFresnel; + vec3 coat = pbrClearcoatLobe( + u_clearcoat, u_clearcoatRoughness, n, viewDir, sunDir, coatFresnel + ); + vec3 lit = base.rgb * u_ambient * ao + + (sunBrdf * (1.0 - coatFresnel * u_clearcoat) + coat) + * u_lightColor * nDotSun * sh; + for (int index = 0; index < 32; index++) { + if (index >= u_pointCount) break; + vec3 toLight = u_pointPositions[index] - v_worldPos; + float distanceToLight = length(toLight); + float radius = max(u_pointRadii[index], 0.001); + float attenuation = max(1.0 - distanceToLight / radius, 0.0); + attenuation *= attenuation; + vec3 pointDir = toLight / max(distanceToLight, 0.001); + float nDotPoint = max(dot(n, pointDir), 0.0); + vec3 pointFresnel; + vec3 pointBrdf = pbrBaseLobe( + base.rgb, u_metallic, u_roughness, u_dielectricF0, + n, viewDir, pointDir, pointFresnel + ); + float pointCoatFresnel; + vec3 pointCoat = pbrClearcoatLobe( + u_clearcoat, u_clearcoatRoughness, n, viewDir, pointDir, pointCoatFresnel + ); + lit += (pointBrdf * (1.0 - pointCoatFresnel * u_clearcoat) + pointCoat) + * u_pointColors[index] * u_pointIntensities[index] + * nDotPoint * attenuation; + } + lit += emission; + float fogD = distance(v_worldPos, u_camEye); + float fogF = clamp((fogD - u_fogNear) / max(1.0, u_fogFar - u_fogNear), 0.0, 1.0); + vec3 surface = mix(lit, u_fogColor, fogF); + if (u_transparentPass == 1 && u_transmission > 0.0) { + float eta = 1.0 / max(u_ior, 1.0); + vec2 refractedUv = clamp(screenUv + n.xy * (1.0 - eta) * 0.005, vec2(0.001), vec2(0.999)); + vec3 transmitted = texture(u_sceneCopy, refractedUv).rgb; + surface = mix(surface, transmitted, clamp(u_transmission, 0.0, 1.0)); + } + frag = vec4(surface, base.a); +} diff --git a/client-rust/assets/shaders/mesh.vert b/client-rust/assets/shaders/mesh.vert new file mode 100644 index 00000000..48d10775 --- /dev/null +++ b/client-rust/assets/shaders/mesh.vert @@ -0,0 +1,23 @@ +// Mesh vertex shader. The GL backend prepends the target header +// (`#version 330 core` on desktop, `#version 300 es` + precision on web). +layout(location = 0) in vec3 a_pos; +layout(location = 1) in vec3 a_normal; +layout(location = 2) in vec2 a_uv; + +uniform mat4 u_model; +uniform mat4 u_viewProj; +uniform mat4 u_lightViewProj; + +out vec3 v_normal; +out vec4 v_lightPos; +out vec2 v_uv; +out vec3 v_worldPos; + +void main() { + vec4 world = u_model * vec4(a_pos, 1.0); + gl_Position = u_viewProj * world; + v_normal = mat3(u_model) * a_normal; + v_lightPos = u_lightViewProj * world; + v_uv = a_uv; + v_worldPos = world.xyz; +} diff --git a/client-rust/assets/shaders/mesh_skinned.vert b/client-rust/assets/shaders/mesh_skinned.vert new file mode 100644 index 00000000..755bd358 --- /dev/null +++ b/client-rust/assets/shaders/mesh_skinned.vert @@ -0,0 +1,32 @@ +// Skinned mesh vertex shader. Same outputs as mesh.vert (pairs with mesh.frag); +// applies a linear-blend-skinning palette before the model transform. The GL +// backend prepends the target header (#version 330 core / 300 es + precision). +layout(location = 0) in vec3 a_pos; +layout(location = 1) in vec3 a_normal; +layout(location = 2) in vec2 a_uv; +layout(location = 5) in vec4 a_joints; +layout(location = 6) in vec4 a_weights; + +uniform mat4 u_model; +uniform mat4 u_viewProj; +uniform mat4 u_lightViewProj; +uniform mat4 u_joints[50]; +out vec3 v_normal; +out vec4 v_lightPos; +out vec2 v_uv; +out vec3 v_worldPos; + +void main() { + mat4 skin = + a_weights.x * u_joints[int(a_joints.x)] + + a_weights.y * u_joints[int(a_joints.y)] + + a_weights.z * u_joints[int(a_joints.z)] + + a_weights.w * u_joints[int(a_joints.w)]; + vec4 skinned = skin * vec4(a_pos, 1.0); + vec4 world = u_model * skinned; + gl_Position = u_viewProj * world; + v_normal = mat3(u_model) * mat3(skin) * a_normal; + v_lightPos = u_lightViewProj * world; + v_uv = a_uv; + v_worldPos = world.xyz; +} diff --git a/client-rust/assets/shaders/particles.frag b/client-rust/assets/shaders/particles.frag new file mode 100644 index 00000000..c2465b49 --- /dev/null +++ b/client-rust/assets/shaders/particles.frag @@ -0,0 +1,10 @@ +// Glow-sprite sampled billboard, modulated by the per-vertex color (rgb + a). +in vec2 v_uv; +in vec4 v_col; +uniform sampler2D u_tex; +out vec4 frag; +void main() { + vec4 t = texture(u_tex, v_uv); + frag = vec4(v_col.rgb * t.rgb, v_col.a * t.a); + if (frag.a <= 0.003) discard; +} diff --git a/client-rust/assets/shaders/particles.vert b/client-rust/assets/shaders/particles.vert new file mode 100644 index 00000000..ed4b1704 --- /dev/null +++ b/client-rust/assets/shaders/particles.vert @@ -0,0 +1,12 @@ +// World-space particle billboards (PARTICLE_LAYOUT): pos3, uv2, color4. +layout(location = 0) in vec3 a_pos; +layout(location = 1) in vec2 a_uv; +layout(location = 2) in vec4 a_col; +uniform mat4 u_viewProj; +out vec2 v_uv; +out vec4 v_col; +void main() { + v_uv = a_uv; + v_col = a_col; + gl_Position = u_viewProj * vec4(a_pos, 1.0); +} diff --git a/client-rust/assets/shaders/pbr_common.glsl b/client-rust/assets/shaders/pbr_common.glsl new file mode 100644 index 00000000..415472fe --- /dev/null +++ b/client-rust/assets/shaders/pbr_common.glsl @@ -0,0 +1,67 @@ +// Shared glTF metallic-roughness and clearcoat BRDF helpers. +const float PBR_PI = 3.14159265359; + +float pbrDistributionGgx(float nDotH, float roughness) { + float alpha = roughness * roughness; + float alpha2 = alpha * alpha; + float denominator = nDotH * nDotH * (alpha2 - 1.0) + 1.0; + return alpha2 / max(PBR_PI * denominator * denominator, 1e-5); +} + +float pbrGeometrySchlick(float nDotV, float roughness) { + float k = roughness + 1.0; + k = k * k / 8.0; + return nDotV / max(nDotV * (1.0 - k) + k, 1e-5); +} + +vec3 pbrFresnelSchlick(float cosine, vec3 f0) { + return f0 + (1.0 - f0) * pow(clamp(1.0 - cosine, 0.0, 1.0), 5.0); +} + +vec3 pbrBaseLobe( + vec3 baseColor, + float metallic, + float perceptualRoughness, + float dielectricF0, + vec3 normal, + vec3 viewDir, + vec3 lightDir, + out vec3 fresnelValue +) { + vec3 halfDir = normalize(viewDir + lightDir); + float nDotL = max(dot(normal, lightDir), 0.0); + float nDotV = max(dot(normal, viewDir), 1e-4); + float nDotH = max(dot(normal, halfDir), 0.0); + float vDotH = max(dot(viewDir, halfDir), 0.0); + vec3 f0 = mix(vec3(dielectricF0), baseColor, metallic); + float distribution = pbrDistributionGgx(nDotH, perceptualRoughness); + float geometry = pbrGeometrySchlick(nDotV, perceptualRoughness) + * pbrGeometrySchlick(nDotL, perceptualRoughness); + fresnelValue = pbrFresnelSchlick(vDotH, f0); + vec3 specular = distribution * geometry * fresnelValue + / max(4.0 * nDotV * nDotL, 1e-4); + vec3 diffuse = (vec3(1.0) - fresnelValue) * (1.0 - metallic) + * baseColor / PBR_PI; + return diffuse + specular; +} + +vec3 pbrClearcoatLobe( + float clearcoat, + float clearcoatRoughness, + vec3 normal, + vec3 viewDir, + vec3 lightDir, + out float clearcoatFresnel +) { + vec3 halfDir = normalize(viewDir + lightDir); + float nDotL = max(dot(normal, lightDir), 0.0); + float nDotV = max(dot(normal, viewDir), 1e-4); + float nDotH = max(dot(normal, halfDir), 0.0); + float vDotH = max(dot(viewDir, halfDir), 0.0); + float distribution = pbrDistributionGgx(nDotH, clearcoatRoughness); + float geometry = pbrGeometrySchlick(nDotV, clearcoatRoughness) + * pbrGeometrySchlick(nDotL, clearcoatRoughness); + clearcoatFresnel = pbrFresnelSchlick(vDotH, vec3(0.04)).r; + return vec3(clearcoat * distribution * geometry * clearcoatFresnel + / max(4.0 * nDotV * nDotL, 1e-4)); +} diff --git a/client-rust/assets/shaders/point_light.frag b/client-rust/assets/shaders/point_light.frag new file mode 100644 index 00000000..7e0984ae --- /dev/null +++ b/client-rust/assets/shaders/point_light.frag @@ -0,0 +1,84 @@ +// Deferred point light: reconstruct world position from depth at this fragment, +// decode the G-buffer, and add a Cook-Torrance point-light term with smooth +// radius falloff. Additive into the HDR scene target; discard outside radius. +in vec4 v_posRadius; +in vec4 v_colorIntensity; + +uniform sampler2D u_gb0; +uniform sampler2D u_gb1; +uniform sampler2D u_gb3; +uniform sampler2D u_depth; +uniform mat4 u_invViewProj; +uniform vec3 u_camEye; +uniform vec2 u_screenSize; +uniform float u_exposure; + +out vec4 frag; + + +vec3 decodeOctahedral(vec2 encoded) { + vec2 f = encoded * 2.0 - 1.0; + vec3 normal = vec3(f, 1.0 - abs(f.x) - abs(f.y)); + float fold = clamp(-normal.z, 0.0, 1.0); + normal.xy += vec2(normal.x >= 0.0 ? -fold : fold, normal.y >= 0.0 ? -fold : fold); + return normalize(normal); +} + +void main() { + vec2 uv = gl_FragCoord.xy / u_screenSize; + float depth = texture(u_depth, uv).r; + if (depth >= 1.0) { discard; } + vec4 clip = vec4(uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0); + vec4 wp = u_invViewProj * clip; + vec3 P = wp.xyz / wp.w; + + vec3 lightPos = v_posRadius.xyz; + float radius = v_posRadius.w; + vec3 d = lightPos - P; + float dist = length(d); + if (dist > radius) { discard; } + + vec4 g0 = texture(u_gb0, uv); + vec4 g1 = texture(u_gb1, uv); + vec4 g3 = texture(u_gb3, uv); + vec3 albedo = g0.rgb; + float metallic = g0.a; + vec3 N = decodeOctahedral(g1.rg); + float roughness = clamp(g1.b, 0.045, 1.0); + float clearcoat = g3.r; + float clearcoatRoughness = clamp(g3.g, 0.045, 1.0); + float dielectricF0 = g3.b; + + vec3 L = d / max(dist, 1e-4); + vec3 V = normalize(u_camEye - P); + float NdotL = max(dot(N, L), 0.0); + vec3 fresnelValue; + vec3 baseLobe = pbrBaseLobe( + albedo, + metallic, + roughness, + dielectricF0, + N, + V, + L, + fresnelValue + ); + float clearcoatFresnel; + vec3 clearcoatLobe = pbrClearcoatLobe( + clearcoat, + clearcoatRoughness, + N, + V, + L, + clearcoatFresnel + ); + + float x = clamp(1.0 - pow(dist / radius, 4.0), 0.0, 1.0); + float atten = (x * x) / (dist * dist + 1.0); + vec3 radiance = v_colorIntensity.rgb * v_colorIntensity.w * atten; + vec3 color = ( + baseLobe * (1.0 - clearcoat * clearcoatFresnel) + + clearcoatLobe + ) * radiance * NdotL; + frag = vec4(color * u_exposure, 1.0); +} diff --git a/client-rust/assets/shaders/point_light.vert b/client-rust/assets/shaders/point_light.vert new file mode 100644 index 00000000..a231c8e6 --- /dev/null +++ b/client-rust/assets/shaders/point_light.vert @@ -0,0 +1,17 @@ +// Point-light bounding volume (unit sphere), instanced. Instance attrs: +// a_posRadius = world center + radius, a_colorIntensity = linear rgb + intensity. +layout(location = 0) in vec3 a_pos; +layout(location = 5) in vec4 a_posRadius; +layout(location = 6) in vec4 a_colorIntensity; + +uniform mat4 u_viewProj; + +out vec4 v_posRadius; +out vec4 v_colorIntensity; + +void main() { + vec3 world = a_posRadius.xyz + a_pos * a_posRadius.w; + gl_Position = u_viewProj * vec4(world, 1.0); + v_posRadius = a_posRadius; + v_colorIntensity = a_colorIntensity; +} diff --git a/client-rust/assets/shaders/post.frag b/client-rust/assets/shaders/post.frag new file mode 100644 index 00000000..9486ce69 --- /dev/null +++ b/client-rust/assets/shaders/post.frag @@ -0,0 +1,25 @@ +// PS2-era color grade over the rendered scene (port of the environment grade): +// desaturate → bone tint → scene darken (exposure) → black lift, with an +// ordered-dither to fake extra bit-depth. Bloom is approximated by lifting the +// brightest highlights slightly (cheap, no separable blur pass). +in vec2 v_uv; +uniform sampler2D u_scene; +uniform vec3 u_boneTint; +uniform float u_desaturate; +uniform float u_sceneDarken; +uniform float u_blackLift; +uniform float u_bloom; +out vec4 frag; + +void main() { + vec3 c = texture(u_scene, v_uv).rgb; + float l = dot(c, vec3(0.299, 0.587, 0.114)); + c = mix(c, vec3(l), clamp(u_desaturate, 0.0, 1.0)); + c *= u_boneTint; + c *= u_sceneDarken; + c = c + u_blackLift * (1.0 - c); + // Cheap highlight bloom: add a fraction of the supra-threshold luminance. + float hi = max(l - 0.72, 0.0); + c += hi * u_bloom * u_boneTint; + frag = vec4(clamp(c, 0.0, 1.0), 1.0); +} diff --git a/client-rust/assets/shaders/post.vert b/client-rust/assets/shaders/post.vert new file mode 100644 index 00000000..a088bbf8 --- /dev/null +++ b/client-rust/assets/shaders/post.vert @@ -0,0 +1,8 @@ +// Fullscreen post pass: a_pos is NDC, a_uv samples the scene color target. +layout(location = 0) in vec2 a_pos; +layout(location = 1) in vec2 a_uv; +out vec2 v_uv; +void main() { + v_uv = a_uv; + gl_Position = vec4(a_pos, 0.0, 1.0); +} diff --git a/client-rust/assets/shaders/terrain_depth.vert b/client-rust/assets/shaders/terrain_depth.vert new file mode 100644 index 00000000..d3c3a6d5 --- /dev/null +++ b/client-rust/assets/shaders/terrain_depth.vert @@ -0,0 +1,23 @@ +// Shadow-depth counterpart of terrain_gbuffer.vert. Both paths decode the same +// streamed alpha height so terrain receives and casts shadows at one surface. +layout(location = 0) in vec3 a_pos; + +uniform mat4 u_model; +uniform mat4 u_lightViewProj; +uniform sampler2D u_terrainControl; +uniform vec2 u_terrainOrigin; +uniform float u_terrainWorldSize; +out vec3 v_worldPos; + +vec2 terrainControlUv(vec2 worldXZ) { + vec2 chunkUv = clamp((worldXZ - u_terrainOrigin) / u_terrainWorldSize, vec2(0.0), vec2(1.0)); + return (chunkUv * 127.0 + 1.5) / 130.0; +} + +void main() { + vec4 world = u_model * vec4(a_pos, 1.0); + float encodedHeight = texture(u_terrainControl, terrainControlUv(world.xz)).a; + world.y += encodedHeight * 8.0 - 4.0; + v_worldPos = world.xyz; + gl_Position = u_lightViewProj * world; +} diff --git a/client-rust/assets/shaders/terrain_gbuffer.frag b/client-rust/assets/shaders/terrain_gbuffer.frag new file mode 100644 index 00000000..1c9751bb --- /dev/null +++ b/client-rust/assets/shaders/terrain_gbuffer.frag @@ -0,0 +1,193 @@ +// World-space procedural-tile terrain material. Macro controls stream per chunk; +// reusable PBR tile arrays provide scale-stable close detail. +in vec3 v_normal; +in vec2 v_uv; +in vec3 v_worldPos; +in vec4 v_color; + +uniform sampler2D u_terrainControl; +uniform sampler2DArray u_terrainAlbedo; +uniform sampler2DArray u_terrainNrma; +uniform vec2 u_terrainOrigin; +uniform float u_terrainWorldSize; +uniform float u_terrainTileScale; +uniform float u_terrainNormalStrength; +uniform int u_terrainBiome; +uniform vec3 u_camEye; + +layout(location = 0) out vec4 gb0; +layout(location = 1) out vec4 gb1; +layout(location = 2) out vec4 gb2; +layout(location = 3) out vec4 gb3; + +vec2 encodeOctahedral(vec3 normal) { + normal /= abs(normal.x) + abs(normal.y) + abs(normal.z); + vec2 encoded = normal.xy; + if (normal.z < 0.0) { + encoded = (1.0 - abs(encoded.yx)) * sign(encoded.xy); + } + return encoded * 0.5 + 0.5; +} + +vec2 hash2(vec2 p) { + vec2 h = vec2( + dot(p, vec2(127.1, 311.7)), + dot(p, vec2(269.5, 183.3)) + ); + return fract(sin(h) * 43758.5453); +} + +float worldNoise(vec2 p) { + vec2 cell = floor(p); + vec2 f = fract(p); + vec2 smoothF = f * f * (3.0 - 2.0 * f); + float a = hash2(cell).x; + float b = hash2(cell + vec2(1.0, 0.0)).x; + float c = hash2(cell + vec2(0.0, 1.0)).x; + float d = hash2(cell + vec2(1.0, 1.0)).x; + return mix(mix(a, b, smoothF.x), mix(c, d, smoothF.x), smoothF.y); +} + +vec2 variantUv(vec2 uv, float variant) { + if (variant < 0.5) return uv; + if (variant < 1.5) return vec2(-uv.y, uv.x); + if (variant < 2.5) return -uv; + return vec2(uv.y, -uv.x); +} + +vec2 variantNormal(vec2 normalXy, float variant) { + if (variant < 0.5) return normalXy; + if (variant < 1.5) return vec2(normalXy.y, -normalXy.x); + if (variant < 2.5) return -normalXy; + return vec2(-normalXy.y, normalXy.x); +} + +void sampleVariant(float surface, vec2 worldUv, vec2 cell, out vec3 albedo, out vec4 nrma) { + vec2 random = hash2(cell + vec2(surface * 19.19, surface * 7.73)); + float variant = min(floor(random.x * 8.0), 7.0); + float orientation = mod(variant, 4.0); + vec2 uv = variantUv(worldUv + random.yx * 23.0, orientation); + float layer = surface * 8.0 + variant; + albedo = texture(u_terrainAlbedo, vec3(uv, layer)).rgb; + nrma = texture(u_terrainNrma, vec3(uv, layer)); + vec2 normalXy = variantNormal(nrma.rg * 2.0 - 1.0, orientation); + nrma.rg = normalXy * 0.5 + 0.5; +} + +void sampleSurface(float surface, vec2 worldUv, out vec3 albedo, out vec4 nrma) { + // A large triangular lattice chooses three stable random tile transforms. + // Sharpened barycentric weights keep transitions continuous without the + // wide two-texture cross-fades that made the old surface look smeared. + vec2 lattice = worldUv * 0.22; + vec2 cell = floor(lattice); + vec2 f = fract(lattice); + vec2 c0; + vec2 c1; + vec2 c2; + vec3 weights; + if (f.x + f.y < 1.0) { + c0 = cell; + c1 = cell + vec2(1.0, 0.0); + c2 = cell + vec2(0.0, 1.0); + weights = vec3(1.0 - f.x - f.y, f.x, f.y); + } else { + c0 = cell + vec2(1.0, 1.0); + c1 = cell + vec2(0.0, 1.0); + c2 = cell + vec2(1.0, 0.0); + weights = vec3(f.x + f.y - 1.0, 1.0 - f.x, 1.0 - f.y); + } + weights *= weights; + weights *= weights; + weights *= weights; + weights /= dot(weights, vec3(1.0)); + + vec3 a0; + vec3 a1; + vec3 a2; + vec4 n0; + vec4 n1; + vec4 n2; + sampleVariant(surface, worldUv, c0, a0, n0); + sampleVariant(surface, worldUv, c1, a1, n1); + sampleVariant(surface, worldUv, c2, a2, n2); + albedo = a0 * weights.x + a1 * weights.y + a2 * weights.z; + nrma = n0 * weights.x + n1 * weights.y + n2 * weights.z; +} + +void main() { + // Reconstruct the displaced geometric normal instead of shading a flat + // plane. The flip keeps winding-independent derivative normals upright. + vec3 geometricNormal = normalize(cross(dFdx(v_worldPos), dFdy(v_worldPos))); + if (geometricNormal.y < 0.0) { + geometricNormal = -geometricNormal; + } + float slope = 1.0 - geometricNormal.y; + + // Low-frequency nonperiodic world fields vary surface coverage without + // inheriting chunk boundaries or introducing a visible repeat interval. + float macroA = worldNoise(v_worldPos.xz * 0.025 + vec2(13.7, -5.1)) * 2.0 - 1.0; + float macroB = worldNoise(v_worldPos.xz * 0.055 + vec2(-8.3, 21.4)) * 2.0 - 1.0; + float macroC = worldNoise(v_worldPos.xz * 0.11 + vec2(37.1, 4.6)) * 2.0 - 1.0; + vec3 weights = vec3( + 0.45 + macroA * 0.40, + 0.30 + macroB * 0.35, + 0.25 + macroC * 0.30 + ); + // Steeper faces expose compact soil and stone instead of stretched sand, + // loam, or moss. + weights.z += smoothstep(0.035, 0.38, slope) * 1.35; + weights = max(weights, vec3(0.03)); + weights /= dot(weights, vec3(1.0)); + vec2 worldUv = v_worldPos.xz / u_terrainTileScale; + + vec3 albedo0; + vec3 albedo1; + vec3 albedo2; + vec4 nrma0; + vec4 nrma1; + vec4 nrma2; + sampleSurface(0.0, worldUv, albedo0, nrma0); + sampleSurface(1.0, worldUv, albedo1, nrma1); + sampleSurface(2.0, worldUv, albedo2, nrma2); + vec3 albedo = albedo0 * weights.x + albedo1 * weights.y + albedo2 * weights.z; + vec4 nrma = nrma0 * weights.x + nrma1 * weights.y + nrma2 * weights.z; + + float distanceToEye = distance(v_worldPos, u_camEye); + float microA = worldNoise(v_worldPos.xz * 3.7 + vec2(17.3, -9.1)) * 2.0 - 1.0; + float microB = worldNoise(v_worldPos.xz * 10.9 + vec2(-4.7, 31.2)) * 2.0 - 1.0; + float microDetail = microA * 0.72 + microB * 0.28; + float microFade = 1.0 - smoothstep(35.0, 120.0, distanceToEye); + albedo *= 1.0 + microDetail * 0.16 * microFade; + + float macroTint = 1.0 + (macroA * 0.55 + macroB * 0.30 + macroC * 0.15) * 0.18; + albedo *= macroTint; + float detailFade = 1.0 - smoothstep(45.0, 150.0, distanceToEye); + vec2 normalXz = (nrma.rg * 2.0 - 1.0) * u_terrainNormalStrength * detailFade; + vec3 worldNormal = normalize(geometricNormal + vec3(normalXz.x, 0.0, normalXz.y)); + + // Independent world fields produce broad dry crust, damp soil, and rare + // reflective pools. Forest receives more damp coverage; desert pools stay + // sparse and collect only on level ground. + float moisture = worldNoise(v_worldPos.xz * 0.032 + vec2(-31.2, 18.9)) * 0.68 + + worldNoise(v_worldPos.xz * 0.097 + vec2(7.4, -42.1)) * 0.32; + float flatness = 1.0 - smoothstep(0.025, 0.20, slope); + float wetThreshold = u_terrainBiome == 1 ? 0.48 : 0.73; + float wet = smoothstep(wetThreshold, 0.91, moisture) * flatness; + float puddleBias = u_terrainBiome == 1 ? 0.04 : 0.0; + float puddle = smoothstep(0.86, 0.97, moisture + puddleBias) * flatness; + float dryness = worldNoise(v_worldPos.xz * 0.071 + vec2(53.7, 11.3)); + + albedo *= mix(0.88 + dryness * 0.18, 0.62, wet); + albedo *= mix(1.0, 0.78, puddle); + float roughness = clamp(nrma.b + (dryness - 0.5) * 0.18, 0.08, 1.0); + roughness = mix(roughness, 0.17, wet); + roughness = mix(roughness, 0.055, puddle); + float ao = clamp(nrma.a, 0.0, 1.0); + float clearcoat = clamp(wet * 0.22 + puddle * 0.78, 0.0, 1.0); + float clearcoatRoughness = mix(0.24, 0.045, puddle); + + gb0 = vec4(albedo, 0.0); + gb1 = vec4(encodeOctahedral(worldNormal), roughness, ao); + gb2 = vec4(0.0, 0.0, 0.0, 1.0 / 255.0); + gb3 = vec4(clearcoat, clearcoatRoughness, 0.04, 0.0); +} diff --git a/client-rust/assets/shaders/terrain_gbuffer.vert b/client-rust/assets/shaders/terrain_gbuffer.vert new file mode 100644 index 00000000..55272a23 --- /dev/null +++ b/client-rust/assets/shaders/terrain_gbuffer.vert @@ -0,0 +1,34 @@ +// Tessellated terrain G-buffer vertex path. Height is streamed in the alpha +// channel of the per-chunk control texture, encoded over [-4, 4] world units. +layout(location = 0) in vec3 a_pos; +layout(location = 1) in vec3 a_normal; +layout(location = 2) in vec2 a_uv; + +uniform mat4 u_model; +uniform mat4 u_viewProj; +uniform sampler2D u_terrainControl; +uniform vec2 u_terrainOrigin; +uniform float u_terrainWorldSize; + +out vec3 v_normal; +out vec2 v_uv; +out vec3 v_worldPos; +out vec4 v_color; +out vec4 v_tangent; + +vec2 terrainControlUv(vec2 worldXZ) { + vec2 chunkUv = clamp((worldXZ - u_terrainOrigin) / u_terrainWorldSize, vec2(0.0), vec2(1.0)); + return (chunkUv * 127.0 + 1.5) / 130.0; +} + +void main() { + vec4 world = u_model * vec4(a_pos, 1.0); + float encodedHeight = texture(u_terrainControl, terrainControlUv(world.xz)).a; + world.y += encodedHeight * 8.0 - 4.0; + gl_Position = u_viewProj * world; + v_normal = vec3(0.0, 1.0, 0.0); + v_uv = a_uv; + v_color = vec4(1.0); + v_tangent = vec4(1.0, 0.0, 0.0, 1.0); + v_worldPos = world.xyz; +} diff --git a/client-rust/assets/shaders/text.frag b/client-rust/assets/shaders/text.frag new file mode 100644 index 00000000..ea5e60c4 --- /dev/null +++ b/client-rust/assets/shaders/text.frag @@ -0,0 +1,6 @@ +// Text/overlay pass: solid fill color. +uniform vec4 u_color; +out vec4 frag; +void main() { + frag = u_color; +} diff --git a/client-rust/assets/shaders/text.vert b/client-rust/assets/shaders/text.vert new file mode 100644 index 00000000..8471217b --- /dev/null +++ b/client-rust/assets/shaders/text.vert @@ -0,0 +1,6 @@ +// Text/overlay pass: a_pos is already in NDC. +layout(location = 0) in vec2 a_pos; +layout(location = 1) in vec2 a_uv; +void main() { + gl_Position = vec4(a_pos, 0.0, 1.0); +} diff --git a/client-rust/assets/shaders/tonemap.frag b/client-rust/assets/shaders/tonemap.frag new file mode 100644 index 00000000..b061d8d4 --- /dev/null +++ b/client-rust/assets/shaders/tonemap.frag @@ -0,0 +1,82 @@ +// HDR resolve, environment grade, mastering grade, optional palette +// quantization, and opaque-depth restoration. +in vec2 v_uv; +uniform sampler2D u_scene; +uniform sampler2D u_depth; +uniform sampler2D u_bloomTex; +uniform vec3 u_boneTint; +uniform float u_desaturate; +uniform float u_sceneDarken; +uniform float u_blackLift; +uniform float u_bloomIntensity; +uniform float u_invExposure; +uniform float u_masterExposure; +uniform float u_saturation; +uniform float u_contrast; +uniform float u_gamma; +uniform float u_temperature; +uniform float u_tint; +uniform vec3 u_lift; +uniform vec3 u_colorGamma; +uniform vec3 u_gain; +uniform int u_paletteEnabled; +uniform float u_paletteLevels; +uniform float u_paletteStrength; +uniform float u_paletteDither; +out vec4 frag; + +vec3 aces(vec3 x) { + const float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14; + return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0); +} + +float bayer4(vec2 pixel) { + ivec2 p = ivec2(mod(pixel, 4.0)); + int index = p.x + p.y * 4; + const float values[16] = float[16]( + 0.0, 8.0, 2.0, 10.0, + 12.0, 4.0, 14.0, 6.0, + 3.0, 11.0, 1.0, 9.0, + 15.0, 7.0, 13.0, 5.0 + ); + return values[index] / 16.0 - 0.5; +} + +void main() { + vec3 hdr = texture(u_scene, v_uv).rgb * u_invExposure; + hdr += texture(u_bloomTex, v_uv).rgb * u_invExposure * u_bloomIntensity; + vec3 c = aces(hdr * u_masterExposure); + + // Authored environment grade. + float l = dot(c, vec3(0.299, 0.587, 0.114)); + c = mix(c, vec3(l), clamp(u_desaturate, 0.0, 1.0)); + c *= u_boneTint; + c *= u_sceneDarken; + c = c + u_blackLift * (1.0 - c); + + // Mastering controls: white balance, lift/gamma/gain, saturation, + // contrast, then display gamma. + vec3 balance = vec3( + 1.0 + u_temperature * 0.12 - u_tint * 0.03, + 1.0 + u_tint * 0.08, + 1.0 - u_temperature * 0.12 - u_tint * 0.03 + ); + c *= max(balance, vec3(0.01)); + c = max(c + u_lift, vec3(0.0)); + c = pow(c, vec3(1.0) / max(u_colorGamma, vec3(0.01))) * u_gain; + l = dot(c, vec3(0.299, 0.587, 0.114)); + c = mix(vec3(l), c, u_saturation); + c = (c - 0.5) * u_contrast + 0.5; + c = pow(max(c, vec3(0.0)), vec3(1.0 / max(u_gamma, 0.01))); + c = clamp(c, 0.0, 1.0); + + if (u_paletteEnabled != 0) { + float steps = max(u_paletteLevels - 1.0, 1.0); + float noise = bayer4(gl_FragCoord.xy) * u_paletteDither; + vec3 quantized = floor(c * steps + 0.5 + noise) / steps; + c = mix(c, clamp(quantized, 0.0, 1.0), u_paletteStrength); + } + + frag = vec4(c, 1.0); + gl_FragDepth = texture(u_depth, v_uv).r; +} diff --git a/client-rust/assets/shaders/ui.frag b/client-rust/assets/shaders/ui.frag new file mode 100644 index 00000000..04d6c8d5 --- /dev/null +++ b/client-rust/assets/shaders/ui.frag @@ -0,0 +1,16 @@ +// Solid quads (uv.x < 0) draw the vertex color directly. Coverage masks use +// atlas UVs in [0,1]. Full-color images encode U in [2,3] and are decoded here. +in vec2 v_uv; +in vec4 v_col; +uniform sampler2D u_atlas; +out vec4 frag; +void main() { + if (v_uv.x < 0.0) { + frag = v_col; + } else if (v_uv.x > 1.0) { + frag = texture(u_atlas, vec2(v_uv.x - 2.0, v_uv.y)) * v_col; + } else { + float cov = texture(u_atlas, v_uv).a; + frag = vec4(v_col.rgb, v_col.a * cov); + } +} diff --git a/client-rust/assets/shaders/ui.vert b/client-rust/assets/shaders/ui.vert new file mode 100644 index 00000000..c1f789d3 --- /dev/null +++ b/client-rust/assets/shaders/ui.vert @@ -0,0 +1,11 @@ +// Immediate-mode UI: NDC positions with per-vertex uv + color (UI_LAYOUT). +layout(location = 0) in vec2 a_pos; +layout(location = 1) in vec2 a_uv; +layout(location = 2) in vec4 a_col; +out vec2 v_uv; +out vec4 v_col; +void main() { + v_uv = a_uv; + v_col = a_col; + gl_Position = vec4(a_pos, 0.0, 1.0); +} diff --git a/client-rust/bench/baselines/darwin-arm64-apple-m2-max.json b/client-rust/bench/baselines/darwin-arm64-apple-m2-max.json new file mode 100644 index 00000000..f37c09e4 --- /dev/null +++ b/client-rust/bench/baselines/darwin-arm64-apple-m2-max.json @@ -0,0 +1,38 @@ +{ + "machine": "darwin-arm64-apple-m2-max", + "rustc": "rustc 1.92.0 (ded5c06cf 2025-12-08)", + "date": "2026-08-03", + "benches": { + "ecs/query1/4096": { + "median_ns": 29715.74 + }, + "ecs/query2/4096": { + "median_ns": 141440.09 + }, + "ecs/spawn-set/4096": { + "median_ns": 403392.76 + }, + "math/mat4-mul/1024": { + "median_ns": 61164.62 + }, + "render/build-drawlist/4096": { + "median_ns": 2098608.5 + } + }, + "sizes": { + "native_stripped": 2703464, + "wasm_stripped": 2761630 + }, + "runtime": { + "frame_p50_ms": 3.8589, + "frame_p99_ms": 4.1638, + "peak_rss_bytes": 9666560, + "frame_allocs_steady": 0 + }, + "render": { + "render_gpu_p99_ms": 5.846959 + }, + "terrain": { + "render_gpu_p99_ms": 4.944083 + } +} diff --git a/client-rust/bench/baselines/darwin-arm64-apple-m5-pro.json b/client-rust/bench/baselines/darwin-arm64-apple-m5-pro.json new file mode 100644 index 00000000..7b5f29d8 --- /dev/null +++ b/client-rust/bench/baselines/darwin-arm64-apple-m5-pro.json @@ -0,0 +1,38 @@ +{ + "machine": "darwin-arm64-apple-m5-pro", + "rustc": "rustc 1.97.1 (8bab26f4f 2026-07-14)", + "date": "2026-08-02", + "benches": { + "ecs/query1/4096": { + "median_ns": 17499.43 + }, + "ecs/query2/4096": { + "median_ns": 59968.63 + }, + "ecs/spawn-set/4096": { + "median_ns": 232529.0 + }, + "math/mat4-mul/1024": { + "median_ns": 34129.49 + }, + "render/build-drawlist/4096": { + "median_ns": 1140353.37 + } + }, + "sizes": { + "native_stripped": 2553128, + "wasm_stripped": 2464385 + }, + "runtime": { + "frame_p50_ms": 1.8934, + "frame_p99_ms": 2.5717, + "peak_rss_bytes": 11452416, + "frame_allocs_steady": 0 + }, + "render": { + "render_gpu_p99_ms": 6.013625 + }, + "terrain": { + "render_gpu_p99_ms": 6.548875 + } +} diff --git a/client-rust/bench/baselines/linux-x86_64-amd-ryzen-9-9900x-12-core-processor.json b/client-rust/bench/baselines/linux-x86_64-amd-ryzen-9-9900x-12-core-processor.json new file mode 100644 index 00000000..be6e1e0b --- /dev/null +++ b/client-rust/bench/baselines/linux-x86_64-amd-ryzen-9-9900x-12-core-processor.json @@ -0,0 +1,32 @@ +{ + "machine": "linux-x86_64-amd-ryzen-9-9900x-12-core-processor", + "rustc": "rustc 1.97.1 (8bab26f4f 2026-07-14)", + "date": "2026-07-30", + "benches": { + "ecs/query1/4096": { + "median_ns": 12426.48 + }, + "ecs/query2/4096": { + "median_ns": 36249.28 + }, + "ecs/spawn-set/4096": { + "median_ns": 228670.41 + }, + "math/mat4-mul/1024": { + "median_ns": 28444.64 + }, + "render/build-drawlist/4096": { + "median_ns": 1202994.12 + } + }, + "sizes": { + "native_stripped": 1539216, + "wasm_stripped": 495379 + }, + "runtime": { + "frame_p50_ms": 2.1292, + "frame_p99_ms": 2.3453, + "peak_rss_bytes": 7491584, + "frame_allocs_steady": 0 + } +} diff --git a/client-rust/bench/compare.py b/client-rust/bench/compare.py new file mode 100755 index 00000000..cdae40c4 --- /dev/null +++ b/client-rust/bench/compare.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +"""Perf, size, runtime, render, and terrain gates for the Successor Rust client. + +In addition to Criterion and process-level runtime measurements, the GPU gates +track the material-parity and terrain-material scenes. Every check enforces the +absolute ceilings in ../budgets.json before applying baseline-relative slack. +""" + + +import argparse +import datetime +import json +import platform +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BASELINE_DIR = ROOT / "bench" / "baselines" +CRITERION_DIR = ROOT / "target" / "criterion" +BUDGETS_PATH = ROOT / "budgets.json" + + +def load_budgets() -> dict: + return json.loads(BUDGETS_PATH.read_text()) if BUDGETS_PATH.is_file() else {} + + +def machine_id() -> str: + os_name = platform.system().lower() + arch = platform.machine().lower() + cpu = "" + if os_name == "darwin": + cpu = subprocess.run( + ["sysctl", "-n", "machdep.cpu.brand_string"], + capture_output=True, text=True, + ).stdout.strip() + elif os_name == "linux": + try: + for line in Path("/proc/cpuinfo").read_text().splitlines(): + if line.lower().startswith("model name"): + cpu = line.split(":", 1)[1].strip() + break + except OSError: + pass + slug = re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", cpu.lower())).strip("-") + return "-".join(p for p in (os_name, arch, slug) if p) + + +def rustc_version() -> str: + return subprocess.run( + ["rustc", "--version"], capture_output=True, text=True + ).stdout.strip() + + +def baseline_path() -> Path: + return BASELINE_DIR / f"{machine_id()}.json" + + +def collect_criterion() -> dict: + """{bench_id: median_ns} from target/criterion/**/new/estimates.json.""" + if not CRITERION_DIR.is_dir(): + return {} + out = {} + for est in CRITERION_DIR.rglob("new/estimates.json"): + rel = est.relative_to(CRITERION_DIR) + if "report" in rel.parts: + continue + bench_dir = est.parent + bid = None + bj = bench_dir / "benchmark.json" + if bj.is_file(): + bid = json.loads(bj.read_text()).get("full_id") + if not bid: + bid = "/".join(rel.parts[:-2]) + median = json.loads(est.read_text())["median"]["point_estimate"] + out[bid] = float(median) + return out + + +def read_sizes(native: str, wasm: str) -> dict: + sizes = {} + for key, p in (("native_stripped", native), ("wasm_stripped", wasm)): + if p is None: + continue + path = Path(p) + if not path.is_file(): + sys.exit(f"error: {path} not found — run the strip step first") + sizes[key] = path.stat().st_size + return sizes + + +def read_runtime(stats: str) -> dict: + path = Path(stats) + if not path.is_file(): + sys.exit(f"error: {path} not found — run `make runtime-check` producing it") + j = json.loads(path.read_text()) + return { + "frame_p50_ms": float(j["frame_p50_ms"]), + "frame_p99_ms": float(j["frame_p99_ms"]), + "peak_rss_bytes": int(j["peak_rss_bytes"]), + "frame_allocs_steady": int(j["frame_allocs_steady"]), + } + + +def read_render(stats: str) -> dict: + path = Path(stats) + if not path.is_file(): + sys.exit(f"error: {path} not found — run `make render-check` producing it") + j = json.loads(path.read_text()) + return {"render_gpu_p99_ms": float(j["render_gpu_p99_ms"])} + + +def read_terrain(stats: str) -> dict: + path = Path(stats) + if not path.is_file(): + sys.exit(f"error: {path} not found — run `make terrain-check` producing it") + j = json.loads(path.read_text()) + return {"render_gpu_p99_ms": float(j["render_gpu_p99_ms"])} + +def load_baseline() -> dict: + path = baseline_path() + if not path.is_file(): + print(f"FAIL: no baseline for this machine ({machine_id()}).") + print(f" expected: {path.relative_to(ROOT)}") + print(" create one with: make bench-baseline (then check it in)") + sys.exit(1) + return json.loads(path.read_text()) + + +def cmd_capture(args) -> None: + benches = collect_criterion() + path = baseline_path() + prev = {} + if path.is_file(): + prev = json.loads(path.read_text()).get("benches", {}) + + def entry(bid: str, median: float) -> dict: + e = {"median_ns": round(median, 2)} + if "max_regress_pct" in prev.get(bid, {}): + e["max_regress_pct"] = prev[bid]["max_regress_pct"] + return e + + data = { + "machine": machine_id(), + "rustc": rustc_version(), + "date": datetime.date.today().isoformat(), + "benches": {k: entry(k, v) for k, v in sorted(benches.items())}, + } + if args.native or args.wasm: + data["sizes"] = read_sizes(args.native, args.wasm) + if args.runtime: + data["runtime"] = read_runtime(args.runtime) + if args.render: + data["render"] = read_render(args.render) + if args.terrain: + data["terrain"] = read_terrain(args.terrain) + BASELINE_DIR.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n") + print(f"baseline written: {path.relative_to(ROOT)} ({len(benches)} benches) — review and commit it.") + + +def warn_rustc(base: dict) -> None: + cur = rustc_version() + if base.get("rustc") and base["rustc"] != cur: + print("WARN: rustc differs from baseline — deltas may be toolchain-caused") + print(f" baseline: {base['rustc']}\n current: {cur}") + + +def check_perf(base: dict, budgets: dict) -> bool: + current = collect_criterion() + if not current: + sys.exit("error: no criterion estimates — run `make bench` first") + default_limit = float(budgets.get("regression", {}).get("perf_max_regress_pct", 10.0)) + ok = True + for bid, entry in sorted(base.get("benches", {}).items()): + limit = float(entry.get("max_regress_pct", default_limit)) + old = entry["median_ns"] + if bid not in current: + print(f"FAIL {bid}: in baseline but not in this run (renamed/removed? re-baseline deliberately)") + ok = False + continue + new = current[bid] + delta = (new - old) / old * 100.0 + if delta > limit: + print(f"FAIL {bid}: {old:.0f}ns -> {new:.0f}ns ({delta:+.1f}% > +{limit:.0f}%)") + ok = False + elif delta < -limit: + print(f"note {bid}: {delta:+.1f}% faster — consider `make bench-baseline`") + else: + print(f"ok {bid}: {delta:+.1f}%") + for bid in sorted(set(current) - set(base.get("benches", {}))): + print(f"WARN {bid}: not in baseline — `make bench-baseline` to track it") + return ok + + +def check_size(base: dict, budgets: dict, native: str, wasm: str) -> bool: + current = read_sizes(native, wasm) + reg = budgets.get("regression", {}) + slack_bytes = int(reg.get("size_max_growth_bytes", 2048)) + slack_pct = float(reg.get("size_max_growth_pct", 1.0)) + ceilings = budgets.get("sizes", {}) + ceiling_key = {"native_stripped": "native_stripped_max_bytes", + "wasm_stripped": "wasm_stripped_max_bytes"} + ok = True + for key, new in current.items(): + # Absolute ceiling (hard budget). + cap = ceilings.get(ceiling_key[key]) + if cap is not None and new > cap: + print(f"FAIL size/{key}: {new}B > ceiling {cap}B (budgets.json)") + ok = False + # Baseline-relative regression. + old = base.get("sizes", {}).get(key) + if old is None: + print(f"WARN size/{key}: not in baseline — re-baseline to track it") + continue + slack = max(slack_bytes, old * slack_pct / 100.0) + delta = new - old + if delta > slack: + print(f"FAIL size/{key}: {old}B -> {new}B (+{delta}B > +{slack:.0f}B slack)") + print(" attribute with: cargo bloat --release -p successor-client") + ok = False + else: + print(f"ok size/{key}: {old}B -> {new}B ({delta:+d}B)") + return ok + + +def check_runtime(base: dict, budgets: dict, stats: str) -> bool: + current = read_runtime(stats) + reg = budgets.get("regression", {}) + rt = budgets.get("runtime", {}) + ok = True + + # Hard ceilings. + alloc_cap = rt.get("frame_allocs_steady_max", 0) + if current["frame_allocs_steady"] > alloc_cap: + print(f"FAIL runtime/frame-allocs: {current['frame_allocs_steady']} > ceiling {alloc_cap}") + ok = False + else: + print(f"ok runtime/frame-allocs: {current['frame_allocs_steady']}") + + rss_cap = rt.get("peak_rss_max_bytes") + if rss_cap is not None and current["peak_rss_bytes"] > rss_cap: + print(f"FAIL runtime/peak-rss: {current['peak_rss_bytes']}B > ceiling {rss_cap}B") + ok = False + else: + print(f"ok runtime/peak-rss: {current['peak_rss_bytes']}B") + + p99_cap = rt.get("frame_p99_max_ms", {}).get(machine_id()) + if p99_cap is not None and current["frame_p99_ms"] > p99_cap: + print(f"FAIL runtime/frame-p99: {current['frame_p99_ms']:.3f}ms > ceiling {p99_cap}ms") + ok = False + else: + print(f"ok runtime/frame-p99: {current['frame_p99_ms']:.3f}ms") + + # Baseline-relative regressions. + b = base.get("runtime") + if b: + rss_pct = float(reg.get("rss_max_regress_pct", 5.0)) + perf_pct = float(reg.get("perf_max_regress_pct", 10.0)) + for key, pct in (("peak_rss_bytes", rss_pct), ("frame_p99_ms", perf_pct)): + old = b.get(key) + if not old: + continue + delta = (current[key] - old) / old * 100.0 + if delta > pct: + print(f"FAIL runtime/{key}: {old} -> {current[key]} ({delta:+.1f}% > +{pct:.0f}%)") + ok = False + else: + print(f"ok runtime/{key}: {delta:+.1f}%") + return ok + + +def check_render(base: dict, budgets: dict, stats: str) -> bool: + current = read_render(stats) + value = current["render_gpu_p99_ms"] + cap = budgets.get("render", {}).get("gpu_p99_max_ms", {}).get(machine_id()) + ok = True + if cap is not None and value > cap: + print(f"FAIL render/gpu-p99: {value:.3f}ms > ceiling {cap}ms") + ok = False + else: + print(f"ok render/gpu-p99: {value:.3f}ms") + + old = base.get("render", {}).get("render_gpu_p99_ms") + if old: + limit = float(budgets.get("regression", {}).get("perf_max_regress_pct", 10.0)) + delta = (value - old) / old * 100.0 + if delta > limit: + print(f"FAIL render/gpu-p99: {old}ms -> {value}ms ({delta:+.1f}% > +{limit:.0f}%)") + ok = False + else: + print(f"ok render/gpu-p99 regression: {delta:+.1f}%") + else: + print("WARN render/gpu-p99: not in baseline — re-baseline to track it") + return ok + +def check_terrain(base: dict, budgets: dict, stats: str) -> bool: + current = read_terrain(stats) + value = current["render_gpu_p99_ms"] + cap = budgets.get("terrain", {}).get("gpu_p99_max_ms", {}).get(machine_id()) + ok = True + if cap is not None and value > cap: + print(f"FAIL terrain/gpu-p99: {value:.3f}ms > ceiling {cap}ms") + ok = False + else: + print(f"ok terrain/gpu-p99: {value:.3f}ms") + + old = base.get("terrain", {}).get("render_gpu_p99_ms") + if old: + limit = float(budgets.get("regression", {}).get("perf_max_regress_pct", 10.0)) + delta = (value - old) / old * 100.0 + if delta > limit: + print(f"FAIL terrain/gpu-p99: {old}ms -> {value}ms ({delta:+.1f}% > +{limit:.0f}%)") + ok = False + else: + print(f"ok terrain/gpu-p99 regression: {delta:+.1f}%") + else: + print("WARN terrain/gpu-p99: not in baseline — re-baseline to track it") + return ok + + +def main() -> None: + ap = argparse.ArgumentParser() + sub = ap.add_subparsers(dest="cmd", required=True) + cap = sub.add_parser("capture") + cap.add_argument("--native") + cap.add_argument("--wasm") + cap.add_argument("--runtime") + cap.add_argument("--render") + cap.add_argument("--terrain") + chk = sub.add_parser("check") + chk.add_argument("--perf", action="store_true") + chk.add_argument("--size", action="store_true") + chk.add_argument("--runtime") + chk.add_argument("--render") + chk.add_argument("--terrain") + chk.add_argument("--native") + chk.add_argument("--wasm") + sub.add_parser("machine-id") + args = ap.parse_args() + + if args.cmd == "machine-id": + print(machine_id()) + return + if args.cmd == "capture": + cmd_capture(args) + return + + if not (args.perf or args.size or args.runtime or args.render or args.terrain): + ap.error("check: pass --perf, --size, --runtime, --render, and/or --terrain") + if args.size and not (args.native and args.wasm): + ap.error("check --size: --native and --wasm paths required") + budgets = load_budgets() + base = load_baseline() + warn_rustc(base) + ok = True + if args.perf: + ok &= check_perf(base, budgets) + if args.size: + ok &= check_size(base, budgets, args.native, args.wasm) + if args.runtime: + ok &= check_runtime(base, budgets, args.runtime) + if args.render: + ok &= check_render(base, budgets, args.render) + if args.terrain: + ok &= check_terrain(base, budgets, args.terrain) + if not ok: + sys.exit(1) + print("PASS") + + +if __name__ == "__main__": + main() diff --git a/client-rust/budgets.json b/client-rust/budgets.json new file mode 100644 index 00000000..a13fe4ea --- /dev/null +++ b/client-rust/budgets.json @@ -0,0 +1,19 @@ +{ + "schema": "successor.client-rust.budgets.v1", + "sizes": { "native_stripped_max_bytes": 6291456, "wasm_stripped_max_bytes": 4194304 }, + "runtime": { + "frame_allocs_steady_max": 0, + "peak_rss_max_bytes": 536870912, + "frame_p99_max_ms": { "darwin-arm64-apple-m2-max": 8.33 } + }, + "render": { + "gpu_p99_max_ms": { "darwin-arm64-apple-m2-max": 16.67 } + }, + "terrain": { + "gpu_p99_max_ms": { "darwin-arm64-apple-m2-max": 8.33 } + }, + "regression": { + "size_max_growth_bytes": 524288, "size_max_growth_pct": 25, + "perf_max_regress_pct": 100, "rss_max_regress_pct": 5 + } +} diff --git a/client-rust/clippy.toml b/client-rust/clippy.toml new file mode 100644 index 00000000..3b11fd04 --- /dev/null +++ b/client-rust/clippy.toml @@ -0,0 +1,5 @@ +# Standalone clippy config for the Rust client workspace. +# +# This file exists so clippy does NOT walk up to the repository-root clippy.toml, +# whose deterministic-sim bans (HashMap, Instant::now, float arithmetic) do not +# apply to a rendering client. No sim bans are inherited here. diff --git a/client-rust/rust-toolchain.toml b/client-rust/rust-toolchain.toml new file mode 100644 index 00000000..5f85fe1a --- /dev/null +++ b/client-rust/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] +targets = ["wasm32-unknown-unknown", "thumbv7em-none-eabihf"] diff --git a/client-rust/source/app/Cargo.toml b/client-rust/source/app/Cargo.toml new file mode 100644 index 00000000..f49f496b --- /dev/null +++ b/client-rust/source/app/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "successor-client" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Successor native/web Rust client: composition root binding engine, renderer, platform, and protocol." + +[lib] +name = "successor_client" +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "successor" +path = "src/main.rs" + +[[bin]] +name = "successor-control" +path = "src/bin/successor-control.rs" +required-features = ["dev-tools"] + +[dependencies] +successor-engine-core.workspace = true +successor-engine-render.workspace = true +successor-platform.workspace = true +successor-movement.workspace = true +serde.workspace = true +serde_json.workspace = true +successor-client-proto.workspace = true +successor-net = { path = "../../../crates/successor-net" } +fontdue = { version = "0.9.3", default-features = false } +libm.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +# Native-only pure-Rust MP3 decode (web decodes via Web Audio); keeps the wasm +# module free of the decoder. +rmp3 = { version = "0.3", default-features = false, features = ["std", "float"] } + +# Wasm networking runtime: the sans-IO session/protocol + matchmake JSON run on +# web too (transport is the WebSocket/fetch shim in `platform::web`). +[target.'cfg(target_arch = "wasm32")'.dependencies] +successor-client-proto.workspace = true + +[dev-dependencies] +successor-engine-render = { workspace = true, features = ["std"] } + +[features] +default = [] +dev-tools = [] +alloc-count = [ + "successor-engine-core/alloc-count", + "successor-engine-render/alloc-count", + "successor-platform/alloc-count", +] diff --git a/client-rust/source/app/assets/ui/PT_Sans-OFL.txt b/client-rust/source/app/assets/ui/PT_Sans-OFL.txt new file mode 100644 index 00000000..73906428 --- /dev/null +++ b/client-rust/source/app/assets/ui/PT_Sans-OFL.txt @@ -0,0 +1,87 @@ +Copyright (c) 2010, ParaType Ltd. (http://www.paratype.com/public), +with Reserved Font Names "PT Sans" and "ParaType". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The fonts, +including any derivative works, can be bundled, embedded, redistributed +and/or sold with any software provided that any reserved names are not used +by derivative works. The fonts and derivatives, however, cannot be released +under any other type of license. The requirement for fonts to remain under +this license does not apply to any document created using the fonts or their +derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright Holder(s) +and clearly marked as such. This may include source files, build scripts and +documentation. + +"Reserved Font Name" refers to any names specified as such after the copyright +statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, or +substituting -- in part or in whole -- any of the components of the Original +Version, by changing formats or by porting the Font Software to a new +environment. + +"Author" refers to any designer, engineer, technical writer or other person +who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining a copy +of the Font Software, to use, study, copy, merge, embed, modify, redistribute, +and sell modified and unmodified copies of the Font Software, subject to the +following conditions: + +1) Neither the Font Software nor any of its individual components, in Original +or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy contains +the above copyright notice and this license. These can be included either as +stand-alone text files, human-readable headers or in the appropriate +machine-readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font Name(s) +unless explicit written permission is granted by the corresponding Copyright +Holder. This restriction only applies to the primary font name as presented +to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software +shall not be used to promote, endorse or advertise any Modified Version, +except to acknowledge the contribution(s) of the Copyright Holder(s) and the +Author(s) or with their explicit written permission. + +5) The Font Software, modified or unmodified, in part or in whole, must be +distributed entirely under this license, and must not be distributed under +any other license. The requirement for fonts to remain under this license does +not apply to any document created using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR +ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, +INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE +THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/client-rust/source/app/assets/ui/PT_Sans-Web-Bold.ttf b/client-rust/source/app/assets/ui/PT_Sans-Web-Bold.ttf new file mode 100644 index 00000000..2d4d9b6a --- /dev/null +++ b/client-rust/source/app/assets/ui/PT_Sans-Web-Bold.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3128bd5ecf01816e59a23d54c57a7a6b14615b07db53ff277c77376010265b05 +size 470240 diff --git a/client-rust/source/app/assets/ui/generated/focus-brackets.png b/client-rust/source/app/assets/ui/generated/focus-brackets.png new file mode 100644 index 00000000..eab9eaf7 --- /dev/null +++ b/client-rust/source/app/assets/ui/generated/focus-brackets.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83ce380b094ce6a7463b70cf12e32a852c9810956804f57ae775d4879bcff045 +size 1626 diff --git a/client-rust/source/app/assets/ui/generated/loading-destination-atlas.png b/client-rust/source/app/assets/ui/generated/loading-destination-atlas.png new file mode 100644 index 00000000..f9a973ea --- /dev/null +++ b/client-rust/source/app/assets/ui/generated/loading-destination-atlas.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:374ab029f6da1cf94c7a82a0553227cab3f8987429b2c7c1178405af8050d4b7 +size 366861 diff --git a/client-rust/source/app/assets/ui/generated/loading-destination.png b/client-rust/source/app/assets/ui/generated/loading-destination.png new file mode 100644 index 00000000..e07681cb --- /dev/null +++ b/client-rust/source/app/assets/ui/generated/loading-destination.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c4cd215190d2dc0f69ce0f627a8e2d0fb0a03290b5edb1ad86519a63849b547 +size 1250668 diff --git a/client-rust/source/app/assets/ui/generated/nav-wedge-left.png b/client-rust/source/app/assets/ui/generated/nav-wedge-left.png new file mode 100644 index 00000000..cb8493b2 --- /dev/null +++ b/client-rust/source/app/assets/ui/generated/nav-wedge-left.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c6a5c1c8cb5c2786e4a9ea317a8874356a35a34e23511c311f525f63b4a64f7 +size 1811 diff --git a/client-rust/source/app/assets/ui/generated/nav-wedge-right.png b/client-rust/source/app/assets/ui/generated/nav-wedge-right.png new file mode 100644 index 00000000..4b5b602c --- /dev/null +++ b/client-rust/source/app/assets/ui/generated/nav-wedge-right.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6309570f561a63b5db11f7eba86ac61c345d08461be882d8b2603aed379e88b3 +size 2004 diff --git a/client-rust/source/app/assets/ui/generated/progress-arc.png b/client-rust/source/app/assets/ui/generated/progress-arc.png new file mode 100644 index 00000000..7508b7a9 --- /dev/null +++ b/client-rust/source/app/assets/ui/generated/progress-arc.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:35d092dec9fcadf7b314ee0d3630ba958e4c99b32824ce8fac475ffdfce4a6cb +size 4093 diff --git a/client-rust/source/app/assets/ui/generated/radial-tick-crown.png b/client-rust/source/app/assets/ui/generated/radial-tick-crown.png new file mode 100644 index 00000000..1482a978 --- /dev/null +++ b/client-rust/source/app/assets/ui/generated/radial-tick-crown.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0cbe17dde06454f69ca87ba4e7cbef9469e2d4eb8690a90c6cfffc3f12a80866 +size 4730 diff --git a/client-rust/source/app/assets/ui/generated/row-chevron.png b/client-rust/source/app/assets/ui/generated/row-chevron.png new file mode 100644 index 00000000..2397c97d --- /dev/null +++ b/client-rust/source/app/assets/ui/generated/row-chevron.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d0dc2f39456af1d85f4cdaf6de38c69c9b56695531a6f51b43680aafc5167cc +size 2215 diff --git a/client-rust/source/app/assets/ui/generated/ui-shape-sheet.png b/client-rust/source/app/assets/ui/generated/ui-shape-sheet.png new file mode 100644 index 00000000..998a9337 --- /dev/null +++ b/client-rust/source/app/assets/ui/generated/ui-shape-sheet.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:85dbf39e5047de878eff8f948cfe4b5b2009e5df33356b0da43aca7267583322 +size 54034 diff --git a/client-rust/source/app/assets/ui/icons.a8 b/client-rust/source/app/assets/ui/icons.a8 new file mode 100644 index 00000000..44ff648b Binary files /dev/null and b/client-rust/source/app/assets/ui/icons.a8 differ diff --git a/client-rust/source/app/assets/ui/icons.json b/client-rust/source/app/assets/ui/icons.json new file mode 100644 index 00000000..315861b1 --- /dev/null +++ b/client-rust/source/app/assets/ui/icons.json @@ -0,0 +1 @@ +{"cell":32,"cols":8,"width":256,"height":160,"icons":[{"id":"actions","col":0,"row":0},{"id":"association","col":1,"row":0},{"id":"author","col":2,"row":0},{"id":"bank","col":3,"row":0},{"id":"bug-report","col":4,"row":0},{"id":"character","col":5,"row":0},{"id":"clone","col":6,"row":0},{"id":"clone-facility","col":7,"row":0},{"id":"close","col":0,"row":1},{"id":"converse","col":1,"row":1},{"id":"craft","col":2,"row":1},{"id":"crosshair","col":3,"row":1},{"id":"datapad","col":4,"row":1},{"id":"examine","col":5,"row":1},{"id":"fx","col":6,"row":1},{"id":"inventory","col":7,"row":1},{"id":"item-ammo","col":0,"row":2},{"id":"item-currency","col":1,"row":2},{"id":"item-gear","col":2,"row":2},{"id":"item-item","col":3,"row":2},{"id":"item-medical","col":4,"row":2},{"id":"item-resource","col":5,"row":2},{"id":"item-tool","col":6,"row":2},{"id":"item-weapon","col":7,"row":2},{"id":"kneel","col":0,"row":3},{"id":"lock","col":1,"row":3},{"id":"loot","col":2,"row":3},{"id":"macro","col":3,"row":3},{"id":"options","col":4,"row":3},{"id":"peace","col":5,"row":3},{"id":"reload","col":6,"row":3},{"id":"sample","col":7,"row":3},{"id":"skills","col":0,"row":4},{"id":"splice","col":1,"row":4},{"id":"stand","col":2,"row":4},{"id":"survey","col":3,"row":4},{"id":"trade","col":4,"row":4},{"id":"trainer","col":5,"row":4},{"id":"travel","col":6,"row":4}]} \ No newline at end of file diff --git a/client-rust/source/app/src/alloc_trace.rs b/client-rust/source/app/src/alloc_trace.rs new file mode 100644 index 00000000..23db6497 --- /dev/null +++ b/client-rust/source/app/src/alloc_trace.rs @@ -0,0 +1,281 @@ +//! Allocation tracing (native, `alloc-count` only). The app installs +//! [`TraceAllocator`] as the global allocator in place of the plain counting +//! wrapper; it forwards every allocation to the same `record_alloc` counter, +//! and — only when `SUCCESSOR_ALLOC_TRACE=1` is set in the environment — +//! captures a backtrace per allocation and aggregates counts by call stack. +//! With the variable unset the only added cost over the counting wrapper is +//! one relaxed branch per allocation, so the steady-state alloc gates stay +//! exact. Trace runs themselves are *not* count-accurate (capture allocates; +//! the re-entrancy guard keeps it finite), they exist to name call sites. + +#[cfg(all(feature = "alloc-count", not(target_arch = "wasm32")))] +mod imp { + use std::alloc::{GlobalAlloc, Layout, System}; + use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; + + use successor_engine_core::rt::alloc::record_alloc; + + /// Backtrace capture is impossible inside a global allocator (std's + /// backtrace init touches TLS that allocates → abort), so the trace hook + /// records a *size histogram* instead: exact byte sizes 0..=512 plus a + /// power-of-two bucket table above that, in fixed statics (no heap, no + /// TLS, no env read — nothing that can re-enter the allocator). Recording + /// is unconditional under `alloc-count` (two relaxed atomics, same order + /// as the existing counter); `SUCCESSOR_ALLOC_TRACE=1` gates only the + /// dump. Match the reported sizes to call sites by code inspection. + const EXACT_SIZES: usize = 513; + /// 512..=2^41 in power-of-two buckets. + const BUCKETS: usize = 33; + + static EXACT: [AtomicU64; EXACT_SIZES] = { + const ZERO: AtomicU64 = AtomicU64::new(0); + [ZERO; EXACT_SIZES] + }; + static BUCKET: [AtomicU64; BUCKETS] = { + const ZERO: AtomicU64 = AtomicU64::new(0); + [ZERO; BUCKETS] + }; + /// Cumulative histogram for frames the caller flagged as steady-state + /// (via [`absorb_frame`]); the dump reads this, not the live frame table. + static STEADY_EXACT: [AtomicU64; EXACT_SIZES] = { + const ZERO: AtomicU64 = AtomicU64::new(0); + [ZERO; EXACT_SIZES] + }; + static STEADY_BUCKET: [AtomicU64; BUCKETS] = { + const ZERO: AtomicU64 = AtomicU64::new(0); + [ZERO; BUCKETS] + }; + /// First/last absorbed frame numbers (0 = none) and absorbed frame count, + /// so the dump shows whether a source is continuous or a bounded phase. + static FIRST_FRAME: AtomicU64 = AtomicU64::new(0); + static LAST_FRAME: AtomicU64 = AtomicU64::new(0); + static FRAMES_WITH_ALLOCS: AtomicU64 = AtomicU64::new(0); + + /// Armed at the start of the steady window (main.rs calls [`arm_trace`]); + /// breakpoints on [`capture`] then hit only steady-state allocations. + /// `#[no_mangle]` so debugger breakpoint conditions can read it as + /// plain `SUCCESSOR_TRACE_ARMED`. + // Plain value (not atomic) so debugger breakpoint conditions read it + // directly; the frame loop is single-threaded, so a torn/missed read on a + // foreign allocating thread is benign for diagnostics. + #[no_mangle] + pub static mut SUCCESSOR_TRACE_ARMED: u64 = 0; + + /// Set while the connected `scene.frame()` body executes (alloc-count + /// diagnostic builds). Chain recording is restricted to this window so the + /// dump names exactly the allocations the connected gate counts. + #[no_mangle] + pub static mut SUCCESSOR_SCENE_ACTIVE: u64 = 0; + + /// Enable tracing (steady window start). + pub fn arm_trace() { + unsafe { SUCCESSOR_TRACE_ARMED = 1 }; + } + + /// Mark scene-frame entry/exit for chain capture. + pub fn scene_active(active: bool) { + unsafe { SUCCESSOR_SCENE_ACTIVE = u64::from(active) }; + } + + #[inline(never)] + fn capture(size: usize) { + if unsafe { SUCCESSOR_TRACE_ARMED } == 0 { + return; + } + if size < EXACT_SIZES { + EXACT[size].fetch_add(1, Ordering::Relaxed); + } else { + let log2 = (usize::BITS - size.leading_zeros()) as usize; // floor(log2(size))+1 + let index = log2.saturating_sub(9).min(BUCKETS - 1); + BUCKET[index].fetch_add(1, Ordering::Relaxed); + } + #[cfg(target_arch = "aarch64")] + if unsafe { SUCCESSOR_SCENE_ACTIVE } != 0 { + record_chain(size); + } + } + + // --- diagnostic: frame-pointer callchain capture (aarch64) --- + // Walks x29 without allocation or TLS; requires + // `-C force-frame-pointers=yes`. Offline-symbolicated from the exit dump. + + const CHAIN_DEPTH: usize = 8; + const CHAIN_SLOTS: usize = 48; + + struct ChainEntry { + pcs: [usize; CHAIN_DEPTH], + depth: u8, + count: u64, + sizes: [u64; 8], + } + + static mut CHAINS: [ChainEntry; CHAIN_SLOTS] = [const { + ChainEntry { + pcs: [0; CHAIN_DEPTH], + depth: 0, + count: 0, + sizes: [0; 8], + } + }; CHAIN_SLOTS]; + static CHAIN_LEN: AtomicUsize = AtomicUsize::new(0); + + #[cfg(target_arch = "aarch64")] + unsafe fn walk_frames(out: &mut [usize; CHAIN_DEPTH]) -> usize { + let mut fp: usize; + unsafe { core::arch::asm!("mov {}, x29", out(reg) fp) }; + let mut n = 0usize; + while n < CHAIN_DEPTH && fp != 0 { + let lr = unsafe { *((fp + 8) as *const usize) }; + if lr == 0 { + break; + } + out[n] = lr; + n += 1; + fp = unsafe { *(fp as *const usize) }; + } + n + } + + #[cfg(target_arch = "aarch64")] + fn record_chain(size: usize) { + let mut pcs = [0usize; CHAIN_DEPTH]; + let depth = unsafe { walk_frames(&mut pcs) }; + let key = pcs[0] ^ pcs[1].rotate_left(13) ^ pcs[2].rotate_left(27); + unsafe { + let len = CHAIN_LEN.load(Ordering::Relaxed); + for i in 0..len { + let entry = &mut CHAINS[i]; + let ekey = + entry.pcs[0] ^ entry.pcs[1].rotate_left(13) ^ entry.pcs[2].rotate_left(27); + if ekey == key && entry.pcs[..3] == pcs[..3] { + entry.count += 1; + let slot = ((usize::BITS - size.leading_zeros()) as usize).min(7); + entry.sizes[slot] += 1; + return; + } + } + if len < CHAIN_SLOTS { + let entry = &mut CHAINS[len]; + entry.pcs = pcs; + entry.depth = depth as u8; + entry.count = 1; + let slot = ((usize::BITS - size.leading_zeros()) as usize).min(7); + entry.sizes[slot] += 1; + CHAIN_LEN.store(len + 1, Ordering::Relaxed); + } + } + } + + /// Global-allocator wrapper: counting semantics identical to + /// `CountingAllocator`, plus env-gated backtrace capture. + pub struct TraceAllocator; + + // SAFETY: forwards verbatim to the system allocator; adds a relaxed atomic + // increment and an env-gated, re-entrancy-guarded backtrace capture. + unsafe impl GlobalAlloc for TraceAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + record_alloc(); + capture(layout.size()); + System.alloc(layout) + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + System.dealloc(ptr, layout); + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + record_alloc(); + capture(new_size); + System.realloc(ptr, layout, new_size) + } + } + + /// Zero the per-frame histogram (same reset point as `reset_alloc_count`). + pub fn reset_histogram() { + for cell in EXACT.iter() { + cell.store(0, Ordering::Relaxed); + } + for cell in BUCKET.iter() { + cell.store(0, Ordering::Relaxed); + } + } + + /// Fold the per-frame table into the steady cumulative table. Called when + /// a frame inside the counted steady window allocated, so the exit dump + /// names every steady-state source, intermittent ones included. + pub fn absorb_frame(frame: u64) { + FRAMES_WITH_ALLOCS.fetch_add(1, Ordering::Relaxed); + let mut first = FIRST_FRAME.load(Ordering::Relaxed); + while first == 0 || frame < first { + match FIRST_FRAME.compare_exchange(first, frame, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => break, + Err(current) => first = current, + } + } + LAST_FRAME.fetch_max(frame, Ordering::Relaxed); + for (index, cell) in EXACT.iter().enumerate() { + let count = cell.load(Ordering::Relaxed); + if count > 0 { + STEADY_EXACT[index].fetch_add(count, Ordering::Relaxed); + } + } + for (index, cell) in BUCKET.iter().enumerate() { + let count = cell.load(Ordering::Relaxed); + if count > 0 { + STEADY_BUCKET[index].fetch_add(count, Ordering::Relaxed); + } + } + } + + /// Print the recorded allocation-size histogram to stderr. No-op unless + /// `SUCCESSOR_ALLOC_TRACE=1` is set. + pub fn dump_alloc_trace(_top: usize) { + if std::env::var_os("SUCCESSOR_ALLOC_TRACE").is_none() { + return; + } + eprintln!( + "=== alloc trace: steady-window size histogram (frames with allocs: {}, first {}, last {}) ===", + FRAMES_WITH_ALLOCS.load(Ordering::Relaxed), + FIRST_FRAME.load(Ordering::Relaxed), + LAST_FRAME.load(Ordering::Relaxed) + ); + for (size, cell) in STEADY_EXACT.iter().enumerate() { + let count = cell.load(Ordering::Relaxed); + if count > 0 { + eprintln!("{count:>10} x {size}B"); + } + } + #[cfg(target_arch = "aarch64")] + unsafe { + eprintln!( + "=== alloc trace: callchains (anchor record_chain={:#x}) ===", + record_chain as *const () as usize + ); + let len = CHAIN_LEN.load(Ordering::Relaxed); + let mut idx: Vec = (0..len).collect(); + idx.sort_by_key(|i| core::cmp::Reverse(CHAINS[*i].count)); + for i in idx.iter().take(8) { + let e = &CHAINS[*i]; + eprintln!( + "count={} sizes={:?} pcs={:?}", + e.count, + e.sizes, + &e.pcs[..e.depth as usize] + ); + } + } + for (index, cell) in STEADY_BUCKET.iter().enumerate() { + let count = cell.load(Ordering::Relaxed); + if count > 0 { + eprintln!( + "{count:>10} x {}B..{}B", + 1usize << (index + 9), + 1usize << (index + 10) + ); + } + } + } +} + +#[cfg(all(feature = "alloc-count", not(target_arch = "wasm32")))] +pub use imp::{ + absorb_frame, arm_trace, dump_alloc_trace, reset_histogram, scene_active, TraceAllocator, +}; diff --git a/client-rust/source/app/src/assets/mod.rs b/client-rust/source/app/src/assets/mod.rs new file mode 100644 index 00000000..58f29ecb --- /dev/null +++ b/client-rust/source/app/src/assets/mod.rs @@ -0,0 +1,4 @@ +//! Asset streaming primitives shared by consumers that load after boot. + +pub mod model; +pub mod stream; diff --git a/client-rust/source/app/src/assets/model.rs b/client-rust/source/app/src/assets/model.rs new file mode 100644 index 00000000..a52bf660 --- /dev/null +++ b/client-rust/source/app/src/assets/model.rs @@ -0,0 +1,1183 @@ +//! Shared GLB upload and bounded embedded point-light extraction. +//! +//! Explicit `KHR_lights_punctual` point lights are authoritative. Checked-in +//! models that do not carry the extension can still produce local lighting from +//! semantically named emissive geometry. All inference happens once while the +//! asset is loaded; world instances reuse the resulting descriptors. + +use std::collections::BTreeMap; + +use successor_engine_core::glb::{self, GlbDocument, GlbLightKind, GlbPrimitive}; +use successor_engine_core::math::{vec3, Mat4, Vec3}; +use successor_engine_render::components::{MaterialId, MeshId}; +use successor_engine_render::gpu::Gpu; +use successor_engine_render::renderer::Renderer; + +pub const MAX_LIGHTS_PER_MODEL: usize = 64; + +const CELL_SIZE_METERS: f32 = 2.0; +const MERGE_DISTANCE_METERS: f32 = 0.25; +const MAX_NORMAL_OFFSET_METERS: f32 = 0.05; +const MIN_EMISSIVE_LIGHT_FLUX: f32 = 0.01; +const EMISSIVE_LIGHT_SCALE: f32 = 100.0; +const MIN_INFERRED_INTENSITY: f32 = 1.0; +const MAX_INFERRED_INTENSITY: f32 = 12.0; +const MIN_INFERRED_RADIUS_METERS: f32 = 1.5; +const MAX_INFERRED_RADIUS_METERS: f32 = 12.0; +const MAX_DERIVED_LIGHT_RADIUS_METERS: f32 = 32.0; +const DEFAULT_EXPLICIT_CUTOFF: f32 = 0.01; + +const EMITTER_TOKENS: &[&str] = &[ + "glow", + "lampglow", + "sconce", + "pendant", + "chandelier", + "beacon", + "bulb", + "flame", + "ember", + "campfire", + "hearth", +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EmbeddedLightSource { + Explicit, + Inferred, +} + +/// A point light anchored to one GLB node. +/// +/// `local` is relative to `node`. Static models compose it with the node's rest +/// global transform; animated models compose it with the live skeleton global. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct EmbeddedPointLight { + pub source: EmbeddedLightSource, + pub node: usize, + pub local: Mat4, + pub color: [f32; 3], + pub intensity: f32, + pub radius: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct StaticPart { + pub mesh: MeshId, + pub material: MaterialId, + pub local: Mat4, +} + +#[derive(Clone, Debug, Default)] +pub struct StaticModel { + pub parts: Vec, + pub lights: Vec, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct LightExtractionReport { + /// Referenced explicit point lights retained by the importer. + pub explicit: usize, + /// Geometry clusters that passed emissive and semantic gates. + pub candidates: usize, + /// Combined explicit and inferred descriptors retained. + pub retained: usize, + /// Geometry clusters discarded below the global flux floor. + pub suppressed_low_flux: usize, + /// Inferred clusters suppressed by an authoritative explicit point light. + pub suppressed_duplicate: usize, + /// Combined explicit/inferred descriptors beyond the per-model budget. + pub over_budget: usize, + /// Invalid or over-budget model-light conditions. Runtime model load fails. + pub errors: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct ClusterKey { + anchor: usize, + material: usize, + component: usize, + cell_x: i32, + cell_y: i32, + cell_z: i32, +} + +#[derive(Clone, Copy, Debug)] +struct EmissiveCluster { + key: ClusterKey, + centroid_sum: Vec3, + normal_sum: Vec3, + area: f32, + emission: [f32; 3], +} + +impl EmissiveCluster { + fn centroid(self) -> Vec3 { + self.centroid_sum.scale(self.area.recip()) + } +} + +#[derive(Clone, Copy, Debug)] +struct ComponentBounds { + anchor: usize, + material: usize, + min: Vec3, + max: Vec3, +} + +#[derive(Clone, Copy, Debug)] +struct EmitterTriangle { + component: usize, + points: [Vec3; 3], + centroid: Vec3, + normal: Vec3, + area: f32, + emission: [f32; 3], +} + +#[derive(Clone, Copy, Debug)] +struct SourceTriangle { + vertices: [u32; 3], + points: [[f32; 3]; 3], +} + +#[derive(Clone, Copy)] +struct InferredLight { + descriptor: EmbeddedPointLight, + model_position: Vec3, + flux: f32, +} + +pub fn light_extraction_report( + document: &GlbDocument, + emissive_texture_means: &[Option<[f32; 3]>], +) -> LightExtractionReport { + extract_lights(document, emissive_texture_means).1 +} + +pub fn extract_embedded_lights( + document: &GlbDocument, + emissive_texture_means: &[Option<[f32; 3]>], +) -> Result, LightExtractionReport> { + let (lights, report) = extract_lights(document, emissive_texture_means); + if report.errors == 0 { + Ok(lights) + } else { + Err(report) + } +} + +fn extract_lights( + document: &GlbDocument, + emissive_texture_means: &[Option<[f32; 3]>], +) -> (Vec, LightExtractionReport) { + let globals = document.node_globals(); + let mut report = LightExtractionReport::default(); + let mut lights = explicit_lights(document, &mut report); + let explicit_positions = lights + .iter() + .map(|light| { + globals + .get(light.node) + .copied() + .unwrap_or(Mat4::IDENTITY) + .mul(light.local) + .transform_point(Vec3::ZERO) + }) + .collect::>(); + + let clusters = emissive_clusters(document, &globals, emissive_texture_means, &mut report); + + let mut inferred = Vec::with_capacity(clusters.len()); + for cluster in clusters { + report.candidates += 1; + let luminance = linear_luminance(cluster.emission); + let flux = luminance * cluster.area; + if !flux.is_finite() || flux < MIN_EMISSIVE_LIGHT_FLUX { + report.suppressed_low_flux += 1; + continue; + } + + let mut model_position = cluster.centroid(); + let normal_length = cluster.normal_sum.length(); + if normal_length > 1.0e-6 { + model_position = model_position.add( + cluster + .normal_sum + .scale(MAX_NORMAL_OFFSET_METERS / normal_length), + ); + } + + let source_radius = cluster.area.sqrt().max(MERGE_DISTANCE_METERS); + if explicit_positions.iter().any(|position| { + distance_squared(*position, model_position) <= source_radius * source_radius + }) { + report.suppressed_duplicate += 1; + continue; + } + + let color = normalized_chroma(cluster.emission); + let intensity = + (flux * EMISSIVE_LIGHT_SCALE).clamp(MIN_INFERRED_INTENSITY, MAX_INFERRED_INTENSITY); + let radius = derived_explicit_radius(color, intensity) + .clamp(MIN_INFERRED_RADIUS_METERS, MAX_INFERRED_RADIUS_METERS); + let anchor_global = globals + .get(cluster.key.anchor) + .copied() + .unwrap_or(Mat4::IDENTITY); + let local_position = anchor_global.inverse().transform_point(model_position); + inferred.push(InferredLight { + descriptor: EmbeddedPointLight { + source: EmbeddedLightSource::Inferred, + node: cluster.key.anchor, + local: Mat4::from_translation(local_position), + color, + intensity, + radius, + }, + model_position, + flux, + }); + } + + inferred.sort_by(|left, right| { + right + .flux + .total_cmp(&left.flux) + .then_with(|| left.descriptor.node.cmp(&right.descriptor.node)) + .then_with(|| left.model_position.x.total_cmp(&right.model_position.x)) + .then_with(|| left.model_position.y.total_cmp(&right.model_position.y)) + .then_with(|| left.model_position.z.total_cmp(&right.model_position.z)) + }); + + let available = MAX_LIGHTS_PER_MODEL.saturating_sub(lights.len()); + if inferred.len() > available { + let overflow = inferred.len() - available; + report.over_budget += overflow; + report.errors += overflow; + inferred.truncate(available); + } + lights.extend(inferred.into_iter().map(|light| light.descriptor)); + report.retained = lights.len(); + (lights, report) +} + +fn explicit_lights( + document: &GlbDocument, + report: &mut LightExtractionReport, +) -> Vec { + let mut lights = Vec::new(); + for (node_index, node) in document.nodes.iter().enumerate() { + let Some(light_index) = node.light else { + continue; + }; + let Some(light) = document.lights.get(light_index) else { + report.errors += 1; + continue; + }; + if light.kind != GlbLightKind::Point { + continue; + } + if lights.len() >= MAX_LIGHTS_PER_MODEL { + report.over_budget += 1; + report.errors += 1; + continue; + } + let radius = light + .range + .unwrap_or_else(|| derived_explicit_radius(light.color, light.intensity)); + report.explicit += 1; + lights.push(EmbeddedPointLight { + source: EmbeddedLightSource::Explicit, + node: node_index, + local: Mat4::IDENTITY, + color: light.color, + intensity: light.intensity, + radius, + }); + } + lights +} + +fn derived_explicit_radius(color: [f32; 3], intensity: f32) -> f32 { + let contribution = intensity.max(0.0) * color[0].max(color[1]).max(color[2]); + (contribution / DEFAULT_EXPLICIT_CUTOFF - 1.0) + .max(0.0) + .sqrt() + .clamp(0.5, MAX_DERIVED_LIGHT_RADIUS_METERS) +} + +fn emissive_clusters( + document: &GlbDocument, + globals: &[Mat4], + emissive_texture_means: &[Option<[f32; 3]>], + report: &mut LightExtractionReport, +) -> Vec { + let mut triangles = Vec::new(); + let mut components = Vec::new(); + for (node_index, node) in document.nodes.iter().enumerate() { + let Some(mesh_index) = node.mesh else { + continue; + }; + let Some(mesh) = document.meshes.get(mesh_index) else { + report.errors += 1; + continue; + }; + let node_name = node.name.as_deref().unwrap_or(""); + let mesh_name = mesh.name.as_deref().unwrap_or(""); + let node_global = globals.get(node_index).copied().unwrap_or(Mat4::IDENTITY); + + for primitive in &mesh.primitives { + let Some(material_index) = primitive.material else { + continue; + }; + let Some(material) = document.materials.get(material_index) else { + report.errors += 1; + continue; + }; + let material_name = material.name.as_deref().unwrap_or(""); + if !has_emitter_semantics(node_name, mesh_name, material_name) { + continue; + } + + let texture_mean = emissive_texture_means + .get(material_index) + .copied() + .flatten() + .unwrap_or([1.0; 3]); + let emission = [ + material.emissive_factor[0] * material.emissive_strength * texture_mean[0], + material.emissive_factor[1] * material.emissive_strength * texture_mean[1], + material.emissive_factor[2] * material.emissive_strength * texture_mean[2], + ]; + if emission + .iter() + .all(|value| value.is_finite() && *value <= 0.0) + { + continue; + } + if emission + .iter() + .any(|value| !value.is_finite() || *value < 0.0) + { + report.errors += 1; + continue; + } + + let anchor = + dominant_joint_anchor(document, node_index, primitive).unwrap_or(node_index); + append_primitive_emitters( + &mut triangles, + &mut components, + primitive, + node_global, + anchor, + material_index, + emission, + ); + } + } + aggregate_emitter_components(triangles, components, report) +} + +#[allow(clippy::too_many_arguments)] +fn append_primitive_emitters( + emitters: &mut Vec, + components: &mut Vec, + primitive: &GlbPrimitive, + node_global: Mat4, + anchor: usize, + material: usize, + emission: [f32; 3], +) { + let positions = primitive.morphed_positions(); + let indexed = !primitive.indices.is_empty(); + let mut source = Vec::new(); + if indexed { + source.reserve(primitive.indices.len() / 3); + for triangle in primitive.indices.chunks_exact(3) { + let Some(&a) = positions.get(triangle[0] as usize) else { + continue; + }; + let Some(&b) = positions.get(triangle[1] as usize) else { + continue; + }; + let Some(&c) = positions.get(triangle[2] as usize) else { + continue; + }; + source.push(SourceTriangle { + vertices: [triangle[0], triangle[1], triangle[2]], + points: [a, b, c], + }); + } + } else { + source.reserve(positions.len() / 3); + for (index, triangle) in positions.chunks_exact(3).enumerate() { + let base = (index * 3) as u32; + source.push(SourceTriangle { + vertices: [base, base + 1, base + 2], + points: [triangle[0], triangle[1], triangle[2]], + }); + } + } + if source.is_empty() { + return; + } + + let mut parents = (0..source.len()).collect::>(); + if indexed { + let mut owner = vec![usize::MAX; positions.len()]; + for (triangle_index, triangle) in source.iter().enumerate() { + for vertex in triangle.vertices { + let slot = &mut owner[vertex as usize]; + if *slot != usize::MAX { + union_components(&mut parents, *slot, triangle_index); + } + *slot = triangle_index; + } + } + } + + let mut component_ids = vec![usize::MAX; source.len()]; + for (triangle_index, triangle) in source.into_iter().enumerate() { + let points = triangle + .points + .map(|point| node_global.transform_point(vec3(point[0], point[1], point[2]))); + let normal = points[1].sub(points[0]).cross(points[2].sub(points[0])); + let area = normal.length() * 0.5; + if !area.is_finite() || area <= 1.0e-8 { + continue; + } + let root = find_component(&mut parents, triangle_index); + let component = if component_ids[root] != usize::MAX { + component_ids[root] + } else { + let component = components.len(); + components.push(ComponentBounds { + anchor, + material, + min: points[0], + max: points[0], + }); + component_ids[root] = component; + component + }; + let bounds = &mut components[component]; + for point in points { + bounds.min = vec3( + bounds.min.x.min(point.x), + bounds.min.y.min(point.y), + bounds.min.z.min(point.z), + ); + bounds.max = vec3( + bounds.max.x.max(point.x), + bounds.max.y.max(point.y), + bounds.max.z.max(point.z), + ); + } + emitters.push(EmitterTriangle { + component, + points, + centroid: points[0].add(points[1]).add(points[2]).scale(1.0 / 3.0), + normal, + area, + emission, + }); + } +} + +fn aggregate_emitter_components( + triangles: Vec, + components: Vec, + report: &mut LightExtractionReport, +) -> Vec { + if components.is_empty() { + return Vec::new(); + } + let mut parents = (0..components.len()).collect::>(); + let mut ordered = (0..components.len()).collect::>(); + ordered.sort_unstable_by(|left, right| { + components[*left] + .anchor + .cmp(&components[*right].anchor) + .then_with(|| components[*left].material.cmp(&components[*right].material)) + .then_with(|| components[*left].min.x.total_cmp(&components[*right].min.x)) + .then_with(|| left.cmp(right)) + }); + let mut active = Vec::::new(); + let mut group = None; + for index in ordered { + let key = (components[index].anchor, components[index].material); + if group != Some(key) { + active.clear(); + group = Some(key); + } + active.retain(|other| { + components[*other].max.x + MERGE_DISTANCE_METERS >= components[index].min.x + }); + for &other in &active { + if bounds_distance_squared(components[index], components[other]) + <= MERGE_DISTANCE_METERS * MERGE_DISTANCE_METERS + { + union_components(&mut parents, index, other); + } + } + active.push(index); + } + + let mut merged_bounds = vec![None; components.len()]; + for (index, bounds) in components.into_iter().enumerate() { + let root = find_component(&mut parents, index); + if let Some(merged) = merged_bounds[root].as_mut() { + merge_bounds(merged, bounds); + } else { + merged_bounds[root] = Some(bounds); + } + } + + let mut clusters = BTreeMap::::new(); + for triangle in triangles { + let root = find_component(&mut parents, triangle.component); + let bounds = merged_bounds[root].expect("emitter component bounds"); + let span = bounds.max.sub(bounds.min); + let split = + span.x > CELL_SIZE_METERS || span.y > CELL_SIZE_METERS || span.z > CELL_SIZE_METERS; + if !split { + accumulate_cluster( + &mut clusters, + ClusterKey { + anchor: bounds.anchor, + material: bounds.material, + component: root, + cell_x: 0, + cell_y: 0, + cell_z: 0, + }, + triangle.centroid, + triangle.normal, + triangle.area, + triangle.emission, + ); + continue; + } + + let min_x = triangle + .points + .iter() + .map(|point| cell_coord(point.x)) + .min() + .unwrap_or(0); + let max_x = triangle + .points + .iter() + .map(|point| cell_coord(point.x)) + .max() + .unwrap_or(0); + let min_y = triangle + .points + .iter() + .map(|point| cell_coord(point.y)) + .min() + .unwrap_or(0); + let max_y = triangle + .points + .iter() + .map(|point| cell_coord(point.y)) + .max() + .unwrap_or(0); + let min_z = triangle + .points + .iter() + .map(|point| cell_coord(point.z)) + .min() + .unwrap_or(0); + let max_z = triangle + .points + .iter() + .map(|point| cell_coord(point.z)) + .max() + .unwrap_or(0); + let clip_tests = i64::from(max_x - min_x + 1) + * i64::from(max_y - min_y + 1) + * i64::from(max_z - min_z + 1); + if clip_tests > 4096 { + report.over_budget += 1; + report.errors += 1; + continue; + } + for cell_x in min_x..=max_x { + for cell_y in min_y..=max_y { + for cell_z in min_z..=max_z { + let Some((centroid, normal, area)) = + clip_triangle_to_cell(triangle.points, cell_x, cell_y, cell_z) + else { + continue; + }; + accumulate_cluster( + &mut clusters, + ClusterKey { + anchor: bounds.anchor, + material: bounds.material, + component: root, + cell_x, + cell_y, + cell_z, + }, + centroid, + normal, + area, + triangle.emission, + ); + } + } + } + } + clusters.into_values().collect() +} + +fn accumulate_cluster( + clusters: &mut BTreeMap, + key: ClusterKey, + centroid: Vec3, + normal: Vec3, + area: f32, + emission: [f32; 3], +) { + let cluster = clusters.entry(key).or_insert(EmissiveCluster { + key, + centroid_sum: Vec3::ZERO, + normal_sum: Vec3::ZERO, + area: 0.0, + emission, + }); + cluster.centroid_sum = cluster.centroid_sum.add(centroid.scale(area)); + cluster.normal_sum = cluster.normal_sum.add(normal); + cluster.area += area; +} + +fn clip_triangle_to_cell( + points: [Vec3; 3], + cell_x: i32, + cell_y: i32, + cell_z: i32, +) -> Option<(Vec3, Vec3, f32)> { + let mut polygon = points.to_vec(); + for (axis, cell) in [(0, cell_x), (1, cell_y), (2, cell_z)] { + let lower = cell as f32 * CELL_SIZE_METERS; + polygon = clip_polygon_axis(&polygon, axis, lower, true); + if polygon.len() < 3 { + return None; + } + polygon = clip_polygon_axis(&polygon, axis, lower + CELL_SIZE_METERS, false); + if polygon.len() < 3 { + return None; + } + } + + let origin = polygon[0]; + let mut centroid_sum = Vec3::ZERO; + let mut normal_sum = Vec3::ZERO; + let mut area = 0.0; + for index in 1..polygon.len() - 1 { + let left = polygon[index]; + let right = polygon[index + 1]; + let normal = left.sub(origin).cross(right.sub(origin)); + let triangle_area = normal.length() * 0.5; + if triangle_area <= 1.0e-8 { + continue; + } + let centroid = origin.add(left).add(right).scale(1.0 / 3.0); + centroid_sum = centroid_sum.add(centroid.scale(triangle_area)); + normal_sum = normal_sum.add(normal); + area += triangle_area; + } + if !area.is_finite() || area <= 1.0e-8 { + None + } else { + Some((centroid_sum.scale(area.recip()), normal_sum, area)) + } +} + +fn clip_polygon_axis(input: &[Vec3], axis: usize, bound: f32, keep_greater: bool) -> Vec { + let mut output = Vec::with_capacity(input.len() + 1); + let Some(mut previous) = input.last().copied() else { + return output; + }; + let mut previous_value = vector_axis(previous, axis); + let mut previous_inside = if keep_greater { + previous_value >= bound + } else { + previous_value <= bound + }; + for ¤t in input { + let current_value = vector_axis(current, axis); + let current_inside = if keep_greater { + current_value >= bound + } else { + current_value <= bound + }; + if current_inside != previous_inside { + let amount = (bound - previous_value) / (current_value - previous_value); + output.push(previous.add(current.sub(previous).scale(amount))); + } + if current_inside { + output.push(current); + } + previous = current; + previous_value = current_value; + previous_inside = current_inside; + } + output +} + +fn vector_axis(value: Vec3, axis: usize) -> f32 { + match axis { + 0 => value.x, + 1 => value.y, + _ => value.z, + } +} + +fn find_component(parents: &mut [usize], mut index: usize) -> usize { + while parents[index] != index { + parents[index] = parents[parents[index]]; + index = parents[index]; + } + index +} + +fn union_components(parents: &mut [usize], left: usize, right: usize) { + let left = find_component(parents, left); + let right = find_component(parents, right); + if left == right { + return; + } + let (root, child) = if left < right { + (left, right) + } else { + (right, left) + }; + parents[child] = root; +} + +fn bounds_distance_squared(left: ComponentBounds, right: ComponentBounds) -> f32 { + let x = (left.min.x - right.max.x) + .max(right.min.x - left.max.x) + .max(0.0); + let y = (left.min.y - right.max.y) + .max(right.min.y - left.max.y) + .max(0.0); + let z = (left.min.z - right.max.z) + .max(right.min.z - left.max.z) + .max(0.0); + x * x + y * y + z * z +} + +fn merge_bounds(target: &mut ComponentBounds, source: ComponentBounds) { + target.min = vec3( + target.min.x.min(source.min.x), + target.min.y.min(source.min.y), + target.min.z.min(source.min.z), + ); + target.max = vec3( + target.max.x.max(source.max.x), + target.max.y.max(source.max.y), + target.max.z.max(source.max.z), + ); +} + +fn dominant_joint_anchor( + document: &GlbDocument, + mesh_node: usize, + primitive: &GlbPrimitive, +) -> Option { + let skin_index = document.nodes.get(mesh_node)?.skin?; + let skin = document.skins.get(skin_index)?; + if primitive.joints.len() != primitive.weights.len() || skin.joints.is_empty() { + return None; + } + let mut totals = vec![0.0f32; skin.joints.len()]; + for (joints, weights) in primitive.joints.iter().zip(&primitive.weights) { + for (&joint, &weight) in joints.iter().zip(weights) { + if let Some(total) = totals.get_mut(joint as usize) { + *total += weight.max(0.0); + } + } + } + let dominant = totals + .iter() + .enumerate() + .max_by(|left, right| left.1.total_cmp(right.1))? + .0; + skin.joints.get(dominant).copied() +} + +fn has_emitter_semantics(node: &str, mesh: &str, material: &str) -> bool { + [node, mesh, material].iter().any(|value| { + EMITTER_TOKENS + .iter() + .any(|token| contains_ascii_case_insensitive(value, token)) + }) +} + +fn contains_ascii_case_insensitive(value: &str, token: &str) -> bool { + value + .as_bytes() + .windows(token.len()) + .any(|window| window.eq_ignore_ascii_case(token.as_bytes())) +} + +fn cell_coord(value: f32) -> i32 { + (value / CELL_SIZE_METERS).floor() as i32 +} + +fn normalized_chroma(emission: [f32; 3]) -> [f32; 3] { + let max = emission[0].max(emission[1]).max(emission[2]); + if max <= 1.0e-6 { + [0.0; 3] + } else { + [emission[0] / max, emission[1] / max, emission[2] / max] + } +} + +fn linear_luminance(color: [f32; 3]) -> f32 { + color[0] * 0.2126 + color[1] * 0.7152 + color[2] * 0.0722 +} + +fn distance_squared(left: Vec3, right: Vec3) -> f32 { + let delta = left.sub(right); + delta.dot(delta) +} + +pub fn upload_static_model( + gpu: &mut G, + renderer: &mut Renderer, + bytes: &[u8], +) -> Result { + let document = glb::parse(bytes)?; + upload_static_document(gpu, renderer, &document) +} + +fn upload_static_document( + gpu: &mut G, + renderer: &mut Renderer, + document: &GlbDocument, +) -> Result { + let prepared = successor_engine_render::model::prepare_glb(document) + .map_err(|_| glb::GlbError::Unsupported("model upload"))?; + let (mut lights, report) = extract_lights(document, &prepared.material_emissive_texture_means); + if report.errors != 0 { + return Err(glb::GlbError::Unsupported("model point lights")); + } + let uploaded = + successor_engine_render::model::upload_prepared_glb(renderer, gpu, document, prepared) + .map_err(|_| glb::GlbError::Unsupported("model upload"))?; + let globals = document.node_globals(); + let mut parts = Vec::new(); + for (node_index, node) in document.nodes.iter().enumerate() { + let Some(mesh_index) = node.mesh else { + continue; + }; + for primitive in uploaded + .primitives + .iter() + .filter(|primitive| primitive.source_mesh == mesh_index) + { + parts.push(StaticPart { + mesh: primitive.mesh, + material: primitive.material, + local: globals.get(node_index).copied().unwrap_or(Mat4::IDENTITY), + }); + } + } + + for light in &mut lights { + light.local = globals + .get(light.node) + .copied() + .unwrap_or(Mat4::IDENTITY) + .mul(light.local); + } + Ok(StaticModel { parts, lights }) +} + +#[cfg(test)] +mod tests { + use super::{ + extract_embedded_lights, light_extraction_report, upload_static_document, + EmbeddedLightSource, MAX_LIGHTS_PER_MODEL, + }; + use std::path::{Path, PathBuf}; + use successor_engine_core::glb; + + fn corpus_path(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../..") + .join("client-3d/public/assets/world-items") + .join(name) + } + + fn material_emissive_means(document: &glb::GlbDocument) -> Vec> { + let image_means = document + .images + .iter() + .map(|image| { + successor_engine_core::image::decode_image(&image.mime_type, &image.bytes) + .ok() + .map(|decoded| { + successor_engine_render::model::emissive_texture_mean_linear( + &decoded.pixels, + ) + }) + }) + .collect::>(); + document + .materials + .iter() + .map(|material| { + material + .emissive_texture + .and_then(|reference| document.textures.get(reference.texture)) + .and_then(|texture| image_means.get(texture.source)) + .copied() + .flatten() + }) + .collect() + } + + fn load( + name: &str, + ) -> ( + glb::GlbDocument, + Vec>, + super::LightExtractionReport, + ) { + let bytes = std::fs::read(corpus_path(name)).expect("read checked-in GLB"); + let document = glb::parse(&bytes).expect("parse checked-in GLB"); + let means = material_emissive_means(&document); + let report = light_extraction_report(&document, &means); + (document, means, report) + } + + #[test] + fn home_modular_starter_retains_warm_lampglow_emitters() { + let (document, means, report) = load("home_modular_starter.glb"); + assert_eq!(report.errors, 0); + assert!(report.candidates >= 4); + assert!(report.retained >= 4); + let lights = extract_embedded_lights(&document, &means).expect("valid home lights"); + assert!(lights.iter().any(|light| { + light.source == EmbeddedLightSource::Inferred + && light.color[0] >= light.color[1] + && light.color[0] > light.color[2] + })); + } + + #[test] + fn clone_terminal_retains_cyan_and_amber_emitters() { + let (document, means, report) = load("clone_terminal.glb"); + assert_eq!(report.errors, 0); + assert!(report.candidates >= 2); + let lights = extract_embedded_lights(&document, &means).expect("valid terminal lights"); + assert!(lights.iter().any(|light| { + light.source == EmbeddedLightSource::Inferred && light.color[2] > light.color[0] + })); + assert!(lights.iter().any(|light| { + light.source == EmbeddedLightSource::Inferred && light.color[0] > light.color[2] + })); + } + + #[test] + fn facility_emitters_split_into_bounded_local_clusters() { + let (document, means, report) = load("cloning_facility.glb"); + assert_eq!(report.errors, 0); + assert!(report.candidates > 1); + assert!(report.retained > 1); + assert!(report.retained <= MAX_LIGHTS_PER_MODEL); + let globals = document.node_globals(); + let lights = extract_embedded_lights(&document, &means).expect("valid facility lights"); + assert!(lights.iter().all(|light| light.radius <= 12.0)); + let positions = lights + .iter() + .map(|light| { + globals[light.node] + .mul(light.local) + .transform_point(successor_engine_core::math::Vec3::ZERO) + }) + .collect::>(); + let mut spread_squared = 0.0f32; + for (index, left) in positions.iter().enumerate() { + for right in &positions[index + 1..] { + let delta = left.sub(*right); + spread_squared = spread_squared.max(delta.dot(delta)); + } + } + assert!( + spread_squared > 4.0, + "facility emitters collapsed to one center" + ); + } + + #[test] + fn campfire_texture_mean_preserves_warm_emission_color() { + let (document, means, report) = load("campfire_frontier.glb"); + assert_eq!(report.errors, 0); + let lights = extract_embedded_lights(&document, &means).expect("valid campfire lights"); + assert!(lights.iter().any(|light| { + light.source == EmbeddedLightSource::Inferred + && light.color[0] > light.color[1] + && light.color[1] > light.color[2] + })); + } + + #[test] + fn tiny_emissive_geometry_does_not_create_point_lights() { + let (mut document, means, _) = load("clone_terminal.glb"); + for material in &mut document.materials { + material.emissive_factor = [1.0e-10; 3]; + material.emissive_strength = 1.0; + } + let report = light_extraction_report(&document, &means); + assert!(report.candidates > 0); + assert_eq!(report.retained, 0); + assert_eq!(report.suppressed_low_flux, report.candidates); + assert_eq!(report.errors, 0); + } + + #[test] + fn authored_point_range_is_preserved_without_inference_clamping() { + let (mut document, _, _) = load("clone_terminal.glb"); + let mut node = document.nodes.first().expect("model node").clone(); + node.children.clear(); + node.mesh = None; + node.skin = None; + node.light = Some(0); + document.nodes = vec![node]; + document.lights = vec![glb::GlbPunctualLight { + name: Some("long-range".to_string()), + kind: glb::GlbLightKind::Point, + color: [1.0, 0.8, 0.6], + intensity: 2.0, + range: Some(64.0), + }]; + + let lights = extract_embedded_lights(&document, &[]).expect("valid authored range"); + assert_eq!(lights.len(), 1); + assert_eq!(lights[0].radius, 64.0); + } + + #[test] + fn wide_triangles_are_clipped_across_two_meter_cells() { + let points = [ + successor_engine_core::math::vec3(0.0, 0.0, 0.0), + successor_engine_core::math::vec3(10.0, 0.0, 0.0), + successor_engine_core::math::vec3(10.0, 1.0, 0.0), + ]; + let second = [ + points[0], + points[2], + successor_engine_core::math::vec3(0.0, 1.0, 0.0), + ]; + let triangles = vec![ + super::EmitterTriangle { + component: 0, + points, + centroid: points[0].add(points[1]).add(points[2]).scale(1.0 / 3.0), + normal: points[1].sub(points[0]).cross(points[2].sub(points[0])), + area: 5.0, + emission: [1.0; 3], + }, + super::EmitterTriangle { + component: 0, + points: second, + centroid: second[0].add(second[1]).add(second[2]).scale(1.0 / 3.0), + normal: second[1].sub(second[0]).cross(second[2].sub(second[0])), + area: 5.0, + emission: [1.0; 3], + }, + ]; + let components = vec![super::ComponentBounds { + anchor: 0, + material: 0, + min: points[0], + max: successor_engine_core::math::vec3(10.0, 1.0, 0.0), + }]; + let mut report = super::LightExtractionReport::default(); + let clusters = super::aggregate_emitter_components(triangles, components, &mut report); + + assert_eq!(report.errors, 0); + assert_eq!(clusters.len(), 5); + let total_area = clusters.iter().map(|cluster| cluster.area).sum::(); + assert!((total_area - 10.0).abs() < 1.0e-4); + } + + #[test] + fn explicit_light_budget_overflow_fails_model_extraction() { + let (mut document, _, _) = load("clone_terminal.glb"); + let template = document.nodes.first().expect("model node").clone(); + document.lights = (0..=MAX_LIGHTS_PER_MODEL) + .map(|index| glb::GlbPunctualLight { + name: Some(format!("point-{index}")), + kind: glb::GlbLightKind::Point, + color: [1.0, 0.8, 0.6], + intensity: 2.0, + range: Some(4.0), + }) + .collect(); + document.nodes = (0..=MAX_LIGHTS_PER_MODEL) + .map(|light| { + let mut node = template.clone(); + node.children.clear(); + node.mesh = None; + node.skin = None; + node.light = Some(light); + node + }) + .collect(); + + let report = light_extraction_report(&document, &[]); + assert_eq!(report.explicit, MAX_LIGHTS_PER_MODEL); + assert_eq!(report.retained, MAX_LIGHTS_PER_MODEL); + assert_eq!(report.over_budget, 1); + assert_eq!(report.errors, 1); + assert_eq!(extract_embedded_lights(&document, &[]), Err(report)); + } + #[test] + fn light_budget_failure_precedes_gpu_resource_upload() { + let (mut document, _, _) = load("clone_terminal.glb"); + let template = document.nodes.first().expect("model node").clone(); + document.lights = (0..=MAX_LIGHTS_PER_MODEL) + .map(|index| glb::GlbPunctualLight { + name: Some(format!("point-{index}")), + kind: glb::GlbLightKind::Point, + color: [1.0, 0.8, 0.6], + intensity: 2.0, + range: Some(4.0), + }) + .collect(); + document.nodes = (0..=MAX_LIGHTS_PER_MODEL) + .map(|light| { + let mut node = template.clone(); + node.children.clear(); + node.mesh = None; + node.skin = None; + node.light = Some(light); + node + }) + .collect(); + + let mut gpu = successor_engine_render::gpu::MockGpu::default(); + let mut renderer = successor_engine_render::renderer::Renderer::new( + &mut gpu, + successor_engine_render::renderer::RendererLimits::default(), + ) + .expect("renderer"); + let resources_before = gpu.resources_created(); + + assert!(matches!( + upload_static_document(&mut gpu, &mut renderer, &document), + Err(glb::GlbError::Unsupported("model point lights")) + )); + assert_eq!(gpu.resources_created(), resources_before); + } +} diff --git a/client-rust/source/app/src/assets/stream.rs b/client-rust/source/app/src/assets/stream.rs new file mode 100644 index 00000000..1e00e5ad --- /dev/null +++ b/client-rust/source/app/src/assets/stream.rs @@ -0,0 +1,408 @@ +//! Bounded asynchronous asset streaming. +//! +//! `AssetStreamer` multiplexes asset fetches through the platform async +//! channel (`begin_asset`/`poll_asset`) with a fixed in-flight capacity. +//! Backends without an async channel (native tests, immediate reads) resolve +//! synchronously inside `request`/`pump`, so consumers behave identically on +//! every platform. `pump` is allocation-free when nothing is in flight, which +//! keeps it safe to call from the steady-state frame loop. + +use std::collections::{HashSet, VecDeque}; + +use successor_platform::{AssetHandle, AssetPoll, Platform}; + +/// Maximum concurrent in-flight fetches. +pub const MAX_IN_FLIGHT: usize = 64; + +/// Three-way byte resolution for streaming consumers: bytes in hand, fetch +/// still in flight, or terminally unavailable (treat like a sync miss). +pub enum ByteSource { + Ready(Vec), + Pending, + Missing, +} + +/// Resolution state for consumers bridging async fetches into sync-shaped +/// call sites: `Pending` means "render without this piece and retry". +pub enum Streamed { + Ready(T), + Pending, +} + +struct InFlight { + id: String, + handle: AssetHandle, +} + +pub struct AssetStreamer { + /// Fixed-capacity slot table; `None` marks a vacant slot. + in_flight: Vec>, + /// Requests accepted beyond capacity, started as slots free up. + queued: VecDeque, + /// Stable ids already claimed (in flight or queued); dedupe guard. + active: HashSet, + /// Ids whose fetch terminally failed; consumers see `Missing` forever. + failed: HashSet, + /// Completed fetches awaiting consumer pickup. + completed: VecDeque<(String, Vec)>, + /// Monotonic counter bumped whenever `pump`/`request` deliver results; + /// cheap change detection so consumers skip probing when unchanged. + ready_epoch: u64, +} + +impl Default for AssetStreamer { + fn default() -> Self { + Self::new() + } +} + +impl AssetStreamer { + pub fn new() -> Self { + Self { + in_flight: std::iter::repeat_with(|| None) + .take(MAX_IN_FLIGHT) + .collect(), + queued: VecDeque::new(), + active: HashSet::new(), + failed: HashSet::new(), + completed: VecDeque::new(), + ready_epoch: 0, + } + } + + /// Request `id`. Deduplicates against in-flight, queued, un-drained + /// completions, and terminal failures. When the platform exposes no async + /// channel the read resolves synchronously and lands directly in + /// `completed` (or `failed`). + pub fn request(&mut self, platform: &mut dyn Platform, id: &str) { + if self.active.contains(id) + || self.failed.contains(id) + || self.completed.iter().any(|(done, _)| done == id) + { + return; + } + match platform.begin_asset(id) { + Some(handle) => { + if let Some(slot) = self.in_flight.iter_mut().find(|slot| slot.is_none()) { + *slot = Some(InFlight { + id: id.to_string(), + handle, + }); + self.active.insert(id.to_string()); + } else { + self.queued.push_back(id.to_string()); + self.active.insert(id.to_string()); + } + } + None => match platform.read_asset(id) { + Ok(bytes) => { + self.completed.push_back((id.to_string(), bytes)); + self.ready_epoch += 1; + } + Err(_) => { + self.failed.insert(id.to_string()); + } + }, + } + } + + /// Poll in-flight fetches and start queued requests as capacity frees. + /// Allocation-free when idle: with no in-flight slots and an empty queue + /// this touches no heap memory. + pub fn pump(&mut self, platform: &mut dyn Platform) { + for slot in &mut self.in_flight { + let Some(in_flight) = slot else { + continue; + }; + match platform.poll_asset(in_flight.handle) { + AssetPoll::Pending => {} + AssetPoll::Ready(bytes) => { + let id = std::mem::take(&mut in_flight.id); + self.active.remove(&id); + self.completed.push_back((id, bytes)); + self.ready_epoch += 1; + *slot = None; + } + AssetPoll::Failed => { + let id = std::mem::take(&mut in_flight.id); + self.active.remove(&id); + self.failed.insert(id); + *slot = None; + } + } + } + while !self.queued.is_empty() { + let Some(slot) = self.in_flight.iter_mut().find(|slot| slot.is_none()) else { + break; + }; + let id = self.queued.pop_front().expect("queue checked non-empty"); + match platform.begin_asset(&id) { + Some(handle) => { + *slot = Some(InFlight { id, handle }); + } + None => { + self.active.remove(&id); + match platform.read_asset(&id) { + Ok(bytes) => { + self.completed.push_back((id, bytes)); + self.ready_epoch += 1; + } + Err(_) => { + self.failed.insert(id); + } + } + } + } + } + } + + /// Drain a completed fetch for `id`, if one is waiting. + pub fn take(&mut self, id: &str) -> Option> { + let position = self.completed.iter().position(|(done, _)| done == id)?; + Some(self.completed.remove(position).expect("position found").1) + } + + /// Drain every waiting completion. Allocation-free when empty. + pub fn drain_ready(&mut self) -> std::collections::vec_deque::Drain<'_, (String, Vec)> { + self.completed.drain(..) + } + + /// Completion epoch: changes exactly when new results land in `completed`. + pub fn ready_epoch(&self) -> u64 { + self.ready_epoch + } + + /// Resolve bytes for `id`: drain a completion when one is waiting, + /// otherwise (idempotently) issue the request and report `Pending`. + /// Terminal failures report `Missing` without ever re-fetching. + pub fn resolve(&mut self, platform: &mut dyn Platform, id: &str) -> ByteSource { + if self.failed.contains(id) { + return ByteSource::Missing; + } + match self.take(id) { + Some(bytes) => ByteSource::Ready(bytes), + None => { + self.request(platform, id); + ByteSource::Pending + } + } + } + + /// True while `id` is in flight or queued. + pub fn is_active(&self, id: &str) -> bool { + self.active.contains(id) + } + + /// Number of occupied in-flight slots. + pub fn in_flight_count(&self) -> usize { + self.in_flight.iter().flatten().count() + } + + /// Completed fetches not yet drained by a consumer. + pub fn pending_drain(&self) -> usize { + self.completed.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use successor_platform::{AssetError, SettingsScope}; + + struct AsyncMock { + bytes: Vec, + pending: Vec<(u64, u32)>, // (handle, polls until ready) + polls_until_ready: u32, + next_handle: u64, + began: Vec, + } + + impl AsyncMock { + fn new(bytes: &[u8], polls_until_ready: u32) -> Self { + Self { + bytes: bytes.to_vec(), + pending: Vec::new(), + polls_until_ready, + next_handle: 1, + began: Vec::new(), + } + } + } + + impl Platform for AsyncMock { + fn monotonic_ms(&self) -> u64 { + 0 + } + fn logical_size(&self) -> (u32, u32) { + (0, 0) + } + fn read_asset(&self, _stable_id: &str) -> Result, AssetError> { + Ok(self.bytes.clone()) + } + fn begin_asset(&mut self, stable_id: &str) -> Option { + self.began.push(stable_id.to_string()); + let handle = self.next_handle; + self.next_handle += 1; + self.pending.push((handle, self.polls_until_ready)); + Some(AssetHandle(handle)) + } + fn poll_asset(&mut self, handle: AssetHandle) -> AssetPoll { + let Some(entry) = self.pending.iter_mut().find(|(h, _)| *h == handle.0) else { + return AssetPoll::Failed; + }; + if entry.1 > 0 { + entry.1 -= 1; + return AssetPoll::Pending; + } + self.pending.retain(|(h, _)| *h != handle.0); + AssetPoll::Ready(self.bytes.clone()) + } + fn load_settings(&self, _scope: SettingsScope) -> Option> { + None + } + fn save_settings(&mut self, _scope: SettingsScope, _bytes: &[u8]) -> Result<(), String> { + Ok(()) + } + fn report_fatal(&mut self, _message: &str) {} + } + + struct SyncMock; + + impl Platform for SyncMock { + fn monotonic_ms(&self) -> u64 { + 0 + } + fn logical_size(&self) -> (u32, u32) { + (0, 0) + } + fn read_asset(&self, stable_id: &str) -> Result, AssetError> { + Ok(stable_id.as_bytes().to_vec()) + } + fn load_settings(&self, _scope: SettingsScope) -> Option> { + None + } + fn save_settings(&mut self, _scope: SettingsScope, _bytes: &[u8]) -> Result<(), String> { + Ok(()) + } + fn report_fatal(&mut self, _message: &str) {} + } + + #[test] + fn sync_backend_resolves_inside_request() { + let mut platform = SyncMock; + let mut streamer = AssetStreamer::new(); + streamer.request(&mut platform, "assets/x.glb"); + let Some(bytes) = streamer.take("assets/x.glb") else { + panic!("sync request must complete immediately"); + }; + assert_eq!(bytes, b"assets/x.glb"); + assert_eq!(streamer.in_flight_count(), 0); + } + + #[test] + fn async_backend_pends_then_delivers() { + let mut platform = AsyncMock::new(b"payload", 2); + let mut streamer = AssetStreamer::new(); + streamer.request(&mut platform, "assets/y.glb"); + assert!(streamer.take("assets/y.glb").is_none()); + streamer.pump(&mut platform); // pending + streamer.pump(&mut platform); // pending + assert!(streamer.take("assets/y.glb").is_none()); + streamer.pump(&mut platform); // ready + let Some(bytes) = streamer.take("assets/y.glb") else { + panic!("poll must deliver bytes once ready"); + }; + assert_eq!(bytes, b"payload"); + assert_eq!(streamer.in_flight_count(), 0); + assert!(!streamer.is_active("assets/y.glb")); + } + + #[test] + fn duplicate_requests_are_ignored() { + let mut platform = AsyncMock::new(b"z", 0); + let mut streamer = AssetStreamer::new(); + streamer.request(&mut platform, "assets/dup.glb"); + streamer.request(&mut platform, "assets/dup.glb"); + assert_eq!(platform.began.len(), 1); + streamer.pump(&mut platform); + streamer.request(&mut platform, "assets/dup.glb"); + assert_eq!(platform.began.len(), 1, "un-drained completion dedupes"); + assert!(streamer.take("assets/dup.glb").is_some()); + } + + #[test] + fn resolve_reports_pending_then_ready_then_missing_is_terminal() { + struct FailOnce; + impl Platform for FailOnce { + fn monotonic_ms(&self) -> u64 { + 0 + } + fn logical_size(&self) -> (u32, u32) { + (0, 0) + } + fn read_asset(&self, _stable_id: &str) -> Result, AssetError> { + Err(AssetError::Unreadable) + } + fn load_settings(&self, _scope: SettingsScope) -> Option> { + None + } + fn save_settings(&mut self, _scope: SettingsScope, _b: &[u8]) -> Result<(), String> { + Ok(()) + } + fn report_fatal(&mut self, _message: &str) {} + } + + let mut platform = AsyncMock::new(b"resolved", 1); + let mut streamer = AssetStreamer::new(); + assert!(matches!( + streamer.resolve(&mut platform, "assets/pending.glb"), + ByteSource::Pending + )); + streamer.pump(&mut platform); + assert!(matches!( + streamer.resolve(&mut platform, "assets/pending.glb"), + ByteSource::Pending + )); + streamer.pump(&mut platform); + match streamer.resolve(&mut platform, "assets/pending.glb") { + ByteSource::Ready(bytes) => assert_eq!(bytes, b"resolved"), + _ => panic!("completion must resolve to bytes"), + } + + let mut failing = FailOnce; + assert!(matches!( + streamer.resolve(&mut failing, "assets/absent.glb"), + ByteSource::Pending + )); + assert!(matches!( + streamer.resolve(&mut failing, "assets/absent.glb"), + ByteSource::Missing + )); + // Terminal: a third resolve must not re-issue a platform read. + assert!(matches!( + streamer.resolve(&mut failing, "assets/absent.glb"), + ByteSource::Missing + )); + } + + #[test] + fn capacity_queues_and_drains_in_order() { + let mut platform = AsyncMock::new(b"w", 0); + let mut streamer = AssetStreamer::new(); + for index in 0..(MAX_IN_FLIGHT + 4) { + let id = format!("assets/bulk/{index}.glb"); + streamer.request(&mut platform, &id); + } + assert_eq!(streamer.in_flight_count(), MAX_IN_FLIGHT); + // Every pump completes all in-flight (0 polls) and starts the queued. + for _ in 0..2 { + streamer.pump(&mut platform); + } + assert_eq!(streamer.pending_drain(), MAX_IN_FLIGHT + 4); + for index in 0..(MAX_IN_FLIGHT + 4) { + let id = format!("assets/bulk/{index}.glb"); + assert!(streamer.take(&id).is_some(), "missing {id}"); + } + assert_eq!(streamer.in_flight_count(), 0); + } +} diff --git a/client-rust/source/app/src/audio/mod.rs b/client-rust/source/app/src/audio/mod.rs new file mode 100644 index 00000000..7469c7b6 --- /dev/null +++ b/client-rust/source/app/src/audio/mod.rs @@ -0,0 +1,472 @@ +//! Native SFX runtime: MP3 decode (`rmp3`) → the software mixer +//! (`engine_core::audio`), a manifest-driven clip registry with buses, and the +//! game-event → sound trigger map (port of `client/src/audio/sfx.ts`). +//! +//! The mixer is shared behind an `Arc>` so the platform device sink +//! (`successor_platform::AudioOutput`) can pull interleaved-stereo blocks from +//! its render thread while the scene fires triggers from the frame loop. The +//! device callback never allocates; play/stop calls only touch preallocated +//! voice slots. +//! +//! Web builds decode through Web Audio and use the same trigger map; the +//! native decode path stays out of the wasm module. + +#[cfg(not(target_arch = "wasm32"))] +use std::sync::{Arc, Mutex}; + +#[cfg(not(target_arch = "wasm32"))] +use successor_engine_core::audio::{Mixer, Pcm}; +use successor_engine_core::audio::{Point, SpatialOpts}; + +pub mod triggers; +#[cfg(not(target_arch = "wasm32"))] +pub mod wav; +pub use triggers::*; + +/// Output sample rate the native mixer runs at (manifest assets are 44.1 kHz). +#[cfg(not(target_arch = "wasm32"))] +pub const OUT_RATE: u32 = 44_100; + +#[cfg(not(target_arch = "wasm32"))] +pub fn decode_mp3(bytes: &[u8]) -> Pcm { + use rmp3::{Decoder, Frame}; + let mut decoder = Decoder::new(bytes); + let mut out: Vec = Vec::new(); + let mut rate = OUT_RATE; + let mut first = true; + while let Some(frame) = decoder.next() { + if let Frame::Audio(audio) = frame { + if first { + rate = audio.sample_rate(); + first = false; + } + let channels = audio.channels() as usize; + let samples = audio.samples(); + if channels <= 1 { + out.extend_from_slice(samples); + } else { + for chunk in samples.chunks_exact(channels) { + out.push(chunk.iter().sum::() / channels as f32); + } + } + } + } + Pcm::new(out, rate) +} + +#[derive(Clone, Debug)] +struct ClipInfo { + id: String, + #[cfg(target_arch = "wasm32")] + path: String, + #[cfg(not(target_arch = "wasm32"))] + bank: usize, + gain: f32, + polyphony: u32, +} + +#[derive(Debug)] +struct ParsedClip { + id: String, + path: String, + gain: f32, + polyphony: u32, +} + +fn normalized_audio_path(path: &str) -> Option<&str> { + let path = path.strip_prefix('/').unwrap_or(path); + (!path.is_empty() + && path.starts_with("successor-audio/") + && !path.contains("..") + && !path.contains('\\') + && !path.contains("://") + && !path.starts_with('/')) + .then_some(path) +} + +fn parse_manifest(manifest_json: &str) -> Result, ()> { + let root: serde_json::Value = serde_json::from_str(manifest_json).map_err(|_| ())?; + if root.get("schema").and_then(|v| v.as_str()) != Some("successor-sfx-manifest-v1") { + return Err(()); + } + let buses = root.get("buses").and_then(|v| v.as_object()).ok_or(())?; + let mut parsed_buses = Vec::with_capacity(buses.len()); + for (name, value) in buses { + if name.is_empty() || parsed_buses.iter().any(|(known, _)| known == name) { + return Err(()); + } + let gain = value.get("volume").and_then(|v| v.as_f64()).ok_or(())? as f32; + let polyphony = value.get("polyphony").and_then(|v| v.as_u64()).ok_or(())?; + if !gain.is_finite() || polyphony == 0 || polyphony > u32::MAX as u64 { + return Err(()); + } + parsed_buses.push((name.as_str(), gain)); + } + let clips = root.get("clips").and_then(|v| v.as_array()).ok_or(())?; + let mut parsed = Vec::with_capacity(clips.len()); + for clip in clips { + let id = clip.get("id").and_then(|v| v.as_str()).ok_or(())?; + if id.is_empty() || parsed.iter().any(|known: &ParsedClip| known.id == id) { + return Err(()); + } + let path = + normalized_audio_path(clip.get("path").and_then(|v| v.as_str()).ok_or(())?).ok_or(())?; + let bus = clip.get("bus").and_then(|v| v.as_str()).ok_or(())?; + let bus_gain = parsed_buses + .iter() + .find(|(name, _)| *name == bus) + .map(|(_, gain)| *gain) + .ok_or(())?; + let clip_gain = clip.get("volume").and_then(|v| v.as_f64()).ok_or(())? as f32; + let polyphony = clip.get("polyphony").and_then(|v| v.as_u64()).ok_or(())?; + if !clip_gain.is_finite() || polyphony == 0 || polyphony > u32::MAX as u64 { + return Err(()); + } + let gain = clip_gain * bus_gain; + if !gain.is_finite() { + return Err(()); + } + parsed.push(ParsedClip { + id: id.to_string(), + path: path.to_string(), + gain, + polyphony: polyphony as u32, + }); + } + Ok(parsed) +} + +pub struct SfxPlayer { + #[cfg(not(target_arch = "wasm32"))] + mixer: Arc>, + clips: Vec, + listener: Point, +} + +impl SfxPlayer { + pub fn new() -> Self { + Self { + #[cfg(not(target_arch = "wasm32"))] + mixer: Arc::new(Mutex::new(Mixer::new(OUT_RATE, 64))), + clips: Vec::new(), + listener: Point { x: 0.0, y: 0.0 }, + } + } + + #[cfg(not(target_arch = "wasm32"))] + pub fn shared_mixer(&self) -> Arc> { + Arc::clone(&self.mixer) + } + + #[cfg(not(target_arch = "wasm32"))] + fn lock_mixer(&self) -> std::sync::MutexGuard<'_, Mixer> { + self.mixer + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + pub fn set_listener(&mut self, p: Point) { + self.listener = p; + } + + pub fn listener(&self) -> Point { + self.listener + } + + pub fn load_with( + &mut self, + manifest_json: &str, + read: &mut dyn FnMut(&str) -> Option>, + ) -> usize { + let Ok(parsed) = parse_manifest(manifest_json) else { + return 0; + }; + self.clips.clear(); + for clip in parsed { + #[cfg(not(target_arch = "wasm32"))] + { + let Some(bytes) = read(&clip.path) else { + continue; + }; + let pcm = decode_mp3(&bytes); + if pcm.samples.is_empty() { + continue; + } + let bank = self.lock_mixer().add_clip(pcm); + self.clips.push(ClipInfo { + id: clip.id, + bank, + gain: clip.gain, + polyphony: clip.polyphony, + }); + } + #[cfg(target_arch = "wasm32")] + { + let _ = read; + self.clips.push(ClipInfo { + id: clip.id, + path: clip.path, + gain: clip.gain, + polyphony: clip.polyphony, + }); + } + } + self.clips.len() + } + + #[cfg(not(target_arch = "wasm32"))] + pub fn load(&mut self, manifest_json: &str, assets_dir: &str) -> usize { + let dir = assets_dir.trim_end_matches('/').to_string(); + self.load_with(manifest_json, &mut |stable_id: &str| { + let file = stable_id.rsplit('/').next().unwrap_or(stable_id); + std::fs::read(format!("{dir}/{file}")).ok() + }) + } + + fn clip(&self, id: &str) -> Option<&ClipInfo> { + self.clips.iter().find(|clip| clip.id == id) + } + + fn key(id: &str) -> u32 { + let mut hash = 0x811c_9dc5u32; + for byte in id.bytes() { + hash ^= byte as u32; + hash = hash.wrapping_mul(0x0100_0193); + } + hash + } + + fn dispatch( + &self, + clip: &ClipInfo, + key: u32, + gain: f32, + pan: f32, + looped: bool, + polyphony: u32, + ) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + self.lock_mixer() + .play(clip.bank, key, gain, pan, 1.0, looped, polyphony) + } + #[cfg(target_arch = "wasm32")] + { + successor_platform::web::audio_play(&clip.path, key, gain, pan, looped, polyphony) + } + } + + pub fn play_at(&mut self, id: &str, at: Point, opts: SpatialOpts) -> bool { + let Some(clip) = self.clip(id) else { + return false; + }; + let mix = successor_engine_core::audio::spatial_mix(self.listener, at, opts); + let gain = clip.gain * mix.gain; + if gain <= 0.0 { + return true; + } + self.dispatch(clip, Self::key(id), gain, mix.pan, false, clip.polyphony) + } + + pub fn play_ui(&mut self, id: &str) -> bool { + let Some(clip) = self.clip(id) else { + return false; + }; + self.dispatch(clip, Self::key(id), clip.gain, 0.0, false, clip.polyphony) + } + + pub fn play_loop(&mut self, id: &str, key: u32, at: Option, volume: f32) -> bool { + let Some(clip) = self.clip(id) else { + return false; + }; + let spatial = at + .map(|point| { + successor_engine_core::audio::spatial_mix( + self.listener, + point, + SpatialOpts::default(), + ) + .gain + }) + .unwrap_or(1.0); + let gain = clip.gain * spatial * volume; + if gain <= 0.0 { + return true; + } + self.dispatch(clip, key, gain, 0.0, true, 1) + } + + pub fn stop_loop(&mut self, key: u32) { + #[cfg(not(target_arch = "wasm32"))] + self.lock_mixer().stop_key(key); + #[cfg(target_arch = "wasm32")] + successor_platform::web::audio_stop(key); + } + + pub fn clip_count(&self) -> usize { + self.clips.len() + } + + pub fn active_voices(&self) -> usize { + #[cfg(not(target_arch = "wasm32"))] + { + self.lock_mixer().active_voices() + } + #[cfg(target_arch = "wasm32")] + { + successor_platform::web::audio_active_voices() as usize + } + } + + #[cfg(not(target_arch = "wasm32"))] + pub fn render(&mut self, out: &mut [f32]) { + self.lock_mixer().mix_into(out); + } +} + +impl Default for SfxPlayer { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ASSETS: &str = "../../../client/public/successor-audio/sfx"; + + fn read_manifest() -> Option { + std::fs::read_to_string(format!("{ASSETS}/manifest.json")).ok() + } + + #[test] + fn decodes_a_real_manifest_mp3() { + let path = format!("{ASSETS}/ui_panel_open.mp3"); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: asset absent"); + return; + }; + let pcm = decode_mp3(&bytes); + assert!(!pcm.samples.is_empty(), "decoded PCM non-empty"); + assert_eq!(pcm.sample_rate, 44_100, "44.1 kHz source"); + assert!( + pcm.duration_secs() > 0.3 && pcm.duration_secs() < 1.0, + "≈0.5s, got {}", + pcm.duration_secs() + ); + } + + #[test] + fn loads_manifest_and_plays_ui_clip() { + let Some(manifest) = read_manifest() else { + eprintln!("skip: manifest absent"); + return; + }; + let mut p = SfxPlayer::new(); + let n = p.load(&manifest, ASSETS); + assert!(n > 50, "loaded a substantial clip bank, got {n}"); + assert!(p.play_ui("ui_panel_open"), "known UI clip plays"); + assert_eq!(p.active_voices(), 1); + assert!(!p.play_ui("does_not_exist"), "unknown clip rejected"); + } + + #[test] + fn distant_spatial_clip_is_culled() { + let Some(manifest) = read_manifest() else { + return; + }; + let mut p = SfxPlayer::new(); + if p.load(&manifest, ASSETS) == 0 { + return; + } + p.set_listener(Point { x: 0.0, y: 0.0 }); + let far = Point { x: 0.0, y: 200.0 }; + assert!(p.play_at("slugthrower_fire", far, SpatialOpts::default())); + assert_eq!(p.active_voices(), 0, "distant shot culled"); + } + + #[test] + fn loop_starts_and_stops_under_its_key() { + let Some(manifest) = read_manifest() else { + return; + }; + let mut p = SfxPlayer::new(); + if p.load(&manifest, ASSETS) == 0 { + return; + } + assert!(p.play_loop("campfire_crackle_loop", 0xC0FFEE, None, 1.0)); + assert!(p.active_voices() >= 1); + p.stop_loop(0xC0FFEE); + assert_eq!(p.active_voices(), 0, "loop torn down by key"); + } + + #[test] + fn shared_mixer_renders_from_a_second_handle() { + let Some(manifest) = read_manifest() else { + return; + }; + let mut p = SfxPlayer::new(); + if p.load(&manifest, ASSETS) == 0 { + return; + } + let shared = p.shared_mixer(); + assert!(p.play_ui("ui_panel_open")); + let mut block = vec![0.0f32; 16_384]; + shared + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .mix_into(&mut block); + assert!( + block.iter().any(|s| s.abs() > 1e-6), + "device-sink handle hears the scene's trigger" + ); + } + + #[test] + fn strict_manifest_parser_preserves_gain_path_and_polyphony() { + let manifest = read_manifest().expect("checked-in manifest"); + let clips = parse_manifest(&manifest).expect("valid manifest"); + let clip = clips + .iter() + .find(|clip| clip.id == "ui_panel_open") + .expect("UI clip"); + assert_eq!(clip.path, "successor-audio/sfx/ui_panel_open.mp3"); + assert!((clip.gain - 0.255).abs() < 1.0e-6); + assert_eq!(clip.polyphony, 4); + } + + fn minimal_manifest(path: &str, bus: &str, volume: &str, polyphony: u32) -> String { + format!( + r#"{{"schema":"successor-sfx-manifest-v1","buses":{{"ui":{{"volume":0.75,"polyphony":10}}}},"clips":[{{"id":"clip","path":"{path}","bus":"{bus}","volume":{volume},"polyphony":{polyphony}}}]}}"# + ) + } + + #[test] + fn strict_manifest_parser_rejects_invalid_contracts() { + assert!(parse_manifest(r#"{"schema":"wrong","buses":{},"clips":[]}"#).is_err()); + for path in [ + "../successor-audio/x.mp3", + "/../successor-audio/x.mp3", + "https://example.test/x.mp3", + "//successor-audio/x.mp3", + ] { + assert!(parse_manifest(&minimal_manifest(path, "ui", "1.0", 1)).is_err()); + } + assert!(parse_manifest(&minimal_manifest( + "successor-audio/x.mp3", + "unknown", + "1.0", + 1, + )) + .is_err()); + assert!( + parse_manifest(&minimal_manifest("successor-audio/x.mp3", "ui", "1e400", 1,)).is_err() + ); + assert!( + parse_manifest(&minimal_manifest("successor-audio/x.mp3", "ui", "1.0", 0,)).is_err() + ); + let duplicate = r#"{"schema":"successor-sfx-manifest-v1","buses":{"ui":{"volume":1,"polyphony":1}},"clips":[{"id":"same","path":"successor-audio/a.mp3","bus":"ui","volume":1,"polyphony":1},{"id":"same","path":"successor-audio/b.mp3","bus":"ui","volume":1,"polyphony":1}]}"#; + assert!(parse_manifest(duplicate).is_err()); + } +} diff --git a/client-rust/source/app/src/audio/triggers.rs b/client-rust/source/app/src/audio/triggers.rs new file mode 100644 index 00000000..7e001ca3 --- /dev/null +++ b/client-rust/source/app/src/audio/triggers.rs @@ -0,0 +1,428 @@ +//! Game-event → SFX trigger map (port of the trigger sites in `sfx.ts` + +//! callers). Each trigger names a manifest clip id; combat triggers derive +//! from the same authoritative `CombatEvent`s that drive the particle FX, so +//! audio and visuals fire from one event. Ambience/doors/loop triggers are +//! keyed, bounded, and always stoppable (no orphaned loops). + +use super::SfxPlayer; +use crate::game::combat_fx::{CombatEvent, CombatOutcome, WeaponVisual}; +use crate::world::terrain::Biome; +use successor_engine_core::audio::{Point, SpatialOpts}; + +/// Minimum separation in milliseconds between consecutive plays of the same UI cue. +/// +/// At 60 Hz, a single frame is ~16.67ms. If multiple events fire the same cue in a +/// single frame or adjacent frames (e.g. bulk loot processing, multi-item transfers, +/// or rapid key repeat), playing identical PCM buffers simultaneously produces +/// constructive phase alignment resulting in digital clipping and an unpleasantly +/// loud click storm. A 35ms retrigger floor suppresses intra-frame and immediate +/// sub-frame duplicates while remaining fully responsive to intentional consecutive +/// user clicks (which typically have >= 100ms separation). +pub const UI_CUE_RETRIGGER_FLOOR_MS: u64 = 35; + +/// UI/HUD sound cues (non-spatial). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UiCue { + PanelOpen, + PanelClose, + ButtonTick, + ToolbarUse, + ToolbarIneligible, + ChatSend, + ChatReceive, + Notification, + Deny, + CreditsChime, + ItemTransfer, + AreaTransition, +} + +impl UiCue { + pub const ALL: [UiCue; 12] = [ + UiCue::PanelOpen, + UiCue::PanelClose, + UiCue::ButtonTick, + UiCue::ToolbarUse, + UiCue::ToolbarIneligible, + UiCue::ChatSend, + UiCue::ChatReceive, + UiCue::Notification, + UiCue::Deny, + UiCue::CreditsChime, + UiCue::ItemTransfer, + UiCue::AreaTransition, + ]; + + pub fn index(self) -> usize { + match self { + UiCue::PanelOpen => 0, + UiCue::PanelClose => 1, + UiCue::ButtonTick => 2, + UiCue::ToolbarUse => 3, + UiCue::ToolbarIneligible => 4, + UiCue::ChatSend => 5, + UiCue::ChatReceive => 6, + UiCue::Notification => 7, + UiCue::Deny => 8, + UiCue::CreditsChime => 9, + UiCue::ItemTransfer => 10, + UiCue::AreaTransition => 11, + } + } + + pub fn clip_id(self) -> &'static str { + match self { + UiCue::PanelOpen => "ui_panel_open", + UiCue::PanelClose => "ui_panel_close", + UiCue::ButtonTick => "ui_button_tick", + UiCue::ToolbarUse => "ui_toolbar_use", + UiCue::ToolbarIneligible => "ui_toolbar_ineligible", + UiCue::ChatSend => "chat_send", + UiCue::ChatReceive => "chat_receive", + UiCue::Notification => "notification_ping", + UiCue::Deny => "ui_deny", + UiCue::CreditsChime => "credits_chime", + UiCue::ItemTransfer => "item_transfer", + UiCue::AreaTransition => "area_transition", + } + } +} + +thread_local! { + static LAST_CUE_TIMES_MS: std::cell::RefCell<[Option; 12]> = + const { std::cell::RefCell::new([None; 12]) }; +} + +/// Reset the per-cue retrigger floor timers (primarily for deterministic unit testing). +pub fn clear_retrigger_floor() { + LAST_CUE_TIMES_MS.with(|cell| { + *cell.borrow_mut() = [None; 12]; + }); +} + +/// Play a UI cue, applying the per-cue retrigger floor to prevent click storms. +pub fn play_ui(player: &mut SfxPlayer, cue: UiCue) -> bool { + play_ui_at(player, cue, successor_platform::now_ms().max(0.0) as u64) +} + +fn play_ui_at(player: &mut SfxPlayer, cue: UiCue, now_ms: u64) -> bool { + let idx = cue.index(); + let too_soon = LAST_CUE_TIMES_MS.with(|cell| { + let mut times = cell.borrow_mut(); + if let Some(last_ms) = times[idx] { + if now_ms.saturating_sub(last_ms) < UI_CUE_RETRIGGER_FLOOR_MS { + return true; + } + } + times[idx] = Some(now_ms); + false + }); + + if too_soon { + return false; + } + + player.play_ui(cue.clip_id()) +} + +/// Footstep clip id for a step index on a surface family. Desert/forest +/// overworld ground round-robins the grass variants; interiors use tile. +pub fn footstep_id(step: u32, interior: bool) -> &'static str { + const GRASS: [&str; 8] = [ + "footstep_grass_01", + "footstep_grass_02", + "footstep_grass_03", + "footstep_grass_04", + "footstep_grass_05", + "footstep_grass_06", + "footstep_grass_07", + "footstep_grass_08", + ]; + const TILE: [&str; 8] = [ + "footstep_tile_01", + "footstep_tile_02", + "footstep_tile_03", + "footstep_tile_04", + "footstep_tile_05", + "footstep_tile_06", + "footstep_tile_07", + "footstep_tile_08", + ]; + let table = if interior { &TILE } else { &GRASS }; + table[(step as usize) % table.len()] +} + +/// The weapon-fire clip id for the event's weapon family. +pub fn weapon_fire_id(weapon: WeaponVisual) -> &'static str { + match weapon { + WeaponVisual::Plasma => "gunshot_4", + WeaponVisual::Melee => "saber_deflect_01", + WeaponVisual::Slugthrower | WeaponVisual::Unknown => "slugthrower_fire", + } +} + +/// The impact clip id for a combat outcome (None → intentionally silent). +pub fn impact_id(outcome: CombatOutcome) -> Option<&'static str> { + match outcome { + CombatOutcome::Blood => Some("body_hit_1"), + CombatOutcome::Spark => Some("projectile_hit"), + CombatOutcome::Deflect => Some("ricochet_ping"), + CombatOutcome::Dodge => None, + CombatOutcome::Sleep => Some("sleep_puff_soft_01"), + } +} + +/// The door slide clip (fixture props, buildings, and camp auto-doors share +/// the ratified door). +pub const DOOR_CLIP: &str = "door_slide"; +/// Death sting (target killed). +pub const DEATH_CLIP: &str = "death"; +/// Reload pair. +pub const RELOAD_CLIP: &str = "slugthrower_reload"; + +/// Fire the audio for one combat event at its resolved world points: a weapon +/// report at the origin (ranged only) and the outcome impact at the hit +/// point. Mirrors the visual `CombatFx` fan-out so both read one event. +pub fn play_combat( + player: &mut SfxPlayer, + ev: &CombatEvent, + origin_world: [f32; 3], + hit_world: [f32; 3], +) { + let opts = SpatialOpts::default(); + if ev.ranged { + let origin = Point { + x: origin_world[0], + y: origin_world[2], + }; + player.play_at(weapon_fire_id(ev.weapon), origin, opts); + } + let hit = Point { + x: hit_world[0], + y: hit_world[2], + }; + if let Some(clip) = impact_id(ev.outcome) { + player.play_at(clip, hit, opts); + } + if ev.killed { + player.play_at(DEATH_CLIP, hit, opts); + } +} + +/// One-shot ambience beds per biome/day-phase — bounded, no loop lifecycle. +/// Returns the clip id to fire when the ambience timer elapses. +pub fn ambience_one_shot(biome: Biome, is_day: bool, roll: u32) -> &'static str { + const DESERT_DAY: [&str; 6] = [ + "amb_desert_bird_01", + "amb_desert_bird_02", + "amb_desert_bird_03", + "amb_desert_crow_01", + "amb_desert_twig_01", + "amb_desert_twig_02", + ]; + const DESERT_NIGHT: [&str; 2] = [ + "amb_night_cricket_distant_01", + "amb_night_cricket_distant_02", + ]; + const FOREST_DAY: [&str; 4] = [ + "amb_desert_bird_04", + "amb_desert_bird_05", + "amb_desert_bird_06", + "amb_desert_twig_03", + ]; + match (biome, is_day) { + (Biome::Desert, true) => DESERT_DAY[(roll as usize) % DESERT_DAY.len()], + (_, false) => DESERT_NIGHT[(roll as usize) % DESERT_NIGHT.len()], + (Biome::Forest, true) => FOREST_DAY[(roll as usize) % FOREST_DAY.len()], + } +} +/// Canonical open-desert score selection. Combat alternates only when a new +/// combat session starts; callers keep the selected index stable until combat +/// ends so frame updates never restart or thrash the music loop. +pub fn open_desert_music_id(is_day: bool, in_combat: bool, combat_index: u32) -> &'static str { + if in_combat { + if combat_index.is_multiple_of(2) { + "music_combat_sandstorm_run_loop" + } else { + "music_combat_red_dunes_loop" + } + } else if is_day { + "music_desert_day_dust_silent_world_loop" + } else { + "music_desert_night_sleeping_city_loop" + } +} + +/// Low settlement bed shared with the browser open-desert profile. +pub const SETTLEMENT_LOOP: &str = "settlement_murmur_loop"; + +/// Weather loop clip for the current streamed weather, if any. +pub fn weather_loop_id( + kind: successor_engine_render::weather::WeatherKind, + intensity: f32, +) -> Option<&'static str> { + use successor_engine_render::weather::WeatherKind; + match kind { + WeatherKind::Rain if intensity >= 0.55 => Some("rain_heavy_loop"), + WeatherKind::Rain => Some("rain_light_loop"), + // The dust bed reuses the settlement murmur-free desert music stem's + // silence; dust reads through particles + grade, not a loop. + WeatherKind::DustStorm | WeatherKind::Clear => None, + } +} + +/// Campfire crackle loop (placed camps / campfire props). +pub const CAMPFIRE_LOOP: &str = "campfire_crackle_loop"; + +#[cfg(test)] +mod tests { + use super::*; + + const ASSETS: &str = "../../../client/public/successor-audio/sfx"; + + fn player() -> Option { + let manifest = std::fs::read_to_string(format!("{ASSETS}/manifest.json")).ok()?; + let mut p = SfxPlayer::new(); + if p.load(&manifest, ASSETS) == 0 { + return None; + } + Some(p) + } + + #[test] + fn ui_cue_ids_map_to_real_clips() { + let Some(mut p) = player() else { + eprintln!("skip: assets absent"); + return; + }; + clear_retrigger_floor(); + assert!(play_ui(&mut p, UiCue::PanelOpen)); + assert!(play_ui(&mut p, UiCue::ButtonTick)); + assert!(p.active_voices() >= 2); + } + + #[test] + fn all_ui_cues_map_to_real_clips() { + let Some(mut p) = player() else { + eprintln!("skip: assets absent"); + return; + }; + for cue in UiCue::ALL { + clear_retrigger_floor(); + let clip_id = cue.clip_id(); + assert!( + play_ui(&mut p, cue), + "UiCue::{cue:?} with clip_id '{clip_id}' failed to play" + ); + } + } + + #[test] + fn ui_cue_retrigger_floor_suppresses_bursts() { + let Some(mut p) = player() else { + return; + }; + clear_retrigger_floor(); + // First trigger plays. + assert!(play_ui_at(&mut p, UiCue::ButtonTick, 1_000)); + // An immediate duplicate is suppressed without consulting std::time, + // which is unavailable in shipped wasm32-unknown-unknown builds. + assert!(!play_ui_at(&mut p, UiCue::ButtonTick, 1_000)); + // Distinct cue variants retain independent clocks. + assert!(play_ui_at(&mut p, UiCue::ItemTransfer, 1_000)); + // The same cue becomes eligible exactly at the retrigger boundary. + assert!(play_ui_at( + &mut p, + UiCue::ButtonTick, + 1_000 + UI_CUE_RETRIGGER_FLOOR_MS, + )); + } + + #[test] + fn combat_event_fires_weapon_and_impact() { + let Some(mut p) = player() else { + return; + }; + p.set_listener(Point { x: 0.0, y: 0.0 }); + let ev = CombatEvent { + id: 1, + tick: 0, + shooter_actor_id: "a".into(), + target_actor_id: "b".into(), + origin: Some([0.0, 0.5]), + hit_point: Some([1.0, 0.5]), + damage: 12.0, + outcome: CombatOutcome::Blood, + killed: false, + downed: false, + ranged: true, + weapon: WeaponVisual::Slugthrower, + }; + play_combat(&mut p, &ev, [0.0, 1.3, 0.5], [1.0, 1.0, 0.5]); + assert!(p.active_voices() >= 1, "voices={}", p.active_voices()); + } + #[test] + fn footstep_round_robins_by_surface() { + assert_eq!(footstep_id(0, false), "footstep_grass_01"); + assert_eq!(footstep_id(8, false), "footstep_grass_01"); + assert_eq!(footstep_id(2, true), "footstep_tile_03"); + } + + #[test] + fn impact_id_by_outcome() { + assert_eq!(impact_id(CombatOutcome::Blood), Some("body_hit_1")); + assert_eq!(impact_id(CombatOutcome::Spark), Some("projectile_hit")); + assert_eq!(impact_id(CombatOutcome::Deflect), Some("ricochet_ping")); + assert_eq!(impact_id(CombatOutcome::Dodge), None, "dodge is silent"); + } + + #[test] + fn ambience_tables_never_panic_and_stay_in_family() { + for roll in 0..16 { + assert!(ambience_one_shot(Biome::Desert, true, roll).starts_with("amb_")); + assert!(ambience_one_shot(Biome::Forest, true, roll).starts_with("amb_")); + assert!(ambience_one_shot(Biome::Desert, false, roll).starts_with("amb_night")); + } + } + + #[test] + fn open_desert_score_tracks_day_night_and_stable_combat_rotation() { + assert_eq!( + open_desert_music_id(true, false, 0), + "music_desert_day_dust_silent_world_loop" + ); + assert_eq!( + open_desert_music_id(false, false, 0), + "music_desert_night_sleeping_city_loop" + ); + assert_eq!( + open_desert_music_id(true, true, 0), + "music_combat_sandstorm_run_loop" + ); + assert_eq!( + open_desert_music_id(false, true, 1), + "music_combat_red_dunes_loop" + ); + } + + #[test] + fn open_desert_score_ids_map_to_real_loop_clips() { + let Some(mut p) = player() else { + return; + }; + for (index, id) in [ + open_desert_music_id(true, false, 0), + open_desert_music_id(false, false, 0), + open_desert_music_id(true, true, 0), + open_desert_music_id(true, true, 1), + SETTLEMENT_LOOP, + ] + .into_iter() + .enumerate() + { + let key = 0x4155_4400 + index as u32; + assert!(p.play_loop(id, key, None, 1.0), "missing loop {id}"); + p.stop_loop(key); + } + } +} diff --git a/client-rust/source/app/src/audio/wav.rs b/client-rust/source/app/src/audio/wav.rs new file mode 100644 index 00000000..f0ef6863 --- /dev/null +++ b/client-rust/source/app/src/audio/wav.rs @@ -0,0 +1,109 @@ +//! Audio output sink: renders the mixer to a 16-bit stereo WAV file. This is +//! the deterministic, device-free output path used to verify the whole audio +//! pipeline (manifest → MP3 decode → mixer → interleaved stereo). The live +//! CoreAudio device sink (`platform::audio`) consumes the same mixer blocks. + +use super::{SfxPlayer, OUT_RATE}; + +/// Render `seconds` of the player's current mix to a 16-bit stereo WAV at `path`. +/// Pulls the mixer in fixed blocks (no per-block heap growth after warmup). +pub fn render_to_wav(player: &mut SfxPlayer, seconds: f32, path: &str) -> std::io::Result { + let total_frames = (seconds * OUT_RATE as f32) as usize; + let block = 1024usize; + let mut buf = vec![0.0f32; block * 2]; + let mut pcm16: Vec = Vec::with_capacity(total_frames * 4); + let mut done = 0usize; + while done < total_frames { + let n = block.min(total_frames - done); + let slice = &mut buf[..n * 2]; + player.render(slice); + for &s in slice.iter() { + let v = (s.clamp(-1.0, 1.0) * 32767.0) as i16; + pcm16.extend_from_slice(&v.to_le_bytes()); + } + done += n; + } + let bytes = write_wav_bytes(&pcm16, OUT_RATE, 2); + std::fs::write(path, &bytes)?; + Ok(total_frames) +} + +/// Wrap interleaved 16-bit LE PCM in a canonical RIFF/WAVE container. +pub fn write_wav_bytes(pcm16: &[u8], sample_rate: u32, channels: u16) -> Vec { + let byte_rate = sample_rate * channels as u32 * 2; + let block_align = channels * 2; + let data_len = pcm16.len() as u32; + let mut out = Vec::with_capacity(44 + pcm16.len()); + out.extend_from_slice(b"RIFF"); + out.extend_from_slice(&(36 + data_len).to_le_bytes()); + out.extend_from_slice(b"WAVE"); + out.extend_from_slice(b"fmt "); + out.extend_from_slice(&16u32.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); // PCM + out.extend_from_slice(&channels.to_le_bytes()); + out.extend_from_slice(&sample_rate.to_le_bytes()); + out.extend_from_slice(&byte_rate.to_le_bytes()); + out.extend_from_slice(&block_align.to_le_bytes()); + out.extend_from_slice(&16u16.to_le_bytes()); // bits per sample + out.extend_from_slice(b"data"); + out.extend_from_slice(&data_len.to_le_bytes()); + out.extend_from_slice(pcm16); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use successor_engine_core::audio::Point; + + const ASSETS: &str = "../../../client/public/successor-audio/sfx"; + + #[test] + fn renders_triggered_sfx_to_a_nonsilent_wav() { + let Ok(manifest) = std::fs::read_to_string(format!("{ASSETS}/manifest.json")) else { + eprintln!("skip: assets absent"); + return; + }; + let mut p = SfxPlayer::new(); + if p.load(&manifest, ASSETS) == 0 { + eprintln!("skip: no clips decoded"); + return; + } + p.set_listener(Point { x: 0.0, y: 0.0 }); + p.play_ui("ui_panel_open"); + p.play_at( + "slugthrower_fire", + Point { x: 1.0, y: 1.0 }, + Default::default(), + ); + let out = std::env::temp_dir().join("successor_sfx_test.wav"); + let path = out.to_string_lossy().to_string(); + let frames = render_to_wav(&mut p, 0.5, &path).expect("render"); + assert_eq!(frames, (0.5 * OUT_RATE as f32) as usize); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(&bytes[0..4], b"RIFF"); + assert_eq!(&bytes[8..12], b"WAVE"); + // Non-silent: some sample past the 44-byte header is non-zero. + let nonzero = bytes[44..].iter().any(|&b| b != 0); + assert!(nonzero, "rendered WAV carries signal"); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn wav_header_is_well_formed() { + let pcm = vec![0u8; 400]; + let w = write_wav_bytes(&pcm, 44_100, 2); + assert_eq!(&w[0..4], b"RIFF"); + assert_eq!( + u32::from_le_bytes([w[40], w[41], w[42], w[43]]), + 400, + "data chunk length" + ); + assert_eq!(u16::from_le_bytes([w[22], w[23]]), 2, "stereo"); + assert_eq!( + u32::from_le_bytes([w[24], w[25], w[26], w[27]]), + 44_100, + "sample rate" + ); + } +} diff --git a/client-rust/source/app/src/bin/successor-control.rs b/client-rust/source/app/src/bin/successor-control.rs new file mode 100644 index 00000000..8922cc5e --- /dev/null +++ b/client-rust/source/app/src/bin/successor-control.rs @@ -0,0 +1,187 @@ +//! Pipeable developer control client for the native Successor client. + +use std::fs::File; +use std::io::{self, BufRead, BufReader, IsTerminal, Write}; +use std::net::{Ipv4Addr, SocketAddrV4, TcpStream}; +use std::path::PathBuf; +use std::time::Duration; + +const DEFAULT_PORT: u16 = successor_platform::DEFAULT_CONTROL_PORT; + +fn main() { + if let Err(error) = run() { + eprintln!("successor-control: {error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let args: Vec = std::env::args().skip(1).collect(); + let mut port = std::env::var("SUCCESSOR_CONTROL_PORT") + .ok() + .map(|value| parse_port(&value)) + .transpose()? + .unwrap_or(DEFAULT_PORT); + let mut file: Option = None; + let mut command_start = 0; + + while command_start < args.len() { + match args[command_start].as_str() { + "--port" => { + let value = args + .get(command_start + 1) + .ok_or_else(|| "--port requires a number".to_string())?; + port = parse_port(value)?; + command_start += 2; + } + "--file" => { + let value = args + .get(command_start + 1) + .ok_or_else(|| "--file requires a path".to_string())?; + file = Some(PathBuf::from(value)); + command_start += 2; + } + "--help" | "-h" => { + print_usage(); + return Ok(()); + } + _ => break, + } + } + + if file.is_some() && command_start < args.len() { + return Err("commands cannot be combined with --file".into()); + } + if file.is_none() && command_start == args.len() && io::stdin().is_terminal() { + print_usage(); + return Err("provide a command, --file, or piped stdin".into()); + } + + let address = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port); + let mut writer = TcpStream::connect_timeout(&address.into(), Duration::from_secs(3)) + .map_err(|error| format!("cannot connect to 127.0.0.1:{port}: {error}"))?; + writer + .set_nodelay(true) + .map_err(|error| format!("cannot configure control socket: {error}"))?; + writer + .set_read_timeout(Some(Duration::from_secs(30))) + .map_err(|error| format!("cannot configure response timeout: {error}"))?; + let reader_stream = writer + .try_clone() + .map_err(|error| format!("cannot clone control socket: {error}"))?; + let mut reader = BufReader::new(reader_stream); + let mut sequence = 1u64; + let mut failed = false; + + if let Some(path) = file { + let source = File::open(&path) + .map_err(|error| format!("cannot open command file {}: {error}", path.display()))?; + drive_lines( + BufReader::new(source), + &mut writer, + &mut reader, + &mut sequence, + &mut failed, + )?; + } else if command_start < args.len() { + let command = args[command_start..].join(" "); + drive_command( + &command, + &mut writer, + &mut reader, + &mut sequence, + &mut failed, + )?; + } else { + let stdin = io::stdin(); + drive_lines( + stdin.lock(), + &mut writer, + &mut reader, + &mut sequence, + &mut failed, + )?; + } + + if failed { + Err("one or more commands failed".into()) + } else { + Ok(()) + } +} + +fn drive_lines( + source: R, + writer: &mut TcpStream, + reader: &mut BufReader, + sequence: &mut u64, + failed: &mut bool, +) -> Result<(), String> { + for (line_number, line) in source.lines().enumerate() { + let line = + line.map_err(|error| format!("cannot read line {}: {error}", line_number + 1))?; + let command = line.trim(); + if command.is_empty() || command.starts_with('#') { + continue; + } + drive_command(command, writer, reader, sequence, failed) + .map_err(|error| format!("line {}: {error}", line_number + 1))?; + } + Ok(()) +} + +fn drive_command( + command: &str, + writer: &mut TcpStream, + reader: &mut BufReader, + sequence: &mut u64, + failed: &mut bool, +) -> Result<(), String> { + if let Some(value) = command.strip_prefix("wait ") { + let milliseconds = value + .trim() + .parse::() + .map_err(|_| "wait requires milliseconds".to_string())?; + std::thread::sleep(Duration::from_millis(milliseconds)); + println!("{{\"ok\":true,\"wait_ms\":{milliseconds}}}"); + return Ok(()); + } + if command == "wait" { + return Err("wait requires milliseconds".into()); + } + + writeln!(writer, "{}\t{}", *sequence, command) + .map_err(|error| format!("cannot send command: {error}"))?; + writer + .flush() + .map_err(|error| format!("cannot flush command: {error}"))?; + + let mut response = String::new(); + let bytes = reader + .read_line(&mut response) + .map_err(|error| format!("cannot read response: {error}"))?; + if bytes == 0 { + return Err("control server closed before responding".into()); + } + print!("{response}"); + io::stdout() + .flush() + .map_err(|error| format!("cannot flush output: {error}"))?; + if response.contains("\"ok\":false") { + *failed = true; + } + *sequence = sequence.saturating_add(1); + Ok(()) +} + +fn parse_port(value: &str) -> Result { + value + .parse::() + .map_err(|_| format!("invalid port: {value}")) +} + +fn print_usage() { + eprintln!( + "usage:\n successor-control [--port N] \n successor-control [--port N] --file commands.txt\n printf 'key down w\\nwait 500\\nkey up w\\nscreenshot /tmp/game.bmp\\n' | successor-control [--port N]\n\nserver commands:\n key \n mouse move \n mouse \n text \n scroll \n screenshot \n record start | record stop\n status | quit\n\nclient-only command:\n wait " + ); +} diff --git a/client-rust/source/app/src/demo.rs b/client-rust/source/app/src/demo.rs new file mode 100644 index 00000000..1d0d9030 --- /dev/null +++ b/client-rust/source/app/src/demo.rs @@ -0,0 +1,431 @@ +//! The `parity-basic` demo scene and the headless stats/alloc runner. +//! +//! The scene is the standard benchmark every budget number refers to: a +//! 64x64 grid of opaque cubes plus 128 dithered-transparent cubes, one +//! shadow-casting directional light, and three cameras — a main perspective +//! (screen), an orthographic minimap and a spinning portrait, both rendered to +//! 256x256 textures and composited into the corners — plus a HUD text line. +//! +//! `run` is backend-generic: the headless gates pass `NullGpu` (full CPU path, +//! no window), while the windowed path passes the platform `GlGpu`. + +use successor_engine_core::ecs::WorldOps; +use successor_engine_core::math::{vec3, Quat, Vec2, Vec3}; +use successor_engine_render::components::{ + CamTarget, Camera, CompositeQuad, DirectionalLight, MaterialId, MeshId, MeshRenderer, + Projection, RectNorm, TextOverlay, Transform, +}; +use successor_engine_render::gpu::{ClearSpec, Filter, Gpu, RenderTargetDesc, RenderTargetId}; +use successor_engine_render::primitives; +use successor_engine_render::renderer::Renderer; + +use crate::GameWorld; + +pub const SCREEN_W: u32 = 1280; +pub const SCREEN_H: u32 = 720; +const OPAQUE_SIDE: i32 = 64; // 64*64 = 4096 opaque cubes +const TRANSPARENT_COUNT: i32 = 128; + +pub struct Scene { + pub world: GameWorld, + pub renderer: Renderer, + portrait_cam: successor_engine_core::ecs::Entity, + /// Spinning portrait target, reused as the UI demo's live paperdoll. + portrait_target: RenderTargetId, + /// Composite quad that places the paperdoll inside a UI pane. + paperdoll_quad: successor_engine_core::ecs::Entity, + transparent: Vec, +} + +#[derive(Clone, Copy, Debug)] +pub struct Stats { + pub frame_p50_ms: f64, + pub frame_p99_ms: f64, + pub peak_rss_bytes: u64, + pub frame_allocs_steady: u64, +} + +impl Stats { + pub fn to_json(&self) -> String { + format!( + "{{\n \"frame_p50_ms\": {:.4},\n \"frame_p99_ms\": {:.4},\n \"peak_rss_bytes\": {},\n \"frame_allocs_steady\": {}\n}}\n", + self.frame_p50_ms, self.frame_p99_ms, self.peak_rss_bytes, self.frame_allocs_steady + ) + } +} + +/// Build the standard performance/reference scene, including its corner +/// render-target composites and diagnostic text overlay. +pub fn build_scene(gpu: &mut G) -> Scene { + build_scene_with_fixture_overlays(gpu, true) +} + +/// Build the same world behind an interactive UI capture, without synthetic +/// portrait/minimap cards or diagnostic text colliding with product chrome. +pub fn build_ui_scene(gpu: &mut G) -> Scene { + build_scene_with_fixture_overlays(gpu, false) +} + +fn build_scene_with_fixture_overlays(gpu: &mut G, fixture_overlays: bool) -> Scene { + let mut renderer = crate::configured_renderer(gpu).expect("renderer initialization failed"); + let mut world = GameWorld::new(); + renderer.gi_set_focus([31.5, 0.0, 31.5]); + + // Meshes + materials. + let (cv, ci) = primitives::cube(); + let cube: MeshId = renderer.upload_mesh(gpu, &cv, &ci); + let (pv, pi) = primitives::plane(160.0); + let plane: MeshId = renderer.upload_mesh(gpu, &pv, &pi); + let (kv, ki) = primitives::capsule(0.4, 1.8, 12, 6); + let capsule: MeshId = renderer.upload_mesh(gpu, &kv, &ki); + let ground: MaterialId = + renderer.add_material_desc(successor_engine_render::renderer::MaterialDesc { + base_color: [0.35, 0.30, 0.20, 1.0], + blend: false, + ..successor_engine_render::renderer::MaterialDesc::default() + }); + let opaque: MaterialId = + renderer.add_material_desc(successor_engine_render::renderer::MaterialDesc { + base_color: [0.72, 0.58, 0.36, 1.0], + blend: false, + ..successor_engine_render::renderer::MaterialDesc::default() + }); + let glass: MaterialId = + renderer.add_material_desc(successor_engine_render::renderer::MaterialDesc { + base_color: [0.30, 0.55, 0.85, 0.5], + blend: ([0.30, 0.55, 0.85, 0.5])[3] < 1.0, + ..successor_engine_render::renderer::MaterialDesc::default() + }); // alpha<1 -> dithered + let hero: MaterialId = + renderer.add_material_desc(successor_engine_render::renderer::MaterialDesc { + base_color: [0.85, 0.85, 0.90, 1.0], + blend: false, + ..successor_engine_render::renderer::MaterialDesc::default() + }); + + // Ground plane (visible in main + minimap). + let g = world.spawn(); + world.set_component( + g, + Transform { + pos: vec3(31.5, 0.0, 31.5), + rot: Quat::IDENTITY, + scale: Vec3::ONE, + }, + ); + world.set_component( + g, + MeshRenderer { + mesh: plane, + material: ground, + viewport_mask: 0b011, + ..Default::default() + }, + ); + + // 64x64 opaque cubes (main + minimap). + for x in 0..OPAQUE_SIDE { + for z in 0..OPAQUE_SIDE { + let e = world.spawn(); + world.set_component( + e, + Transform { + pos: vec3(x as f32, 0.5, z as f32), + rot: Quat::IDENTITY, + scale: vec3(0.9, 0.9, 0.9), + }, + ); + world.set_component( + e, + MeshRenderer { + mesh: cube, + material: opaque, + viewport_mask: 0b011, + ..Default::default() + }, + ); + } + } + + // 128 dithered-transparent cubes floating above (main only), animated. + let mut transparent = Vec::with_capacity(TRANSPARENT_COUNT as usize); + for i in 0..TRANSPARENT_COUNT { + let e = world.spawn(); + let fx = (i % 16) as f32 * 4.0; + let fz = (i / 16) as f32 * 4.0; + world.set_component( + e, + Transform { + pos: vec3(fx, 3.0, fz), + rot: Quat::IDENTITY, + scale: Vec3::ONE, + }, + ); + world.set_component( + e, + MeshRenderer { + mesh: cube, + material: glass, + viewport_mask: 0b001, + ..Default::default() + }, + ); + transparent.push(e); + } + + // Hero capsule visible in ALL viewports (main + minimap + portrait). + let hero_e = world.spawn(); + world.set_component( + hero_e, + Transform { + pos: vec3(31.5, 0.9, 31.5), + rot: Quat::IDENTITY, + scale: Vec3::ONE, + }, + ); + world.set_component( + hero_e, + MeshRenderer { + mesh: capsule, + material: hero, + viewport_mask: 0b111, + ..Default::default() + }, + ); + + // Shadow-casting sun. + let sun = world.spawn(); + world.set_component( + sun, + DirectionalLight { + dir: vec3(-0.5, -1.0, -0.35), + color: [1.0, 0.97, 0.9], + cast_shadows: true, + }, + ); + + // Offscreen targets for minimap + portrait. + let rt_minimap = make_rt(gpu); + let rt_portrait = make_rt(gpu); + + // Cameras (render order: minimap -2, portrait -1, main 0). + let main = world.spawn(); + world.set_component( + main, + Camera { + viewport_id: 0, + order: 0, + projection: Projection::Perspective { + fovy: 1.05, + near: 0.1, + far: 400.0, + }, + target: CamTarget::Screen(RectNorm::FULL), + clear: ClearSpec { + color: Some([0.05, 0.06, 0.08, 1.0]), + depth: Some(1.0), + }, + eye: vec3(31.5, 40.0, 92.0), + look_at: vec3(31.5, 0.0, 31.5), + up: Vec3::Y, + }, + ); + let minimap = world.spawn(); + world.set_component( + minimap, + Camera { + viewport_id: 1, + order: -2, + projection: Projection::Ortho { + half_height: 40.0, + near: 0.1, + far: 200.0, + }, + target: CamTarget::Texture(rt_minimap), + clear: ClearSpec { + color: Some([0.02, 0.03, 0.04, 1.0]), + depth: Some(1.0), + }, + eye: vec3(31.5, 120.0, 31.5), + look_at: vec3(31.5, 0.0, 31.5), + up: vec3(0.0, 0.0, -1.0), + }, + ); + let portrait = world.spawn(); + world.set_component( + portrait, + Camera { + viewport_id: 2, + order: -1, + projection: Projection::Perspective { + fovy: 0.8, + near: 0.05, + far: 20.0, + }, + target: CamTarget::Texture(rt_portrait), + clear: ClearSpec { + color: Some([0.10, 0.10, 0.12, 1.0]), + depth: Some(1.0), + }, + eye: vec3(31.5, 1.4, 34.0), + look_at: vec3(31.5, 0.9, 31.5), + up: Vec3::Y, + }, + ); + + if fixture_overlays { + // Composite the two RTs into corners for the renderer performance gate. + let q1 = world.spawn(); + world.set_component( + q1, + CompositeQuad { + source: rt_minimap, + rect: RectNorm { + x: 0.75, + y: 0.74, + w: 0.24, + h: 0.24, + }, + order: 0, + }, + ); + let q2 = world.spawn(); + world.set_component( + q2, + CompositeQuad { + source: rt_portrait, + rect: RectNorm { + x: 0.01, + y: 0.01, + w: 0.20, + h: 0.20, + }, + order: 1, + }, + ); + + let hud = world.spawn(); + world.set_component( + hud, + TextOverlay::new( + "successor rust client", + Vec2 { x: 0.02, y: 0.05 }, + [220, 230, 240, 255], + ), + ); + } + + let paperdoll_quad = world.spawn(); + + Scene { + world, + renderer, + portrait_cam: portrait, + portrait_target: rt_portrait, + paperdoll_quad, + transparent, + } +} + +fn make_rt(gpu: &mut G) -> RenderTargetId { + gpu.create_render_target(&RenderTargetDesc { + width: 256, + height: 256, + color: true, + depth: true, + filter: Filter::Linear, + }) +} + +impl Scene { + /// Advance one frame of animation (spin transparents, orbit portrait cam). + pub fn animate(&mut self, frame: u64) { + let t = frame as f32 * 0.016; + let yaw = Quat::from_yaw(t); + for i in 0..self.transparent.len() { + let e = self.transparent[i]; + if let Some(tr) = self.world.get_component::(e) { + tr.rot = yaw; + tr.pos.y = 3.0 + (t + i as f32).sin() * 0.5; + } + } + if let Some(cam) = self.world.get_component::(self.portrait_cam) { + let r = 3.0; + cam.eye = vec3(31.5 + t.cos() * r, 1.4, 31.5 + t.sin() * r); + } + } + + /// Place the spinning portrait target inside a UI pane, in framebuffer + /// pixels. `None` removes it. The UI demo uses this so an inventory or + /// examine capture shows the same live rotating doll connected mode draws. + pub fn set_paperdoll_viewport(&mut self, rect: Option<[f32; 4]>, screen_w: f32, screen_h: f32) { + let Some([x, y, w, h]) = rect.filter(|r| r[2] > 1.0 && r[3] > 1.0) else { + self.world + .remove_component::(self.paperdoll_quad); + return; + }; + self.world.set_component( + self.paperdoll_quad, + CompositeQuad { + source: self.portrait_target, + rect: RectNorm { + x: x / screen_w, + y: 1.0 - (y + h) / screen_h, + w: w / screen_w, + h: h / screen_h, + }, + order: 0, + }, + ); + } + + pub fn render(&mut self, gpu: &mut G) { + self.renderer + .render(gpu, &mut self.world, SCREEN_W, SCREEN_H) + .expect("render failed"); + } +} + +/// Run `frames` frames headlessly through `NullGpu`, returning stats measured +/// after the 120-frame warmup. Used by `make runtime-check` / `check-allocs`. +pub fn run_headless(frames: u64) -> Stats { + use successor_engine_render::gpu::NullGpu; + let mut gpu = NullGpu::default(); + let mut scene = build_scene(&mut gpu); + + let warmup: u64 = 120; + let measured = frames.saturating_sub(warmup) as usize; + let mut times: Vec = Vec::with_capacity(measured.max(1)); + let mut max_alloc: u64 = 0; + + for f in 0..frames { + successor_engine_core::rt::alloc::reset_alloc_count(); + let t0 = std::time::Instant::now(); + scene.animate(f); + scene.render(&mut gpu); + let dt_ms = t0.elapsed().as_secs_f64() * 1000.0; + let allocs = successor_engine_core::rt::alloc::alloc_count(); + if f >= warmup { + times.push(dt_ms); + if allocs > max_alloc { + max_alloc = allocs; + } + } + } + + times.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let p = |q: f64| -> f64 { + if times.is_empty() { + 0.0 + } else { + let idx = ((times.len() as f64 - 1.0) * q).round() as usize; + times[idx] + } + }; + Stats { + frame_p50_ms: p(0.50), + frame_p99_ms: p(0.99), + peak_rss_bytes: crate::rss::peak_rss_bytes(), + frame_allocs_steady: max_alloc, + } +} diff --git a/client-rust/source/app/src/game/actions.rs b/client-rust/source/app/src/game/actions.rs new file mode 100644 index 00000000..0a65c305 --- /dev/null +++ b/client-rust/source/app/src/game/actions.rs @@ -0,0 +1,293 @@ +//! Public gameplay action path. +//! +//! UI, keyboard and pointer handlers describe intent with `GameplayAction`; this +//! module is the only place that turns an accepted intent into an authority +//! command. It deliberately has no projection mutation or local prediction. + +use successor_net::ClientCommand; + +use super::command_queue::CommandQueue; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GameplayAction { + Attack { + action_id: String, + target_actor_id: String, + }, + Peace, + CancelAbilityQueue { + queue_entry_id: Option, + }, + Reload { + weapon_id: Option, + ammo_type: Option, + }, + UseConsumable { + item_id: String, + }, + EquipWeapon { + weapon_id: Option, + }, + SetPosture { + posture: String, + }, + Revive { + target_actor_id: String, + }, + Stabilize { + target_actor_id: String, + }, + CloneRespawn { + facility_id: Option, + }, + EnterTransition { + transition_id: String, + }, + ToggleDoor { + prop_id: String, + }, + Interact { + verb: String, + target_id: String, + }, + Move { + dx: i32, + dy: i32, + facing: Option, + sprint: bool, + }, + Stop, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DisabledVerb { + pub id: &'static str, + pub reason: String, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct VerbContext { + pub has_target: bool, + pub target_alive: bool, + pub in_combat: bool, + pub can_act: bool, + pub in_range: bool, + pub has_weapon: bool, + pub can_reload: bool, + pub can_revive: bool, + pub can_transition: bool, +} + +/// The same registry drives radial menus, action browser and keyboard verbs. +/// Disabled entries are retained so the UI can present the server-derived +/// reason rather than silently dropping a click. +pub fn verbs(ctx: VerbContext) -> Vec> { + let mut out = Vec::with_capacity(8); + out.push( + if ctx.can_act && ctx.has_target && ctx.target_alive && ctx.in_range && ctx.has_weapon { + Ok("attack") + } else { + Err(DisabledVerb { + id: "attack", + reason: gate_reason(ctx, "attack"), + }) + }, + ); + out.push(if ctx.can_act { + Ok("peace") + } else { + Err(DisabledVerb { + id: "peace", + reason: "actor cannot act".into(), + }) + }); + out.push(if ctx.can_act && ctx.has_weapon && ctx.can_reload { + Ok("reload") + } else { + Err(DisabledVerb { + id: "reload", + reason: if !ctx.has_weapon { + "no weapon equipped".into() + } else { + "reload unavailable".into() + }, + }) + }); + out.push( + if ctx.can_act && ctx.has_target && ctx.in_range && ctx.can_revive { + Ok("revive") + } else { + Err(DisabledVerb { + id: "revive", + reason: "revive unavailable at target".into(), + }) + }, + ); + out.push(if ctx.can_act && ctx.can_transition { + Ok("transition") + } else { + Err(DisabledVerb { + id: "transition", + reason: "transition unavailable".into(), + }) + }); + out +} + +fn gate_reason(ctx: VerbContext, _verb: &str) -> String { + if !ctx.can_act { + return "actor cannot act".into(); + } + if !ctx.has_target { + return "no target selected".into(); + } + if !ctx.in_range { + return "target out of range".into(); + } + if !ctx.target_alive { + return "target is not alive".into(); + } + if !ctx.has_weapon { + return "no weapon equipped".into(); + } + "unavailable".into() +} + +/// Convert an accepted public action to the exact shared wire command. +pub fn command_for(action: GameplayAction) -> Option { + Some(match action { + GameplayAction::Attack { + action_id, + target_actor_id, + } => ClientCommand::QueueCombatAction { + action_id, + target_actor_id, + }, + GameplayAction::Peace => ClientCommand::Peace {}, + GameplayAction::CancelAbilityQueue { queue_entry_id } => { + ClientCommand::CancelAbilityQueue { + queue_entry_id, + scope: None, + } + } + GameplayAction::Reload { + weapon_id, + ammo_type, + } => ClientCommand::ReloadWeapon { + weapon_id: weapon_id.and_then(|v| serde_json::from_str(&format!("\"{}\"", v)).ok()), + ammo_type: ammo_type.and_then(|v| serde_json::from_str(&format!("\"{}\"", v)).ok()), + }, + GameplayAction::UseConsumable { item_id } => ClientCommand::UseConsumable { + item_id, + item_numeric_id: None, + variant_id: None, + }, + + GameplayAction::EquipWeapon { weapon_id } => ClientCommand::SetEquippedWeapon { + weapon_id: weapon_id.and_then(|v| serde_json::from_str(&format!("\"{}\"", v)).ok()), + weapon_item_id: None, + weapon_variant_id: None, + }, + GameplayAction::SetPosture { posture } => ClientCommand::SetPosture { posture }, + GameplayAction::Revive { target_actor_id } + | GameplayAction::Stabilize { target_actor_id } => { + ClientCommand::ReviveActor { target_actor_id } + } + GameplayAction::CloneRespawn { facility_id } => ClientCommand::CloneRespawn { facility_id }, + GameplayAction::EnterTransition { transition_id } => { + ClientCommand::EnterTransition { transition_id } + } + GameplayAction::ToggleDoor { prop_id } => ClientCommand::ToggleDoor { prop_id }, + GameplayAction::Interact { verb, target_id } => { + // Public verbs share the authority action queue; terminal/door/ + // transition verbs are normalized below. + return interaction_command(&verb, target_id); + } + GameplayAction::Move { + dx, + dy, + facing, + sprint, + } => ClientCommand::SetMoveIntent { + dx, + dy, + facing, + sprint, + }, + GameplayAction::Stop => ClientCommand::SetMoveIntent { + dx: 0, + dy: 0, + facing: None, + sprint: false, + }, + }) +} + +/// Enqueue an accepted action; callers only receive the id and never mutate +/// authority-owned state locally. Receipts settle this id through the queue. +pub fn enqueue_action( + queue: &mut CommandQueue, + action: GameplayAction, + issued_at_tick: u64, +) -> Option { + command_for(action).map(|command| queue.enqueue(command, issued_at_tick)) +} + +fn interaction_command(verb: &str, target_id: String) -> Option { + match verb { + "transition" => Some(ClientCommand::EnterTransition { + transition_id: target_id, + }), + "door" => Some(ClientCommand::ToggleDoor { prop_id: target_id }), + "revive" | "stabilize" => Some(ClientCommand::ReviveActor { + target_actor_id: target_id, + }), + _ => Some(ClientCommand::QueueCombatAction { + action_id: verb.to_owned(), + target_actor_id: target_id, + }), + } +} + +/// Queue result used by windows and HUD. Rejections are retained by the host +/// for presentation; no authority-owned value is changed here. +#[derive(Clone, Debug, PartialEq)] +pub enum DispatchOutcome { + Queued(u64), + Local(crate::windows::WindowLocalAction), + Rejected(String), +} + +pub fn enqueue_window_action( + queue: &mut CommandQueue, + action: crate::windows::WindowAction, + issued_at_tick: u64, +) -> DispatchOutcome { + match action.resolve() { + crate::windows::WindowActionResult::Command(command) => { + DispatchOutcome::Queued(queue.enqueue(command, issued_at_tick)) + } + crate::windows::WindowActionResult::Local(local) => DispatchOutcome::Local(local), + crate::windows::WindowActionResult::Rejected(reason) => DispatchOutcome::Rejected(reason), + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn disabled_verbs_keep_reason() { + let v = verbs(VerbContext::default()); + assert!(matches!(&v[0], Err(e) if e.reason == "actor cannot act")); + } + #[test] + fn attack_maps_without_projection_mutation() { + assert!(matches!( + command_for(GameplayAction::Attack { + action_id: "fire".into(), + target_actor_id: "a".into() + }), + Some(ClientCommand::QueueCombatAction { .. }) + )); + } +} diff --git a/client-rust/source/app/src/game/authority.rs b/client-rust/source/app/src/game/authority.rs new file mode 100644 index 00000000..84fb2329 --- /dev/null +++ b/client-rust/source/app/src/game/authority.rs @@ -0,0 +1,1162 @@ +//! App-side authority store — mirrors `PlayState.serverAuthority` and applies +//! snapshots/deltas/receipts with the semantics ported from +//! `gameAuthoritySystem.ts`: +//! - snapshot fully replaces actors + present sections; +//! - delta merges actor patches / compact moves, honours `actorRemovals`, +//! and uses the present-key-replaces / absent-key-retains rule for sections; +//! - actor updates are gated on `lifecycleSeq` (stale generations ignored); +//! - `respawning` actors are excluded from the render set; +//! - receipts are de-duplicated over a bounded window (512). +//! +//! Values for the optional sections are retained as `serde_json::Value` (the +//! window layer decodes what it needs); the actor and counter data are typed. + +use std::collections::{HashMap, HashSet, VecDeque}; + +use serde_json::Value; +use successor_client_proto::packets::{ + GameActorPatch, GameActorSnapshot, GameActorVitals, GameCommandReceipt, GameCounters, + GameShardDelta, GameShardSnapshot, +}; + +const RECEIPT_DEDUPE_MAX: usize = 512; +const MOVE_QUANTIZATION: f32 = 100.0; +fn compact_bool(value: &Value) -> Option { + value.as_bool().or_else(|| match value.as_i64() { + Some(0) => Some(false), + Some(1) => Some(true), + _ => None, + }) +} + +fn compact_vitals(value: &Value) -> Option { + let values = value.as_array()?; + if values.len() != 3 { + return None; + } + let health = values[0].as_f64()? as f32; + let action = values[1].as_f64()? as f32; + let spirit = values[2].as_f64()? as f32; + (health.is_finite() && action.is_finite() && spirit.is_finite()).then_some(GameActorVitals { + health, + action, + spirit, + }) +} + +#[derive(Default, Clone)] +pub struct AuthorityStore { + pub tick: u64, + pub player_actor_id: String, + pub actors: HashMap, + pub inventory: Vec, + pub reservations: Vec, + pub bank: Option, + pub building: Option, + pub groups: Option, + pub guilds: Option, + pub duels: Option, + pub prop_states: HashMap, + pub world_clock: Option, + pub weather: Vec, + pub counters: Option, + pub source_state_hash: Option, + net_refs: HashMap, + combat_events: VecDeque, + receipt_set: HashSet, + receipt_seen: VecDeque, + pub player_corpses: Vec, + pub resource_spawns: Vec, + pub placed_extractors: Vec, + pub placed_camps: Vec, + pub placed_parcels: Vec, + pub farm_plots: Vec, + pub drafted_schematics: Vec, + pub craft_session: Option, + pub splice_session: Option, + pub trade_session: Option, + pub survey_results: Vec, + /// Latest command the authority refused, with its reason code. Cleared as + /// soon as the HUD has shown it. + pub command_rejection: Option, + pub genome_scans: Vec, + pub duel_outcomes: Vec, + pub bug_report_result: Option, + pub dialogue_deliveries: Vec, + pub ability_queue: Option, + pub last_receipt: Option, +} + +impl AuthorityStore { + pub fn new() -> Self { + Self::default() + } + + pub fn apply_snapshot(&mut self, snap: &GameShardSnapshot) { + self.tick = snap.tick; + self.player_actor_id = snap.player_actor_id.clone(); + self.actors = snap.actors.clone(); + self.inventory = snap.inventory.clone(); + self.reservations = snap.reservations.clone(); + self.player_corpses = snap.player_corpses.clone(); + self.resource_spawns = snap.resource_spawns.clone(); + self.placed_extractors = snap.placed_extractors.clone(); + self.placed_camps = snap.placed_camps.clone(); + self.placed_parcels = snap.placed_parcels.clone(); + self.farm_plots = snap.farm_plots.clone(); + self.drafted_schematics = snap.drafted_schematics.clone(); + self.craft_session = snap.craft_session.clone(); + self.ability_queue = snap.ability_queue.clone(); + self.bank = snap.bank.clone(); + self.building = snap.building.clone(); + self.groups = snap.groups.clone(); + self.guilds = snap.guilds.clone(); + self.duels = snap.duels.clone(); + self.prop_states = snap.prop_states.clone(); + self.weather = snap.weather.clone(); + self.world_clock = snap.world_clock.clone(); + self.counters = snap.counters; + self.source_state_hash = snap.source_state_hash.clone(); + // A complete snapshot replaces the prior projection, including + // transient room/event sections that are not part of the snapshot. + self.splice_session = None; + self.trade_session = None; + self.survey_results.clear(); + self.genome_scans.clear(); + self.duel_outcomes.clear(); + self.bug_report_result = None; + self.dialogue_deliveries.clear(); + self.combat_events.clear(); + self.net_refs.clear(); + } + + fn clone_for_transaction(&self) -> Self { + self.clone() + } + + /// Apply a delta transactionally: malformed compact references or + /// non-finite coordinates leave the previously accepted projection intact. + pub fn apply_delta(&mut self, delta: &GameShardDelta) { + let mut next = self.clone_for_transaction(); + if !delta.schema.is_empty() && !delta.schema.contains("delta") { + return; + } + next.tick = delta.tick; + for r in &delta.actor_refs { + next.net_refs.insert(r.0, r.1.clone()); + } + for (id, actor) in &delta.actors { + if !actor.x.is_finite() + || !actor.y.is_finite() + || next.actor_is_stale(id, actor.lifecycle_seq) + { + return; + } + next.actors.insert(id.clone(), actor.clone()); + } + for row in &delta.compact_actors { + let raw = Value::Array(row.0.clone()); + let actor = match compact_snapshot(&raw) { + Ok(actor) => actor, + Err(_) => return, + }; + if !actor.x.is_finite() || !actor.y.is_finite() { + return; + } + next.actors.insert(actor.id.clone(), actor); + } + for row in &delta.compact_actor_patches { + let raw = Value::Array(row.0.clone()); + let (id, patch) = match compact_patch(&raw) { + Ok(value) => value, + Err(_) => return, + }; + if !next.apply_compact_patch(&id, &patch) { + return; + } + } + for id in &delta.actor_removals { + next.actors.remove(id); + } + for (id, patch) in &delta.actor_patches { + if !next.apply_patch(id, patch) { + return; + } + } + for m in &delta.compact_actor_moves { + let Some(id) = next.net_refs.get(&m.0).cloned() else { + return; + }; + let Some(actor) = next.actors.get_mut(&id) else { + return; + }; + let x = m.1 as f32 / MOVE_QUANTIZATION; + let y = m.2 as f32 / MOVE_QUANTIZATION; + if !x.is_finite() || !y.is_finite() { + return; + } + actor.x = x; + actor.y = y; + actor.direction = direction_from_compact(m.3); + } + if let Some(v) = &delta.inventory { + next.inventory = v.clone(); + } + if let Some(v) = &delta.reservations { + next.reservations = v.clone(); + } + if let Some(v) = &delta.bank { + next.bank = v.clone(); + } + if let Some(v) = &delta.building { + next.building = v.clone(); + } + if let Some(v) = &delta.player_corpses { + next.player_corpses = v.clone(); + } + if let Some(v) = &delta.resource_spawns { + next.resource_spawns = v.clone(); + } + if let Some(v) = &delta.placed_extractors { + next.placed_extractors = v.clone(); + } + if let Some(v) = &delta.placed_camps { + next.placed_camps = v.clone(); + } + if let Some(v) = &delta.placed_parcels { + next.placed_parcels = v.clone(); + } + if let Some(v) = &delta.farm_plots { + next.farm_plots = v.clone(); + } + if let Some(v) = &delta.drafted_schematics { + next.drafted_schematics = v.clone(); + } + if let Some(v) = &delta.craft_session { + next.craft_session = v.clone(); + } + if let Some(v) = &delta.ability_queue { + next.ability_queue = v.clone(); + } + if let Some(v) = &delta.groups { + next.groups = v.clone(); + } + if let Some(v) = &delta.guilds { + next.guilds = v.clone(); + } + if let Some(v) = &delta.duels { + next.duels = v.clone(); + } + if let Some(v) = &delta.prop_states { + next.prop_states = v.clone(); + } + if let Some(v) = &delta.world_clock { + next.world_clock = v.clone(); + } + if let Some(v) = &delta.weather { + next.weather = v.clone(); + } + if let Some(v) = &delta.dialogue_deliveries { + next.dialogue_deliveries = v.clone(); + } + if let Some(v) = &delta.source_state_hash { + next.source_state_hash = Some(v.clone()); + } + if let Some(v) = &delta.counters { + next.counters = Some(*v); + } + *self = next; + } + + fn apply_compact_patch(&mut self, id: &str, row: &Value) -> bool { + fn update_string(value: &Value, target: &mut String) -> bool { + if value.is_null() { + true + } else if let Some(value) = value.as_str() { + target.clear(); + target.push_str(value); + true + } else { + false + } + } + + fn update_optional_string(value: &Value, target: &mut Option) -> bool { + if value.is_null() { + true + } else if let Some(value) = value.as_str() { + *target = Some(value.to_owned()); + true + } else { + false + } + } + + // Compact nullable fields use `false` for "unchanged" and `null` for + // "clear". `GameActorPatch::Option` cannot represent both states. + fn update_nullable_string(value: &Value, target: &mut Option) -> bool { + if value == &Value::Bool(false) { + true + } else if value.is_null() { + *target = None; + true + } else if let Some(value) = value.as_str() { + *target = Some(value.to_owned()); + true + } else { + false + } + } + + fn update_optional_i64(value: &Value, target: &mut Option) -> bool { + if value.is_null() { + true + } else if let Some(value) = value.as_i64() { + *target = Some(value); + true + } else { + false + } + } + + fn update_optional_bool(value: &Value, target: &mut Option) -> bool { + if value.is_null() { + true + } else if let Some(value) = compact_bool(value) { + *target = Some(value); + true + } else { + false + } + } + + let Some(values) = row.as_array() else { + return false; + }; + if values.len() != 52 || values[0].as_str() != Some(id) { + return false; + } + if let Some(sequence) = values[6].as_i64() { + if self.actor_is_stale(id, sequence) { + return true; + } + } else if !values[6].is_null() { + return false; + } + let Some(actor) = self.actors.get_mut(id) else { + return false; + }; + + if !update_string(&values[1], &mut actor.area_id) { + return false; + } + if !values[2].is_null() { + let Some(value) = values[2].as_f64() else { + return false; + }; + if !value.is_finite() { + return false; + } + actor.x = value as f32; + } + if !values[3].is_null() { + let Some(value) = values[3].as_f64() else { + return false; + }; + if !value.is_finite() { + return false; + } + actor.y = value as f32; + } + if !values[4].is_null() { + let Some(value) = values[4] + .as_u64() + .and_then(|value| u8::try_from(value).ok()) + else { + return false; + }; + if value > 3 { + return false; + } + actor.direction = direction_from_compact(value); + } + if !values[5].is_null() { + let Some(value) = values[5].as_u64() else { + return false; + }; + actor.life_state = life_from_compact(value).to_owned(); + } + if let Some(sequence) = values[6].as_i64() { + actor.lifecycle_seq = sequence; + } + if !values[7].is_null() { + let Some(vitals) = compact_vitals(&values[7]) else { + return false; + }; + actor.vitals = vitals; + } + if !values[8].is_null() { + let Some(max_vitals) = compact_vitals(&values[8]) else { + return false; + }; + actor.max_vitals = max_vitals; + } + if !values[9].is_null() { + actor.bleed = Some(values[9].clone()); + } + if !values[10].is_null() { + actor.statuses = match serde_json::from_value(values[10].clone()) { + Ok(value) => value, + Err(_) => return false, + }; + } + if !update_optional_i64(&values[11], &mut actor.body_vanish_at_tick) + || !update_optional_i64(&values[12], &mut actor.respawn_at_tick) + { + return false; + } + if !values[13].is_null() { + actor.professions = match serde_json::from_value(values[13].clone()) { + Ok(value) => value, + Err(_) => return false, + }; + } + if !values[14].is_null() { + actor.active_title = Some(values[14].clone()); + } + if !update_optional_i64(&values[15], &mut actor.skill_points_used) + || !update_optional_i64(&values[16], &mut actor.skill_points_cap) + || !update_optional_i64(&values[17], &mut actor.credits) + { + return false; + } + if values[18] != Value::Bool(false) { + actor.personal_shield = if values[18].is_null() { + None + } else { + Some(values[18].clone()) + }; + } + if !update_string(&values[19], &mut actor.label) + || !update_optional_string(&values[20], &mut actor.sprite) + || !update_optional_string(&values[21], &mut actor.role) + || !update_nullable_string(&values[22], &mut actor.player_organization_id) + || !update_nullable_string(&values[23], &mut actor.player_organization_tag) + { + return false; + } + if values[24] != Value::Bool(false) { + actor.weapon = if values[24].is_null() { + None + } else { + match serde_json::from_value(values[24].clone()) { + Ok(value) => Some(value), + Err(_) => return false, + } + }; + } + if !update_nullable_string(&values[25], &mut actor.faction_id) + || !update_nullable_string(&values[26], &mut actor.social_group) + || !update_nullable_string(&values[27], &mut actor.pvp_status) + || !update_optional_i64(&values[28], &mut actor.shot_spread_degrees_milli) + || !update_optional_string(&values[29], &mut actor.posture) + || !update_optional_i64(&values[30], &mut actor.posture_until_tick) + { + return false; + } + if values[31] != Value::Bool(false) { + actor.combat_queue = if values[31].is_null() { + None + } else { + Some(values[31].clone()) + }; + } + if !update_optional_bool(&values[32], &mut actor.in_combat) + || !update_optional_i64(&values[33], &mut actor.clone_sickness_remaining_ms) + || !update_optional_bool(&values[34], &mut actor.peace_requested) + || !update_optional_string(&values[35], &mut actor.ai_attitude) + || !update_nullable_string(&values[36], &mut actor.engagement_target_id) + || !update_optional_bool(&values[37], &mut actor.lootable) + || !update_optional_bool(&values[38], &mut actor.has_loot) + || !update_nullable_string(&values[39], &mut actor.loot_rights_actor_id) + || !update_optional_i64(&values[40], &mut actor.body_vanish_tick) + || !update_optional_i64(&values[41], &mut actor.incap_remaining_ms) + || !update_optional_i64(&values[42], &mut actor.incap_count) + || !update_optional_i64(&values[43], &mut actor.incap_window_ms) + { + return false; + } + if !update_string(&values[44], &mut actor.display_name) { + return false; + } + if !values[45].is_null() { + let Some(value) = compact_bool(&values[45]) else { + return false; + }; + actor.link_dead = value; + } + if !values[46].is_null() { + actor.appearance = match serde_json::from_value(values[46].clone()) { + Ok(value) => Some(value), + Err(_) => return false, + }; + } + if !update_optional_i64(&values[47], &mut actor.next_sample_tick) { + return false; + } + if !values[48].is_null() { + actor.worn = match serde_json::from_value(values[48].clone()) { + Ok(value) => value, + Err(_) => return false, + }; + } + if !update_optional_bool(&values[49], &mut actor.will_auto_aggro) + || !update_optional_string(&values[50], &mut actor.descriptor) + { + return false; + } + if !values[51].is_null() { + let Some(locked) = compact_bool(&values[51]) else { + return false; + }; + actor.sprint_recovery_locked = Some(locked); + actor.mobility = Some(serde_json::json!({ "sprintRecoveryLocked": locked })); + } + true + } + + fn apply_patch(&mut self, id: &str, patch: &GameActorPatch) -> bool { + if let Some(seq) = patch.lifecycle_seq { + if self.actor_is_stale(id, seq) { + return true; + } + } + let Some(a) = self.actors.get_mut(id) else { + return false; + }; + if let Some(v) = &patch.label { + a.label = v.clone(); + } + if let Some(v) = &patch.display_name { + a.display_name = v.clone(); + } + if let Some(v) = &patch.area_id { + a.area_id = v.clone(); + } + if let Some(v) = patch.x { + if !v.is_finite() { + return false; + } + a.x = v; + } + if let Some(v) = patch.y { + if !v.is_finite() { + return false; + } + a.y = v; + } + if let Some(v) = &patch.direction { + a.direction = v.clone(); + } + if let Some(v) = patch.vitals { + a.vitals = v; + } + if let Some(v) = patch.max_vitals { + a.max_vitals = v; + } + if let Some(v) = &patch.life_state { + a.life_state = v.clone(); + } + if let Some(v) = patch.lifecycle_seq { + a.lifecycle_seq = v; + } + if let Some(v) = &patch.sprite { + a.sprite = Some(v.clone()); + } + if let Some(v) = &patch.role { + a.role = Some(v.clone()); + } + if let Some(v) = &patch.posture { + a.posture = Some(v.clone()); + } + if let Some(v) = &patch.appearance { + a.appearance = Some(v.clone()); + } + if let Some(v) = &patch.worn { + a.worn = v.clone(); + } + if let Some(v) = &patch.weapon { + a.weapon = Some(v.clone()); + } + if let Some(v) = &patch.statuses { + a.statuses = v.clone(); + } + if let Some(v) = &patch.professions { + a.professions = v.clone(); + } + if let Some(v) = &patch.personal_shield { + a.personal_shield = Some(v.clone()); + } + if let Some(v) = patch.credits { + a.credits = Some(v); + } + if let Some(v) = &patch.faction_id { + a.faction_id = Some(v.clone()); + } + if let Some(v) = &patch.social_group { + a.social_group = Some(v.clone()); + } + if let Some(v) = &patch.pvp_status { + a.pvp_status = Some(v.clone()); + } + if let Some(v) = &patch.engagement_target_id { + a.engagement_target_id = Some(v.clone()); + } + true + } + + /// An update for `id` is stale if it carries a lower `lifecycleSeq` than the + /// actor we already hold (a late packet from a previous life). + fn actor_is_stale(&self, id: &str, incoming_seq: i64) -> bool { + self.actors + .get(id) + .map(|a| incoming_seq < a.lifecycle_seq) + .unwrap_or(false) + } + + /// De-duplicate a command receipt over a bounded window. Returns true if the + /// receipt is new (should be processed), false if already seen. + pub fn accept_receipt(&mut self, command_id: u64) -> bool { + if self.receipt_set.contains(&command_id) { + return false; + } + self.receipt_set.insert(command_id); + self.receipt_seen.push_back(command_id); + if self.receipt_seen.len() > RECEIPT_DEDUPE_MAX { + if let Some(old) = self.receipt_seen.pop_front() { + self.receipt_set.remove(&old); + } + } + true + } + + /// Apply the authoritative player position from a `game.acks` packet (the + /// server acks your move command with your reconciled position — this does + /// not arrive as an AOI delta since you are the AOI centre). + pub fn apply_player_position(&mut self, x: f32, y: f32) { + if let Some(a) = self.actors.get_mut(&self.player_actor_id) { + a.x = x; + a.y = y; + } + } + /// Full actor acknowledgements replace the player projection atomically. + pub fn apply_player_actor(&mut self, actor: GameActorSnapshot) { + if self.player_actor_id.is_empty() { + self.player_actor_id = actor.id.clone(); + } + if actor.id == self.player_actor_id { + self.actors.insert(actor.id.clone(), actor); + } + } + + /// Actors that should be rendered: alive/downed, excluding `respawning`. + pub fn render_actors(&self) -> impl Iterator { + self.actors + .iter() + .filter(|(_, a)| a.life_state != "respawning") + } + pub fn world_clock(&self) -> Option<&Value> { + self.world_clock.as_ref() + } + pub fn weather(&self) -> &[Value] { + &self.weather + } + pub fn prop_states(&self) -> &HashMap { + &self.prop_states + } + pub fn frame(&self) -> AuthorityFrameView<'_> { + AuthorityFrameView { store: self } + } + pub fn push_combat_events(&mut self, events: impl IntoIterator) { + for event in events { + self.combat_events.push_back(event); + while self.combat_events.len() > 256 { + self.combat_events.pop_front(); + } + } + } + pub fn drain_combat_events(&mut self, out: &mut Vec) { + out.extend(self.combat_events.drain(..)); + } + pub fn apply_room_message(&mut self, name: &str, payload: &Value) { + match name { + "craftSession" => self.craft_session = Some(payload.clone()), + "spliceSession" => self.splice_session = Some(payload.clone()), + "tradeSession" => self.trade_session = Some(payload.clone()), + "surveyResult" => { + self.survey_results.push(payload.clone()); + if self.survey_results.len() > 128 { + self.survey_results.remove(0); + } + } + // The authority refused a command this client sent. Kept as the + // latest one only: a refusal is about the press the player just + // made, and a queue of them would surface stale reasons. + "commandRejected" => self.command_rejection = Some(payload.clone()), + "genomeScan" => { + self.genome_scans.push(payload.clone()); + if self.genome_scans.len() > 128 { + self.genome_scans.remove(0); + } + } + "duelOutcome" => { + self.duel_outcomes.push(payload.clone()); + if self.duel_outcomes.len() > 128 { + self.duel_outcomes.remove(0); + } + } + "bugReportResult" => self.bug_report_result = Some(payload.clone()), + _ => {} + } + } +} +/// Immutable borrowed frame view; callers cannot mutate or clone authority +/// collections while rendering. +pub struct AuthorityFrameView<'a> { + store: &'a AuthorityStore, +} + +impl<'a> AuthorityFrameView<'a> { + pub fn actors(&self) -> impl Iterator { + self.store.render_actors() + } + pub fn world_clock(&self) -> Option<&'a Value> { + self.store.world_clock() + } + pub fn weather(&self) -> &'a [Value] { + self.store.weather() + } + pub fn prop_states(&self) -> &'a HashMap { + self.store.prop_states() + } +} + +fn direction_from_compact(v: u8) -> String { + match v { + 1 => "right", + 2 => "back", + 3 => "left", + _ => "front", + } + .into() +} + +fn life_from_compact(v: u64) -> &'static str { + match v { + 1 => "downed", + 2 => "respawning", + _ => "alive", + } +} + +fn compact_snapshot(row: &Value) -> Result { + let a = row.as_array().ok_or(())?; + if a.len() != 52 { + return Err(()); + } + let mut o = serde_json::Map::new(); + let put = |o: &mut serde_json::Map, k: &str, v: &Value| { + o.insert(k.into(), v.clone()); + }; + for (i, k) in [ + (0, "id"), + (1, "label"), + (2, "areaId"), + (3, "x"), + (4, "y"), + (7, "lifecycleSeq"), + (8, "vitals"), + (9, "maxVitals"), + (10, "bleed"), + (11, "statuses"), + (12, "factionId"), + (13, "socialGroup"), + (14, "pvpStatus"), + (15, "bodyVanishAtTick"), + (16, "respawnAtTick"), + (17, "professions"), + (18, "activeTitle"), + (19, "skillPointsUsed"), + (20, "skillPointsCap"), + (21, "credits"), + (22, "personalShield"), + (23, "sprite"), + (24, "role"), + (25, "playerOrganizationId"), + (26, "playerOrganizationTag"), + (27, "weapon"), + (28, "shotSpreadDegreesMilli"), + (29, "posture"), + (30, "postureUntilTick"), + (31, "combatQueue"), + (32, "inCombat"), + (33, "cloneSicknessRemainingMs"), + (34, "peaceRequested"), + (35, "aiAttitude"), + (36, "engagementTargetId"), + (37, "lootable"), + (38, "hasLoot"), + (39, "lootRightsActorId"), + (40, "bodyVanishTick"), + (41, "incapRemainingMs"), + (42, "incapCount"), + (43, "incapWindowMs"), + (44, "displayName"), + (45, "linkDead"), + (46, "appearance"), + (47, "nextSampleTick"), + (48, "worn"), + (49, "willAutoAggro"), + (50, "descriptor"), + ] { + put(&mut o, k, &a[i]); + } + let vitals = compact_vitals(&a[8]).ok_or(())?; + o.insert( + "vitals".into(), + serde_json::json!({ + "health": vitals.health, + "action": vitals.action, + "spirit": vitals.spirit, + }), + ); + let max_vitals = compact_vitals(&a[9]).ok_or(())?; + o.insert( + "maxVitals".into(), + serde_json::json!({ + "health": max_vitals.health, + "action": max_vitals.action, + "spirit": max_vitals.spirit, + }), + ); + for (index, key) in [ + (32, "inCombat"), + (34, "peaceRequested"), + (37, "lootable"), + (38, "hasLoot"), + (45, "linkDead"), + (49, "willAutoAggro"), + ] { + o.insert(key.into(), Value::Bool(compact_bool(&a[index]).ok_or(())?)); + } + if a[48].is_null() { + o.insert("worn".into(), Value::Array(Vec::new())); + } + let d = a[5] + .as_u64() + .and_then(|v| u8::try_from(v).ok()) + .filter(|v| *v <= 3) + .ok_or(())?; + let l = a[6].as_u64().ok_or(())?; + put( + &mut o, + "direction", + &Value::String(direction_from_compact(d)), + ); + put( + &mut o, + "lifeState", + &Value::String(life_from_compact(l).into()), + ); + let sprint_recovery_locked = compact_bool(&a[51]).ok_or(())?; + o.insert( + "sprintRecoveryLocked".into(), + Value::Bool(sprint_recovery_locked), + ); + o.insert( + "mobility".into(), + serde_json::json!({ "sprintRecoveryLocked": sprint_recovery_locked }), + ); + serde_json::from_value(Value::Object(o)).map_err(|_| ()) +} +fn compact_patch(row: &Value) -> Result<(String, Value), ()> { + let a = row.as_array().ok_or(())?; + if a.len() != 52 { + return Err(()); + } + let id = a[0].as_str().ok_or(())?.to_string(); + Ok((id, row.clone())) +} + +#[cfg(test)] +mod tests { + use super::*; + use successor_client_proto::packets::{ + GameActorNetRef, GameActorVitals, GameCompactActorMove, GameCompactActorPatch, + GameCompactActorSnapshot, + }; + + fn actor(id: &str, x: f32, y: f32, seq: i64) -> GameActorSnapshot { + GameActorSnapshot { + id: id.into(), + x, + y, + life_state: "alive".into(), + lifecycle_seq: seq, + vitals: GameActorVitals { + health: 100.0, + action: 100.0, + spirit: 100.0, + }, + ..Default::default() + } + } + + fn snap_with(actors: Vec) -> GameShardSnapshot { + let mut s = GameShardSnapshot { + tick: 1, + player_actor_id: "me".into(), + ..Default::default() + }; + for a in actors { + s.actors.insert(a.id.clone(), a); + } + s + } + + #[test] + fn snapshot_then_delta_patch_and_remove() { + let mut store = AuthorityStore::new(); + store.apply_snapshot(&snap_with(vec![ + actor("me", 0.0, 0.0, 1), + actor("bob", 5.0, 5.0, 1), + ])); + assert_eq!(store.actors.len(), 2); + + let mut d = GameShardDelta { + tick: 2, + ..Default::default() + }; + d.actor_patches.insert( + "bob".into(), + GameActorPatch { + id: "bob".into(), + x: Some(9.0), + ..Default::default() + }, + ); + d.actor_removals.push("me".into()); + store.apply_delta(&d); + assert!(!store.actors.contains_key("me")); + assert_eq!(store.actors.get("bob").unwrap().x, 9.0); + assert_eq!(store.tick, 2); + } + #[test] + fn current_compact_actor_schema_decodes_snapshot_and_weapon_patch() { + let mut snapshot_row = vec![Value::Null; 52]; + snapshot_row[0] = serde_json::json!("rogue"); + snapshot_row[1] = serde_json::json!("Wrenn Vale"); + snapshot_row[2] = serde_json::json!("open-desert-overworld"); + snapshot_row[3] = serde_json::json!(611.0); + snapshot_row[4] = serde_json::json!(570.0); + snapshot_row[5] = serde_json::json!(3); + snapshot_row[6] = serde_json::json!(0); + snapshot_row[7] = serde_json::json!(1); + snapshot_row[8] = serde_json::json!([74.0, 73.0, 68.0]); + snapshot_row[9] = serde_json::json!([100.0, 100.0, 100.0]); + snapshot_row[11] = serde_json::json!([]); + snapshot_row[12] = serde_json::json!("rogue_troopers"); + snapshot_row[13] = serde_json::json!("open_desert_rogues"); + snapshot_row[14] = serde_json::json!("overt"); + snapshot_row[15] = serde_json::json!(0); + snapshot_row[16] = serde_json::json!(0); + snapshot_row[17] = serde_json::json!([]); + snapshot_row[19] = serde_json::json!(0); + snapshot_row[20] = serde_json::json!(0); + snapshot_row[21] = serde_json::json!(0); + snapshot_row[24] = serde_json::json!("skirmisher"); + snapshot_row[27] = serde_json::json!({ + "weaponId": "vibrosword", + "weaponItemId": 3103, + "weaponVariantId": 0, + "ammoType": "melee", + "loadedRounds": 1, + "magazineSize": 1, + "reloadRemainingTicks": 0, + "reloadTotalTicks": 1 + }); + snapshot_row[28] = serde_json::json!(0); + snapshot_row[29] = serde_json::json!("standing"); + snapshot_row[30] = serde_json::json!(0); + snapshot_row[32] = serde_json::json!(0); + snapshot_row[33] = serde_json::json!(0); + snapshot_row[34] = serde_json::json!(0); + snapshot_row[37] = serde_json::json!(0); + snapshot_row[38] = serde_json::json!(0); + snapshot_row[40] = serde_json::json!(0); + snapshot_row[41] = serde_json::json!(0); + snapshot_row[42] = serde_json::json!(0); + snapshot_row[43] = serde_json::json!(0); + snapshot_row[44] = serde_json::json!("Wrenn Vale"); + snapshot_row[45] = serde_json::json!(0); + snapshot_row[46] = serde_json::json!({ + "skin": "#c78f62", + "hair": "hair_mop", + "hair_mat": "hair_raven" + }); + snapshot_row[47] = serde_json::json!(0); + snapshot_row[48] = serde_json::json!([{ + "item": "under_bodysuit", + "colors": ["#89cff0"] + }]); + snapshot_row[49] = serde_json::json!(0); + snapshot_row[50] = serde_json::json!("a rogue drifter"); + snapshot_row[51] = serde_json::json!(0); + + let mut store = AuthorityStore::new(); + let mut snapshot_delta = GameShardDelta { + tick: 2, + ..Default::default() + }; + snapshot_delta + .compact_actors + .push(GameCompactActorSnapshot(snapshot_row)); + store.apply_delta(&snapshot_delta); + + let rogue = store.actors.get("rogue").expect("compact actor accepted"); + assert_eq!(rogue.vitals.health, 74.0); + assert_eq!(rogue.faction_id.as_deref(), Some("rogue_troopers")); + assert_eq!(rogue.pvp_status.as_deref(), Some("overt")); + assert_eq!(rogue.role.as_deref(), Some("skirmisher")); + assert_eq!( + rogue + .appearance + .as_ref() + .and_then(|appearance| appearance.skin_tone.as_deref()), + Some("#c78f62") + ); + assert_eq!( + rogue + .appearance + .as_ref() + .and_then(|appearance| appearance.hair_material.as_deref()), + Some("hair_raven") + ); + assert_eq!(rogue.worn[0].item_id.as_deref(), Some("under_bodysuit")); + assert_eq!(rogue.worn[0].colors, ["#89cff0"]); + assert_eq!(rogue.sprint_recovery_locked, Some(false)); + + let mut patch_row = vec![Value::Null; 52]; + patch_row[0] = serde_json::json!("rogue"); + for index in [18, 22, 23, 24, 25, 26, 27, 31, 36, 39] { + patch_row[index] = Value::Bool(false); + } + patch_row[24] = serde_json::json!({ + "weaponId": "wpn-launcher", + "weaponItemId": 3127, + "weaponVariantId": 0, + "ammoType": "slug_iron", + "loadedRounds": 30, + "magazineSize": 30, + "reloadRemainingTicks": 0, + "reloadTotalTicks": 60 + }); + let mut patch_delta = GameShardDelta { + tick: 3, + ..Default::default() + }; + patch_delta + .compact_actor_patches + .push(GameCompactActorPatch(patch_row)); + store.apply_delta(&patch_delta); + + let rogue = store.actors.get("rogue").expect("patched actor retained"); + let weapon = rogue.weapon.as_ref().expect("weapon patch applied"); + assert_eq!(weapon.weapon_id.as_deref(), Some("wpn-launcher")); + assert_eq!(weapon.weapon_item_id, Some(3127)); + assert_eq!(rogue.faction_id.as_deref(), Some("rogue_troopers")); + assert_eq!(rogue.vitals.health, 74.0); + } + + #[test] + fn compact_move_resolves_netid() { + let mut store = AuthorityStore::new(); + store.apply_snapshot(&snap_with(vec![actor("bob", 0.0, 0.0, 1)])); + let mut d = GameShardDelta { + tick: 2, + ..Default::default() + }; + d.actor_refs.push(GameActorNetRef(7, "bob".into())); + d.compact_actor_moves + .push(GameCompactActorMove(7, 51200, 50300, 2)); + store.apply_delta(&d); + let bob = store.actors.get("bob").unwrap(); + assert!((bob.x - 512.0).abs() < 1e-3); + assert!((bob.y - 503.0).abs() < 1e-3); + assert_eq!(bob.direction, "back"); + } + + #[test] + fn stale_generation_ignored() { + let mut store = AuthorityStore::new(); + store.apply_snapshot(&snap_with(vec![actor("bob", 0.0, 0.0, 5)])); + let mut d = GameShardDelta { + tick: 2, + ..Default::default() + }; + // Late patch from a previous life (seq 3 < current 5): ignored. + d.actor_patches.insert( + "bob".into(), + GameActorPatch { + id: "bob".into(), + x: Some(99.0), + lifecycle_seq: Some(3), + ..Default::default() + }, + ); + store.apply_delta(&d); + assert_eq!(store.actors.get("bob").unwrap().x, 0.0); + } + + #[test] + fn section_present_replaces_absent_retains() { + let mut store = AuthorityStore::new(); + let mut s = snap_with(vec![]); + s.bank = Some(serde_json::json!({"credits": 100})); + store.apply_snapshot(&s); + assert!(store.bank.is_some()); + // Delta without bank retains it. + store.apply_delta(&GameShardDelta { + tick: 2, + ..Default::default() + }); + assert!(store.bank.is_some()); + // Delta with bank replaces it. + let mut d = GameShardDelta { + tick: 3, + ..Default::default() + }; + d.bank = Some(Some(serde_json::json!({"credits": 250}))); + store.apply_delta(&d); + assert_eq!(store.bank.as_ref().unwrap()["credits"], 250); + } + + #[test] + fn receipt_dedupe_window() { + let mut store = AuthorityStore::new(); + assert!(store.accept_receipt(1)); + assert!(!store.accept_receipt(1)); + assert!(store.accept_receipt(2)); + } + + #[test] + fn respawning_excluded_from_render() { + let mut store = AuthorityStore::new(); + let mut dead = actor("ghost", 1.0, 1.0, 1); + dead.life_state = "respawning".into(); + store.apply_snapshot(&snap_with(vec![actor("bob", 0.0, 0.0, 1), dead])); + let rendered: Vec<_> = store.render_actors().map(|(id, _)| id.clone()).collect(); + assert!(rendered.contains(&"bob".to_string())); + assert!(!rendered.contains(&"ghost".to_string())); + } +} diff --git a/client-rust/source/app/src/game/chat.rs b/client-rust/source/app/src/game/chat.rs new file mode 100644 index 00000000..35c38a49 --- /dev/null +++ b/client-rust/source/app/src/game/chat.rs @@ -0,0 +1,157 @@ +//! Client-side chat UI: a line editor (Enter opens, types, Enter submits) plus +//! a bounded ring of recent lines rendered as `TextOverlay`s. +//! +//! Scope note: this is the chat PRESENTATION and input. The LOCAL chat network +//! path is a separate Colyseus chat room (a second connection using the +//! `client/src/chat/chatClient.ts` vocabulary and a chat ticket) — that +//! transport is a tracked PARITY follow-up, since it needs the chat-room +//! protocol and a live authority to verify. `submit()` returns the entered text +//! so a future chat-room sender can transmit it. + +use super::chat_net::ChatMessage; +use successor_engine_core::math::Vec2; +use successor_engine_render::components::TextOverlay; + +pub struct ChatState { + pub open: bool, + input: String, + lines: Vec, + cap: usize, +} + +impl ChatState { + pub fn new(cap: usize) -> Self { + Self { + open: false, + input: String::new(), + lines: Vec::with_capacity(cap), + cap, + } + } + + /// Enter toggles the editor open, or submits a non-empty line when open. + /// Returns `Some(text)` when a line is submitted. + pub fn on_enter(&mut self) -> Option { + if !self.open { + self.open = true; + return None; + } + let text = self.input.trim().to_string(); + self.input.clear(); + self.open = false; + if text.is_empty() { + None + } else { + self.push_local(&text); + Some(text) + } + } + + pub fn on_char(&mut self, c: char) { + if self.open && !c.is_control() && self.input.len() < 200 { + self.input.push(c); + } + } + + pub fn on_backspace(&mut self) { + if self.open { + self.input.pop(); + } + } + + pub fn escape(&mut self) { + self.open = false; + self.input.clear(); + } + + fn push_local(&mut self, text: &str) { + self.push_line(&format!("you: {text}")); + } + + /// Record an incoming chat line (e.g. LOCAL bubble text from another actor). + pub fn push_incoming(&mut self, who: &str, text: &str) { + self.push_line(&format!("{who}: {text}")); + } + + /// Bridge a sanitized network message into the render ring. + pub fn push_message(&mut self, message: &ChatMessage) { + let who = if message.sender.is_empty() { + message.channel.as_str() + } else { + message.sender.as_str() + }; + self.push_incoming(who, &message.text); + } + + fn push_line(&mut self, line: &str) { + if self.lines.len() == self.cap { + self.lines.remove(0); + } + self.lines.push(line.to_string()); + } + + pub fn lines(&self) -> &[String] { + &self.lines + } + + /// Build overlays for the recent lines plus the active input line. + pub fn overlays(&self, out: &mut Vec) { + let base_y = 0.80; + for (i, line) in self.lines.iter().enumerate() { + out.push(TextOverlay::new( + line, + Vec2 { + x: 0.02, + y: base_y + i as f32 * 0.03, + }, + [200, 210, 220, 255], + )); + } + if self.open { + let s = format!("> {}", self.input); + out.push(TextOverlay::new( + &s, + Vec2 { x: 0.02, y: 0.96 }, + [255, 240, 120, 255], + )); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enter_opens_then_submits() { + let mut c = ChatState::new(8); + assert_eq!(c.on_enter(), None, "first Enter opens"); + assert!(c.open); + for ch in "hello".chars() { + c.on_char(ch); + } + assert_eq!(c.on_enter().as_deref(), Some("hello")); + assert!(!c.open); + assert_eq!(c.lines().last().map(String::as_str), Some("you: hello")); + } + + #[test] + fn incoming_ring_is_bounded() { + let mut c = ChatState::new(2); + c.push_incoming("a", "1"); + c.push_incoming("b", "2"); + c.push_incoming("c", "3"); + assert_eq!(c.lines().len(), 2); + assert_eq!(c.lines()[0], "b: 2"); + assert_eq!(c.lines()[1], "c: 3"); + } + + #[test] + fn typing_only_when_open() { + let mut c = ChatState::new(4); + c.on_char('x'); // closed -> ignored + c.on_enter(); // open + c.on_char('y'); + assert_eq!(c.on_enter().as_deref(), Some("y")); + } +} diff --git a/client-rust/source/app/src/game/chat_net.rs b/client-rust/source/app/src/game/chat_net.rs new file mode 100644 index 00000000..251c5d0f --- /dev/null +++ b/client-rust/source/app/src/game/chat_net.rs @@ -0,0 +1,737 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatChannel { + All, + Local, + Zone, + Global, + Combat, + Trade, + Party, + Guild, + Whisper, + System, +} + +impl ChatChannel { + pub fn as_str(&self) -> &'static str { + match self { + ChatChannel::All => "all", + ChatChannel::Local => "local", + ChatChannel::Zone => "zone", + ChatChannel::Global => "global", + ChatChannel::Combat => "combat", + ChatChannel::Trade => "trade", + ChatChannel::Party => "party", + ChatChannel::Guild => "guild", + ChatChannel::Whisper => "whisper", + ChatChannel::System => "system", + } + } + + #[allow(clippy::should_implement_trait)] + pub fn from_str(s: &str) -> Option { + match s { + "all" => Some(ChatChannel::All), + "local" => Some(ChatChannel::Local), + "zone" => Some(ChatChannel::Zone), + "global" => Some(ChatChannel::Global), + "combat" => Some(ChatChannel::Combat), + "trade" => Some(ChatChannel::Trade), + "party" => Some(ChatChannel::Party), + "guild" => Some(ChatChannel::Guild), + "whisper" => Some(ChatChannel::Whisper), + "system" => Some(ChatChannel::System), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ChatView { + #[default] + All, + Global, + Combat, + Friends, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatMessage { + pub channel: ChatChannel, + /// Stable character/actor identity used for world anchoring. + pub sender_id: String, + /// Human-facing display name used by the chat pane. + pub sender: String, + pub text: String, + pub whisper_to: Option, +} + +fn send_frame( + request_id: &str, + channel: ChatChannel, + body: &str, + target_id: Option<&str>, +) -> serde_json::Value { + let mut frame = serde_json::json!({ + "type": "chat.send", + "requestId": request_id, + "channel": channel.as_str(), + "body": body, + }); + if let Some(target_id) = target_id { + frame["targetId"] = serde_json::Value::String(target_id.to_owned()); + } + frame +} + +pub fn encode_outgoing(msg: &ChatMessage) -> String { + serde_json::to_string(&send_frame( + "0", + msg.channel, + &msg.text, + msg.whisper_to.as_deref(), + )) + .unwrap_or_default() +} + +pub fn decode_incoming(json: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(json).ok()?; + + // Check type + let packet_type = v.get("type").and_then(|t| t.as_str())?; + + if packet_type == "chat.send" { + let channel_str = v.get("channel").and_then(|c| c.as_str())?; + let channel = ChatChannel::from_str(channel_str)?; + let text = v.get("body").and_then(|b| b.as_str())?.to_string(); + let whisper_to = v + .get("targetId") + .and_then(|t| t.as_str()) + .map(|s| s.to_string()); + + return Some(ChatMessage { + channel, + sender_id: String::new(), + sender: String::new(), + text, + whisper_to, + }); + } + + if packet_type == "chat.message" { + let message = v.get("message")?; + let channel_str = message.get("channel").and_then(|c| c.as_str())?; + let channel = ChatChannel::from_str(channel_str)?; + + let text = message + .get("body") + .or_else(|| message.get("text")) + .and_then(|b| b.as_str())? + .to_string(); + + let (sender_id, sender) = if let Some(sender_val) = message.get("sender") { + if let Some(sender_obj) = sender_val.as_object() { + ( + sender_obj + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + sender_obj + .get("displayName") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + ) + } else if let Some(sender_str) = sender_val.as_str() { + (String::new(), sender_str.to_string()) + } else { + (String::new(), String::new()) + } + } else { + (String::new(), String::new()) + }; + + let whisper_to = message + .get("targetId") + .or_else(|| message.get("target_id")) + .or_else(|| message.get("whisper_to")) + .and_then(|t| t.as_str()) + .map(|s| s.to_string()); + + return Some(ChatMessage { + channel, + sender_id, + sender, + text, + whisper_to, + }); + } + + // Direct ChatMessage object + if let Some(channel_str) = v.get("channel").and_then(|c| c.as_str()) { + if let Some(channel) = ChatChannel::from_str(channel_str) { + let text = v + .get("body") + .or_else(|| v.get("text")) + .and_then(|b| b.as_str())? + .to_string(); + + let (sender_id, sender) = if let Some(sender_val) = v.get("sender") { + if let Some(sender_obj) = sender_val.as_object() { + ( + sender_obj + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + sender_obj + .get("displayName") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + ) + } else if let Some(sender_str) = sender_val.as_str() { + (String::new(), sender_str.to_string()) + } else { + (String::new(), String::new()) + } + } else { + (String::new(), String::new()) + }; + + let whisper_to = v + .get("targetId") + .or_else(|| v.get("target_id")) + .or_else(|| v.get("whisper_to")) + .and_then(|t| t.as_str()) + .map(|s| s.to_string()); + + return Some(ChatMessage { + channel, + sender_id, + sender, + text, + whisper_to, + }); + } + } + + None +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatConnectionState { + Offline, + Connecting, + Authenticating, + SyncingHistory, + Online, + Reconnecting, + Degraded, + Exhausted, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SocialRequest { + FriendAdd(String), + FriendRemove(String), + IgnoreAdd(String), + IgnoreRemove(String), + Presence(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChatCommand { + Send(ChatChannel, String, Option), + Social(SocialRequest), + Invalid(String), +} + +/// Separate chat transport state. Gameplay remains usable when this state is +/// degraded; authentication failures are terminal to chat only. +pub struct ChatConnection { + pub state: ChatConnectionState, + pub endpoint: String, + pub history_cursor: Option, + pub reconnect_attempt: u32, + pub max_reconnect_attempts: u32, + pub pending_requests: Vec, + /// Last server error is retained for the HUD, but never includes secrets. + pub last_error: Option, +} + +impl ChatConnection { + pub fn new(endpoint: String) -> Self { + Self { + state: ChatConnectionState::Offline, + endpoint, + history_cursor: None, + reconnect_attempt: 0, + max_reconnect_attempts: 5, + pending_requests: Vec::new(), + last_error: None, + } + } + pub fn begin(&mut self) { + self.state = ChatConnectionState::Connecting; + self.last_error = None; + } + /// Takes the one-use ticket and builds the first authenticated frame. + /// The ticket is never retained in the connection after this call. + pub fn authenticate( + &mut self, + ticket: &mut Option, + client_release: &str, + ) -> Option { + let value = ticket.take()?; + if client_release.is_empty() { + return None; + } + self.state = ChatConnectionState::Authenticating; + Some( + serde_json::json!({ + "type":"chat.authenticate", + "chatTicket":value, + "release":client_release, + }) + .to_string(), + ) + } + pub fn authenticated(&mut self) { + self.state = ChatConnectionState::SyncingHistory; + self.reconnect_attempt = 0; + } + pub fn history_loaded(&mut self, cursor: Option) { + self.history_cursor = cursor; + self.state = ChatConnectionState::Online; + } + pub fn failed(&mut self, message: &str) { + self.last_error = Some(bounded_text(message)); + self.state = ChatConnectionState::Degraded; + } + pub fn lost(&mut self) -> bool { + if self.reconnect_attempt >= self.max_reconnect_attempts { + self.state = ChatConnectionState::Exhausted; + return false; + } + self.reconnect_attempt += 1; + self.state = ChatConnectionState::Reconnecting; + true + } + pub fn request_id(&mut self, prefix: &str) -> String { + let id = format!( + "{prefix}-{}", + self.pending_requests.len() as u32 + self.reconnect_attempt + 1 + ); + self.pending_requests.push(id.clone()); + id + } + pub fn complete_request(&mut self, id: &str) { + self.pending_requests.retain(|v| v != id); + } +} + +fn bounded_text(text: &str) -> String { + text.chars() + .filter(|c| !c.is_control() || *c == '\n') + .take(200) + .collect() +} + +pub fn parse_input(channel: ChatChannel, input: &str) -> ChatCommand { + let text = bounded_text(input.trim()); + if text.is_empty() { + return ChatCommand::Invalid("empty message".into()); + } + if !text.starts_with('/') { + return ChatCommand::Send(channel, text, None); + } + let mut p = text.splitn(3, ' '); + match p.next().unwrap_or_default() { + "/w" | "/whisper" => match (p.next(), p.next()) { + (Some(target), Some(body)) if !target.is_empty() => ChatCommand::Send( + ChatChannel::Whisper, + bounded_text(body), + Some(target.to_owned()), + ), + _ => ChatCommand::Invalid("usage: /whisper ".into()), + }, + "/friend" => match (p.next(), p.next()) { + (Some("add"), Some(id)) => ChatCommand::Social(SocialRequest::FriendAdd(id.to_owned())), + (Some("add"), None) => ChatCommand::Invalid("missing player".into()), + (Some("remove"), Some(id)) => { + ChatCommand::Social(SocialRequest::FriendRemove(id.to_owned())) + } + (Some(id), _) => ChatCommand::Social(SocialRequest::FriendAdd(id.to_owned())), + _ => ChatCommand::Invalid("missing player".into()), + }, + "/unfriend" => p + .next() + .map(|id| ChatCommand::Social(SocialRequest::FriendRemove(id.to_owned()))) + .unwrap_or_else(|| ChatCommand::Invalid("missing player".into())), + "/ignore" => match (p.next(), p.next()) { + (Some("add"), Some(id)) => ChatCommand::Social(SocialRequest::IgnoreAdd(id.to_owned())), + (Some("remove"), Some(id)) => { + ChatCommand::Social(SocialRequest::IgnoreRemove(id.to_owned())) + } + (Some(id), _) => ChatCommand::Social(SocialRequest::IgnoreAdd(id.to_owned())), + _ => ChatCommand::Invalid("missing player".into()), + }, + "/unignore" => p + .next() + .map(|id| ChatCommand::Social(SocialRequest::IgnoreRemove(id.to_owned()))) + .unwrap_or_else(|| ChatCommand::Invalid("missing player".into())), + "/status" => p + .next() + .map(|s| ChatCommand::Social(SocialRequest::Presence(s.to_owned()))) + .unwrap_or_else(|| ChatCommand::Invalid("missing status".into())), + _ => ChatCommand::Invalid("unknown chat command".into()), + } +} +pub struct ChatClient { + pub history: Vec, + pub cap: usize, + pub connection: ChatConnection, + pub friends: Vec, + pub ignored: Vec, + pub active_view: ChatView, + pub send_channel: ChatChannel, + pub last_error: Option, + pub presence: Option, +} + +impl ChatClient { + pub fn new(cap: usize) -> Self { + Self { + history: Vec::with_capacity(cap), + cap, + connection: ChatConnection::new(String::new()), + friends: Vec::new(), + ignored: Vec::new(), + active_view: ChatView::All, + send_channel: ChatChannel::Local, + last_error: None, + presence: None, + } + } + pub fn with_endpoint(cap: usize, endpoint: String) -> Self { + let mut c = Self::new(cap); + c.connection = ChatConnection::new(endpoint); + c + } + pub fn apply_social(&mut self, request: SocialRequest) { + let (list, add, id) = match request { + SocialRequest::FriendAdd(id) => (&mut self.friends, true, id), + SocialRequest::FriendRemove(id) => (&mut self.friends, false, id), + SocialRequest::IgnoreAdd(id) => (&mut self.ignored, true, id), + SocialRequest::IgnoreRemove(id) => (&mut self.ignored, false, id), + SocialRequest::Presence(status) => { + self.presence = Some(status); + return; + } + }; + if add { + if !list.iter().any(|x| x == &id) && list.len() < 128 { + list.push(id); + } + } else { + list.retain(|x| x != &id); + } + } + pub fn ping(&mut self) -> String { + let request_id = self.connection.request_id("ping"); + serde_json::json!({"type":"ping","requestId":request_id}).to_string() + } + pub fn submit_input(&mut self, input: &str) -> ChatCommand { + parse_input(self.send_channel, input) + } + /// Encode a parsed line using the server's chat-room vocabulary. + pub fn command_frame(&mut self, command: ChatCommand) -> Option { + let request_id = self.connection.request_id("chat"); + let frame = match command { + ChatCommand::Send(channel, body, target) => send_frame( + &request_id, + channel, + &bounded_text(&body), + target.as_deref(), + ), + ChatCommand::Social(SocialRequest::FriendAdd(id)) => { + serde_json::json!({"type":"friend.add","requestId":request_id,"friendId":bounded_text(&id)}) + } + ChatCommand::Social(SocialRequest::FriendRemove(id)) => { + serde_json::json!({"type":"friend.remove","requestId":request_id,"friendId":bounded_text(&id)}) + } + ChatCommand::Social(SocialRequest::IgnoreAdd(id)) => { + serde_json::json!({"type":"ignore.add","requestId":request_id,"targetId":bounded_text(&id)}) + } + ChatCommand::Social(SocialRequest::IgnoreRemove(id)) => { + serde_json::json!({"type":"ignore.remove","requestId":request_id,"targetId":bounded_text(&id)}) + } + ChatCommand::Social(SocialRequest::Presence(status)) => { + serde_json::json!({"type":"presence.set","requestId":request_id,"status":bounded_text(&status)}) + } + ChatCommand::Invalid(error) => { + self.connection.complete_request(&request_id); + self.last_error = Some(bounded_text(&error)); + return None; + } + }; + serde_json::to_string(&frame).ok() + } + pub fn on_incoming(&mut self, json: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(json).ok()?; + if let Some(id) = value.get("requestId").and_then(|v| v.as_str()) { + self.connection.complete_request(id); + } + match value.get("type").and_then(|v| v.as_str()) { + Some("chat.hello") => { + self.connection.authenticated(); + let msg = ChatMessage { + channel: ChatChannel::System, + sender_id: String::new(), + sender: String::new(), + text: "Connected to chat.".into(), + whisper_to: None, + }; + self.record(&msg); + Some(msg) + } + Some("chat.history") => { + if let Some(messages) = value.get("messages").and_then(|v| v.as_array()) { + for message in messages { + if let Ok(raw) = serde_json::to_string( + &serde_json::json!({"type":"chat.message","message":message}), + ) { + let _ = self.on_incoming(&raw); + } + } + } + self.connection.history_loaded(None); + None + } + // A `chat.error` is the server refusing ONE request - no guild, rate + // limited, moderated. The socket is fine, so it must not be marked + // failed: doing that left every empty tab claiming CHAT DEGRADED and + // silently closed the gates that require `Online`. Surface it as a + // system line instead, which is also the only way the player learns + // the message they just typed went nowhere. + Some("chat.error") => { + let text = value + .get("message") + .and_then(|v| v.as_str()) + .map(bounded_text) + .unwrap_or_else(|| "Chat request refused.".to_string()); + self.last_error = Some(text.clone()); + let msg = ChatMessage { + channel: ChatChannel::System, + sender_id: String::new(), + sender: String::new(), + text, + whisper_to: None, + }; + self.record(&msg); + Some(msg) + } + Some("friends.snapshot") => { + self.friends = value + .get("friends") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|x| x.get("id").and_then(|v| v.as_str()).map(str::to_owned)) + .take(128) + .collect() + }) + .unwrap_or_default(); + None + } + Some("friend.event") => { + if let Some(id) = value.pointer("/friend/id").and_then(|v| v.as_str()) { + let added = value.get("action").and_then(|v| v.as_str()) == Some("added"); + self.apply_social(if added { + SocialRequest::FriendAdd(id.into()) + } else { + SocialRequest::FriendRemove(id.into()) + }); + } + None + } + Some("presence.update") => None, + Some("pong") => None, + _ => { + let msg = decode_incoming(json)?; + if self.cap > 0 { + while self.history.len() >= self.cap { + self.history.remove(0); + } + self.history.push(msg.clone()); + } + Some(msg) + } + } + } + fn record(&mut self, msg: &ChatMessage) { + if self.cap == 0 { + return; + } + while self.history.len() >= self.cap { + self.history.remove(0); + } + self.history.push(msg.clone()); + } + pub fn recent_channel(&self, channel: ChatChannel) -> impl Iterator { + self.history + .iter() + .filter(move |m| m.channel == channel || channel == ChatChannel::All) + } + pub fn compose(&self, channel: ChatChannel, text: &str, whisper_to: Option) -> String { + encode_outgoing(&ChatMessage { + channel, + sender_id: String::new(), + sender: String::new(), + text: bounded_text(text), + whisper_to, + }) + } + pub fn recent(&self) -> &[ChatMessage] { + &self.history + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn channel_round_trip() { + let channels = [ + ChatChannel::All, + ChatChannel::Local, + ChatChannel::Zone, + ChatChannel::Global, + ChatChannel::Trade, + ChatChannel::Party, + ChatChannel::Guild, + ChatChannel::Whisper, + ChatChannel::System, + ]; + for ch in channels { + let s = ch.as_str(); + let parsed = ChatChannel::from_str(s).expect("from_str failed"); + assert_eq!(parsed, ch); + } + } + + /// A refused request is not a broken socket. Marking the connection failed + /// here made every empty tab read CHAT DEGRADED and closed the gates that + /// require `Online`. + #[test] + fn a_refused_request_stays_online_and_reaches_the_log() { + let mut client = ChatClient::new(10); + client.on_incoming(r#"{"type":"chat.hello","self":{"id":"a","displayName":"A"}}"#); + client.on_incoming(r#"{"type":"chat.history","channel":"local","messages":[]}"#); + assert_eq!(client.connection.state, ChatConnectionState::Online); + + let surfaced = client.on_incoming( + r#"{"type":"chat.error","code":"no_guild","message":"You are not in a guild."}"#, + ); + + assert_eq!(client.connection.state, ChatConnectionState::Online); + let surfaced = surfaced.expect("a refusal must reach the player"); + assert_eq!(surfaced.channel, ChatChannel::System); + assert_eq!(surfaced.text, "You are not in a guild."); + assert!( + client + .history + .iter() + .any(|m| m.text == "You are not in a guild."), + "the refusal must be recorded in the log, not just returned" + ); + } + + /// Transport failure is still a failure - the split must not swallow it. + #[test] + fn a_transport_failure_still_degrades_the_connection() { + let mut client = ChatClient::new(10); + client.on_incoming(r#"{"type":"chat.hello","self":{"id":"a","displayName":"A"}}"#); + client.on_incoming(r#"{"type":"chat.history","channel":"local","messages":[]}"#); + + client.connection.failed("socket closed"); + + assert_eq!(client.connection.state, ChatConnectionState::Degraded); + } + + #[test] + fn compose_decode_round_trip() { + let client = ChatClient::new(10); + let channels = [ + ChatChannel::Local, + ChatChannel::Zone, + ChatChannel::Global, + ChatChannel::Trade, + ChatChannel::Party, + ChatChannel::Guild, + ChatChannel::Whisper, + ]; + + for ch in channels { + let text = "Hello, world!"; + let whisper = if ch == ChatChannel::Whisper { + Some("recipient123".to_string()) + } else { + None + }; + + let json = client.compose(ch, text, whisper.clone()); + let decoded = decode_incoming(&json).expect("Failed to decode outgoing frame"); + assert_eq!(decoded.channel, ch); + assert_eq!(decoded.text, text); + assert_eq!(decoded.whisper_to, whisper); + let frame: serde_json::Value = serde_json::from_str(&json).unwrap(); + if let Some(target) = whisper.as_deref() { + assert_eq!(frame["targetId"], target); + } else { + assert!(frame.get("targetId").is_none()); + } + } + } + + #[test] + fn history_bounds_cap() { + let mut client = ChatClient::new(3); + + // Pushing server-like chat message packets + let packets = [ + r#"{"type":"chat.message","message":{"channel":"local","sender":{"id":"1","displayName":"Alice"},"body":"Msg 1"}}"#, + r#"{"type":"chat.message","message":{"channel":"local","sender":{"id":"2","displayName":"Bob"},"body":"Msg 2"}}"#, + r#"{"type":"chat.message","message":{"channel":"local","sender":{"id":"3","displayName":"Charlie"},"body":"Msg 3"}}"#, + r#"{"type":"chat.message","message":{"channel":"local","sender":{"id":"4","displayName":"David"},"body":"Msg 4"}}"#, + ]; + + for p in packets { + client.on_incoming(p).expect("Should parse valid message"); + } + + let recent = client.recent(); + assert_eq!(recent.len(), 3); + assert_eq!(recent[0].text, "Msg 2"); + assert_eq!(recent[0].sender, "Bob"); + assert_eq!(recent[0].sender_id, "2"); + assert_eq!(recent[1].text, "Msg 3"); + assert_eq!(recent[1].sender, "Charlie"); + assert_eq!(recent[1].sender_id, "3"); + assert_eq!(recent[2].text, "Msg 4"); + assert_eq!(recent[2].sender, "David"); + assert_eq!(recent[2].sender_id, "4"); + } + + #[test] + fn decode_defensive_malformed() { + assert!(decode_incoming("").is_none()); + assert!(decode_incoming("{").is_none()); + assert!(decode_incoming(r#"{"type":"chat.message"}"#).is_none()); + assert!(decode_incoming(r#"{"type":"chat.message","message":{}}"#).is_none()); + assert!(decode_incoming(r#"{"type":"chat.send","channel":"local"}"#).is_none()); + } +} diff --git a/client-rust/source/app/src/game/chat_ui.rs b/client-rust/source/app/src/game/chat_ui.rs new file mode 100644 index 00000000..e9e3954a --- /dev/null +++ b/client-rust/source/app/src/game/chat_ui.rs @@ -0,0 +1,365 @@ +use super::chat_net::{ChatChannel, ChatClient, ChatConnectionState, ChatMessage, ChatView}; +use successor_engine_render::font::GLYPH_H; +use successor_engine_render::ui::{TextField, UiBuilder}; + +/// Returns a distinct tint per channel. +pub fn channel_color(ch: ChatChannel) -> [u8; 4] { + match ch { + ChatChannel::All => [255, 255, 255, 255], // white + ChatChannel::Local => [255, 255, 255, 255], // white + ChatChannel::Zone => [72, 214, 230, 255], // teal (#48d6e6) + ChatChannel::Global => [240, 196, 96, 255], // gold (#f0c460) + ChatChannel::Combat => [236, 112, 96, 255], + ChatChannel::Trade => [100, 220, 120, 255], // green + ChatChannel::Party => [100, 160, 240, 255], // blue + ChatChannel::Guild => [200, 100, 240, 255], // purple + ChatChannel::Whisper => [230, 100, 230, 255], // magenta + ChatChannel::System => [150, 150, 150, 255], // grey + } +} + +/// Draw the persistent lower-left chat console. Channel tabs and history stay +/// visible while the text field is unfocused; Enter or a click gives it focus. +#[allow(clippy::too_many_arguments)] +pub fn draw_chat_pane( + ui: &mut UiBuilder, + client: &mut ChatClient, + input: &mut TextField, + x: f32, + y: f32, + w: f32, + h: f32, +) { + // The console is a chromeless HUD pane, so it paints its own surface. It + // used to paint a hardcoded near-black slab, which made it the one pane + // that never followed the theme and the darkest thing on screen. Its + // tones come from the active palette, and only the per-channel message + // tints below stay fixed — those are identity, not chrome. + let pal = crate::hud::active_palette(); + let fill = crate::hud::faded(pal.bg_panel); + let edge = pal.hairline; + let accent = pal.accent; + let dim = pal.ink_dim; + let ink = pal.ink; + ui.panel(x, y, w, h, fill, edge); + + let tabs = [ + (ChatView::All, "ALL"), + (ChatView::Global, "GLOBAL"), + (ChatView::Combat, "COMBAT"), + (ChatView::Friends, "FRIENDS"), + ]; + let tab_w = (w - 10.0) / tabs.len() as f32; + for (index, (view, label)) in tabs.into_iter().enumerate() { + let tab_x = x + 5.0 + index as f32 * tab_w; + let active = client.active_view == view; + let tint = if active { ink } else { dim }; + let tw = ui.measure_text(label, 1.6); + ui.text(label, tab_x + (tab_w - tw) * 0.5, y + 6.0, 1.6, tint); + if active { + ui.rect(tab_x + 3.0, y + 25.0, tab_w - 6.0, 2.0, accent); + } + if ui.interact(tab_x, y + 3.0, tab_w, 26.0).clicked { + client.active_view = view; + } + } + + // Chat is read at a glance mid-fight and sits over bright terrain, so it + // runs a step above the window body face. `line_h` moves with `px` - leaving + // it at 16 would set larger glyphs in the same leading and close the gaps + // between lines. + let px = 1.6; + let line_h = 18.0; + let padding = 6.0; + let input_h = 27.0; + let input_y = y + h - input_h - padding; + let channel_w = 92.0; + let mut channel_style = crate::hud::button_style(); + channel_style.text = dim; + if ui.button( + x + padding, + input_y, + channel_w, + input_h, + channel_label(client.send_channel), + channel_style, + ) { + client.send_channel = next_send_channel(client.send_channel); + } + ui.text_field( + input, + x + padding + channel_w + 6.0, + input_y, + w - padding * 2.0 - channel_w - 6.0, + input_h, + px, + input.focused, + crate::hud::button_style(), + ); + + let rows = ((input_y - (y + 32.0)) / line_h).floor().max(0.0) as usize; + let visible_count = client + .history + .iter() + .filter(|message| message_visible(client, message)) + .count(); + let skip = visible_count.saturating_sub(rows); + let mut line_y = y + 34.0; + let char_w = ui.measure_text("M", px); + for msg in client + .history + .iter() + .filter(|message| message_visible(client, message)) + .skip(skip) + .take(rows) + { + let prefix = if msg.sender.is_empty() { + channel_label(msg.channel) + } else { + msg.sender.as_str() + }; + let tint = channel_color(msg.channel); + let mut tx = x + padding + 10.0; + ui.rect(x + padding, line_y - 1.0, 3.0, line_h - 2.0, tint); + ui.text(prefix, tx, line_y, px, tint); + tx += ui.measure_text(prefix, px) + char_w; + let remaining = ((x + w - padding - tx) / char_w).floor().max(0.0) as usize; + if remaining > 0 { + let (body, clipped) = char_prefix(&msg.text, remaining.saturating_sub(3)); + ui.text(body, tx, line_y, px, ink); + if clipped { + ui.text("...", tx + ui.measure_text(body, px), line_y, px, dim); + } + } + line_y += line_h; + } + + if visible_count == 0 { + // An empty tab is a filter result, not a fault. Only report the socket + // when it is actually unwell; otherwise name the tab that has nothing + // in it, so COMBAT before a fight does not read as a broken client. + let status = match client.connection.state { + ChatConnectionState::Online => match client.active_view { + ChatView::All => "NO MESSAGES YET", + ChatView::Global => "NO GLOBAL MESSAGES", + ChatView::Combat => "NO COMBAT MESSAGES", + ChatView::Friends => "NO FRIEND MESSAGES", + }, + ChatConnectionState::Connecting + | ChatConnectionState::Authenticating + | ChatConnectionState::SyncingHistory + | ChatConnectionState::Reconnecting => "CHAT CONNECTING", + ChatConnectionState::Offline + | ChatConnectionState::Degraded + | ChatConnectionState::Exhausted => "CHAT DEGRADED", + }; + ui.text(status, x + padding + 10.0, line_y, 1.45, dim); + } +} + +fn channel_label(channel: ChatChannel) -> &'static str { + match channel { + ChatChannel::All => "ALL", + ChatChannel::Local => "LOCAL", + ChatChannel::Zone => "ZONE", + ChatChannel::Global => "GLOBAL", + ChatChannel::Combat => "COMBAT", + ChatChannel::Trade => "TRADE", + ChatChannel::Party => "PARTY", + ChatChannel::Guild => "GUILD", + ChatChannel::Whisper => "WHISPER", + ChatChannel::System => "SYSTEM", + } +} + +fn next_send_channel(channel: ChatChannel) -> ChatChannel { + match channel { + ChatChannel::Local => ChatChannel::Global, + ChatChannel::Global => ChatChannel::Party, + ChatChannel::Party => ChatChannel::Guild, + ChatChannel::Guild => ChatChannel::Trade, + ChatChannel::Trade => ChatChannel::Local, + _ => ChatChannel::Local, + } +} + +fn message_visible(client: &ChatClient, message: &ChatMessage) -> bool { + match client.active_view { + ChatView::All => true, + ChatView::Global => matches!(message.channel, ChatChannel::Global | ChatChannel::Zone), + ChatView::Combat => message.channel == ChatChannel::Combat, + ChatView::Friends => { + matches!( + message.channel, + ChatChannel::Whisper | ChatChannel::Party | ChatChannel::Guild + ) || client + .friends + .iter() + .any(|friend| friend == &message.sender) + } + } +} + +fn char_prefix(text: &str, max_chars: usize) -> (&str, bool) { + match text.char_indices().nth(max_chars) { + Some((byte, _)) => (&text[..byte], true), + None => (text, false), + } +} + +/// Draws a small rounded speech panel centered above a projected actor screen position. +pub fn draw_bubble(ui: &mut UiBuilder, screen_x: f32, screen_y: f32, text: &str) { + let px = 1.5; + let char_w = ui.measure_text("M", px); + let glyph_h_scaled = (GLYPH_H as f32) * px; // 10.5 + + let max_w = 200.0; + let padding_x = 8.0; + let padding_y = 6.0; + + let mut display_text = text.to_string(); + let max_chars = ((max_w - padding_x * 2.0) / char_w).floor() as usize; + + if display_text.chars().count() > max_chars && max_chars > 3 { + let truncated: String = display_text.chars().take(max_chars - 3).collect(); + display_text = format!("{}...", truncated); + } + + let text_w = ui.measure_text(&display_text, px); + let bubble_w = text_w + padding_x * 2.0; + let bubble_h = glyph_h_scaled + padding_y * 2.0; // 22.5 + + let bx = screen_x - bubble_w * 0.5; + let by = screen_y - bubble_h - 4.0; + + // Background: dark translucent panel with 1px rounded corners (chopped corners) + let fill = [10, 10, 10, 220]; + ui.rect(bx + 1.0, by, bubble_w - 2.0, bubble_h, fill); + ui.rect(bx, by + 1.0, 1.0, bubble_h - 2.0, fill); + ui.rect(bx + bubble_w - 1.0, by + 1.0, 1.0, bubble_h - 2.0, fill); + + // Border: light grey with 1px rounded corners + let edge = [200, 200, 200, 255]; + ui.rect(bx + 1.0, by, bubble_w - 2.0, 1.0, edge); // top edge + ui.rect(bx + 1.0, by + bubble_h - 1.0, bubble_w - 2.0, 1.0, edge); // bottom edge + ui.rect(bx, by + 1.0, 1.0, bubble_h - 2.0, edge); // left edge + ui.rect(bx + bubble_w - 1.0, by + 1.0, 1.0, bubble_h - 2.0, edge); // right edge + + // Centered text + let tx = bx + padding_x; + let ty = by + padding_y; + ui.text(&display_text, tx, ty, px, [255, 255, 255, 255]); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::chat_net::ChatChannel; + use successor_engine_render::ui::AtlasMeta; + + #[test] + fn test_channel_color_distinct() { + let local_color = channel_color(ChatChannel::Local); + let system_color = channel_color(ChatChannel::System); + let whisper_color = channel_color(ChatChannel::Whisper); + + assert_ne!(local_color, system_color); + assert_ne!(local_color, whisper_color); + assert_ne!(system_color, whisper_color); + } + + #[test] + fn test_draw_chat_pane_renders_messages() { + let atlas = AtlasMeta { + cell: 32, + cols: 8, + width: 256, + height: 160, + }; + let mut ui = UiBuilder::new(atlas); + let mut input = TextField::new(100); + let mut client = ChatClient::new(10); + + // 1. Draw empty chat pane and record baseline quads + ui.begin(800, 600); + draw_chat_pane(&mut ui, &mut client, &mut input, 10.0, 10.0, 300.0, 200.0); + let baseline_quads = ui.quads; + assert!( + baseline_quads > 0, + "Should draw background panel and border" + ); + + // 2. Build a ChatClient in tests via its real API + on_incoming a JSON frame + let json_str = client.compose( + ChatChannel::Local, + "HELLO FROM THE MARKET TERMINAL, TRAVELER", + None, + ); + let mut val: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + val["sender"] = serde_json::json!("ZARA"); + let json_str_with_sender = serde_json::to_string(&val).unwrap(); + client.on_incoming(&json_str_with_sender); + + // 3. Draw chat pane with 1 message + ui.begin(800, 600); + draw_chat_pane(&mut ui, &mut client, &mut input, 10.0, 10.0, 300.0, 200.0); + let new_quads = ui.quads; + + // 4. Assert quads grew and that the message is rendered (new_quads > baseline_quads) + assert!( + new_quads > baseline_quads, + "Quads should grow when messages are rendered" + ); + } + + #[test] + fn test_draw_bubble_centers_text() { + let atlas = AtlasMeta { + cell: 32, + cols: 8, + width: 256, + height: 160, + }; + let mut ui = UiBuilder::new(atlas); + ui.begin(800, 600); + + let screen_x = 100.0; + let screen_y = 150.0; + let text = "HELLO"; + + let start_quads = ui.quads; + draw_bubble(&mut ui, screen_x, screen_y, text); + let end_quads = ui.quads; + + let quads_emitted = end_quads - start_quads; + assert!(quads_emitted > 0); + assert!(quads_emitted < 100, "Should emit a bounded number of quads"); + + // Calculate expected bubble boundaries + let px = 1.5; + let text_w = ui.measure_text(text, px); + let padding_x = 8.0; + let bubble_w = text_w + padding_x * 2.0; + let bx = screen_x - bubble_w * 0.5; + let right_edge = bx + bubble_w; + + // Assert bubble centers around screen_x + assert!( + bx < screen_x, + "Bubble left edge {} should be left of screen_x {}", + bx, + screen_x + ); + assert!( + right_edge > screen_x, + "Bubble right edge {} should be right of screen_x {}", + right_edge, + screen_x + ); + assert_eq!( + (bx + right_edge) * 0.5, + screen_x, + "Bubble should be centered around screen_x" + ); + } +} diff --git a/client-rust/source/app/src/game/combat_fx.rs b/client-rust/source/app/src/game/combat_fx.rs new file mode 100644 index 00000000..95e88481 --- /dev/null +++ b/client-rust/source/app/src/game/combat_fx.rs @@ -0,0 +1,589 @@ +//! Combat VFX driver — the read-only combat-event tap (port of +//! `client-3d/src/render/fx/events.ts`). +//! +//! Consumes the authoritative `ServerAuthorityCombatEventState` shape streamed +//! by the server (`damage`/`effect`/`lifecycle`/roll-combat fields, actor and +//! weapon ids, sim-space origin/hit points) and drives the particle pool: a +//! muzzle flash at the shot origin, a tracer origin → hit, and an outcome +//! burst at the hit point. Events are deduped by id (bounded window + a +//! monotonic watermark) so a resent snapshot or a re-drained queue never +//! double-fires — combat FX are idempotent per event id. This is the +//! presentation half; the sim owns the authoritative rolls. + +use successor_engine_render::fx::ParticlePool; + +/// Presentation outcome derived from the authoritative event, following the +/// precedence in `events.ts`: deflect/shield beats dodge beats sleep beats +/// damage blood beats plain sparks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CombatOutcome { + /// Damaging hit on flesh → blood burst. + Blood, + /// Non-damaging contact / armor ping → sparks. + Spark, + /// Personal-shield or saber deflect → bright spark fan. + Deflect, + /// Dodged: no impact burst (a faint whiff only). + Dodge, + /// Sleep dart: intentionally no burst (never fake blood). + Sleep, +} + +/// Weapon presentation family for bolt/muzzle coloring, derived from the +/// event's `weaponId`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WeaponVisual { + Slugthrower, + Plasma, + Melee, + Unknown, +} + +impl WeaponVisual { + pub fn from_weapon_id(id: Option<&str>) -> Self { + let Some(id) = id else { + return WeaponVisual::Unknown; + }; + let lower = id.to_ascii_lowercase(); + if lower.contains("plasma") { + WeaponVisual::Plasma + } else if lower.contains("slug") || lower.contains("rifle") || lower.contains("gun") { + WeaponVisual::Slugthrower + } else if lower.contains("sword") || lower.contains("vibro") || lower.contains("blade") { + WeaponVisual::Melee + } else { + WeaponVisual::Unknown + } + } + + /// Bolt/muzzle color (linear rgb). + pub fn color(self) -> [f32; 3] { + match self { + WeaponVisual::Plasma => [1.0, 0.36, 0.24], + WeaponVisual::Slugthrower | WeaponVisual::Unknown => [1.0, 0.79, 0.47], + WeaponVisual::Melee => [0.85, 0.9, 1.0], + } + } +} + +/// A projected combat event — the subset of the server's +/// `ServerAuthorityCombatEventState` the presentation needs. Points are SIM +/// cell coordinates; the scene lifts them to world space (terrain height + +/// body heights) before triggering FX. +#[derive(Clone, Debug, PartialEq)] +pub struct CombatEvent { + pub id: i64, + pub tick: i64, + pub shooter_actor_id: String, + pub target_actor_id: String, + /// Muzzle / shot origin (sim cells), when the event carries one. + pub origin: Option<[f32; 2]>, + /// Impact point (sim cells), when the event carries one. + pub hit_point: Option<[f32; 2]>, + pub damage: f32, + pub outcome: CombatOutcome, + /// Lifecycle transitions (drive death/downed presentation + magnitude). + pub killed: bool, + pub downed: bool, + /// True for projectile events (roll-combat `ranged_roll` or an origin far + /// enough from the hit to read as a shot) — gates muzzle flash + tracer. + pub ranged: bool, + pub weapon: WeaponVisual, +} + +impl CombatEvent { + /// Burst magnitude: damage-scaled, boosted on kill (events.ts + /// `killedMagnitudeBoost` semantics). + pub fn magnitude(&self) -> f32 { + let base = (self.damage / 25.0).clamp(0.4, 1.6); + if self.killed { + base + 1.0 + } else { + base + } + } + + /// Decode one server event, reading fields defensively. The wire carries + /// two shapes: the verbose object (`events`) and the + /// `GameCompactCombatEvent` tuple (`compactEvents`, protocol.ts) that the + /// size-reduction wave made the shipping default. Returns `None` when the + /// id is missing (undeliverable — nothing to dedupe on). + pub fn from_json(v: &serde_json::Value) -> Option { + if let Some(tuple) = v.as_array() { + return Self::from_compact(tuple); + } + let id = v + .get("id") + .or_else(|| v.get("eventId")) + .and_then(|x| x.as_i64())?; + let tick = v.get("tick").and_then(|x| x.as_i64()).unwrap_or(0); + let shooter = str_field(v, "shooterActorId"); + let target = str_field(v, "targetActorId"); + let origin = point2(v.get("originPoint")); + let hit_point = point2(v.get("hitPoint")); + let damage = v.get("damage").and_then(|x| x.as_f64()).unwrap_or(0.0) as f32; + if !damage.is_finite() { + return None; + } + + let effect_kind = v + .get("effect") + .and_then(|e| e.get("kind")) + .and_then(|k| k.as_str()) + .unwrap_or(""); + let lifecycle_kind = v + .get("lifecycle") + .and_then(|l| l.get("kind")) + .and_then(|k| k.as_str()) + .unwrap_or(""); + let lifecycle_cause = v + .get("lifecycle") + .and_then(|l| l.get("cause")) + .and_then(|c| c.as_str()) + .unwrap_or(""); + let prev_life = str_field(v, "previousLifeState"); + let life = str_field(v, "lifeState"); + let kind = v.get("kind").and_then(|k| k.as_str()).unwrap_or(""); + let weapon = WeaponVisual::from_weapon_id(v.get("weaponId").and_then(|w| w.as_str())); + + Some(Self::assemble(AssembleFields { + id, + tick, + shooter, + target, + origin, + hit_point, + damage, + effect_kind, + lifecycle_kind, + lifecycle_cause, + prev_life: &prev_life, + life: &life, + kind, + weapon, + })) + } + + /// Decode the compact tuple. Index layout mirrors `compactCombatEvent` in + /// `server/src/game/shard.ts` / `GameCompactCombatEvent` in protocol.ts: + /// 0 id · 2 tick · 3 shooter · 4 target · 5/6 hit x/y · 7 damage · + /// 9 previousLifeState · 10 lifeState · 13 lifecycle.kind · + /// 16 lifecycle.cause · 17 weaponId · 19 effect.kind · 23/24 origin x/y · + /// 25 kind. + fn from_compact(t: &[serde_json::Value]) -> Option { + let id = t.first()?.as_i64()?; + let tick = t.get(2).and_then(|x| x.as_i64()).unwrap_or(0); + let damage = t.get(7).and_then(|x| x.as_f64()).unwrap_or(0.0) as f32; + if !damage.is_finite() { + return None; + } + let tuple_str = |at: usize| t.get(at).and_then(|x| x.as_str()).unwrap_or("").to_string(); + let tuple_point = |x_at: usize, y_at: usize| -> Option<[f32; 2]> { + let x = t.get(x_at)?.as_f64()? as f32; + let y = t.get(y_at)?.as_f64()? as f32; + (x.is_finite() && y.is_finite()).then_some([x, y]) + }; + let prev_life = tuple_str(9); + let life = tuple_str(10); + Some(Self::assemble(AssembleFields { + id, + tick, + shooter: tuple_str(3), + target: tuple_str(4), + origin: tuple_point(23, 24), + hit_point: tuple_point(5, 6), + damage, + effect_kind: t.get(19).and_then(|x| x.as_str()).unwrap_or(""), + lifecycle_kind: t.get(13).and_then(|x| x.as_str()).unwrap_or(""), + lifecycle_cause: t.get(16).and_then(|x| x.as_str()).unwrap_or(""), + prev_life: &prev_life, + life: &life, + kind: t.get(25).and_then(|x| x.as_str()).unwrap_or(""), + weapon: WeaponVisual::from_weapon_id(t.get(17).and_then(|x| x.as_str())), + })) + } + + /// Shared presentation derivation — outcome precedence, kill/down edges, + /// and ranged classification are identical for both wire shapes. + fn assemble(fields: AssembleFields<'_>) -> Self { + let killed = fields.lifecycle_kind == "killed" + || (fields.prev_life != fields.life && fields.life == "respawning"); + let downed = fields.lifecycle_kind == "downed" + || (fields.prev_life != fields.life && fields.life == "downed"); + + // Outcome precedence (events.ts): deflect/shield > dodge > sleep > + // damaging blood > sparks. + let deflected = fields.effect_kind == "deflected" + || fields.effect_kind == "shield" + || fields.lifecycle_cause == "personal shield" + || fields.lifecycle_cause == "personal-shield"; + let dodged = fields.effect_kind == "dodge" || fields.lifecycle_cause == "dodged"; + let outcome = if deflected { + CombatOutcome::Deflect + } else if dodged { + CombatOutcome::Dodge + } else if fields.effect_kind == "sleep" { + CombatOutcome::Sleep + } else if fields.damage > 0.0 { + CombatOutcome::Blood + } else { + CombatOutcome::Spark + }; + + let ranged = fields.kind == "ranged_roll" || fields.origin.is_some(); + Self { + id: fields.id, + tick: fields.tick, + shooter_actor_id: fields.shooter, + target_actor_id: fields.target, + origin: fields.origin, + hit_point: fields.hit_point, + damage: fields.damage, + outcome, + killed, + downed, + ranged, + weapon: fields.weapon, + } + } +} + +/// Field bundle for `CombatEvent::assemble` — one struct so both wire decoders +/// feed the identical derivation. +struct AssembleFields<'a> { + id: i64, + tick: i64, + shooter: String, + target: String, + origin: Option<[f32; 2]>, + hit_point: Option<[f32; 2]>, + damage: f32, + effect_kind: &'a str, + lifecycle_kind: &'a str, + lifecycle_cause: &'a str, + prev_life: &'a str, + life: &'a str, + kind: &'a str, + weapon: WeaponVisual, +} + +fn str_field(v: &serde_json::Value, key: &str) -> String { + v.get(key) + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string() +} + +/// A finite `{x, y}` sim point, else `None` (malformed points are dropped, not +/// guessed). +fn point2(v: Option<&serde_json::Value>) -> Option<[f32; 2]> { + let v = v?; + let x = v.get("x")?.as_f64()? as f32; + let y = v.get("y")?.as_f64()? as f32; + (x.is_finite() && y.is_finite()).then_some([x, y]) +} + +/// How many recent ids the duplicate window retains (beyond the watermark). +const SEEN_WINDOW: usize = 128; + +/// Drives the particle pool from a stream of combat events, deduped by id. +pub struct CombatFx { + pool: ParticlePool, + /// Every id ≤ watermark has been processed (events stream in id order; + /// the window covers out-of-order stragglers above it). + watermark: i64, + seen: [i64; SEEN_WINDOW], + seen_cursor: usize, +} + +impl CombatFx { + pub fn new(seed: u32) -> Self { + Self { + pool: ParticlePool::new(seed), + watermark: i64::MIN, + seen: [i64::MIN; SEEN_WINDOW], + seen_cursor: 0, + } + } + + pub fn pool(&self) -> &ParticlePool { + &self.pool + } + pub fn pool_mut(&mut self) -> &mut ParticlePool { + &mut self.pool + } + + pub fn update(&mut self, dt: f32) { + self.pool.update(dt); + } + + /// Record an id; true if it was already processed. Idempotency contract: + /// each event id fires at most once for the lifetime of this driver. + fn already_seen(&mut self, id: i64) -> bool { + if id <= self.watermark || self.seen.contains(&id) { + return true; + } + self.seen[self.seen_cursor] = id; + self.seen_cursor = (self.seen_cursor + 1) % self.seen.len(); + if id > self.watermark + SEEN_WINDOW as i64 { + // Far ahead: everything below the window floor is implicitly seen. + self.watermark = id - SEEN_WINDOW as i64; + } + false + } + + /// Fire the VFX for one event, with world-space origin/hit already + /// resolved by the scene (terrain height + chest/muzzle heights). Returns + /// false if the event id was a duplicate (nothing emitted). + pub fn trigger( + &mut self, + ev: &CombatEvent, + origin_world: [f32; 3], + hit_world: [f32; 3], + ) -> bool { + if self.already_seen(ev.id) { + return false; + } + let mag = ev.magnitude(); + let mut dir = [ + hit_world[0] - origin_world[0], + hit_world[1] - origin_world[1], + hit_world[2] - origin_world[2], + ]; + let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt(); + if len > 1e-4 { + dir = [dir[0] / len, dir[1] / len, dir[2] / len]; + } else { + dir = [1.0, 0.0, 0.0]; + } + if ev.ranged { + self.pool + .emit_muzzle_flash(origin_world, dir, mag, ev.weapon.color()); + self.pool.emit_tracer(origin_world, hit_world, mag); + } + let normal = [-dir[0], -dir[1], -dir[2]]; + match ev.outcome { + CombatOutcome::Blood => self.pool.emit_blood_burst(hit_world, dir, mag), + CombatOutcome::Spark => self + .pool + .emit_spark_burst(hit_world, normal, dir, mag * 0.7), + CombatOutcome::Deflect => self + .pool + .emit_spark_burst(hit_world, normal, dir, mag * 1.2), + // A dodged shot whiffs past: the tracer already told the story. + CombatOutcome::Dodge => {} + // Sleep darts intentionally no-op rather than faking an impact. + CombatOutcome::Sleep => {} + } + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn shot_json(id: i64) -> serde_json::Value { + json!({ + "id": id, + "tick": 900, + "shooterActorId": "1:1", + "targetActorId": "npc:9", + "originPoint": { "x": 10.0, "y": 12.0 }, + "hitPoint": { "x": 14.0, "y": 12.0 }, + "damage": 18, + "zone": "chest", + "previousLifeState": "alive", + "lifeState": "alive", + "kind": "ranged_roll", + "hit": true, + "weaponId": "slugthrower", + }) + } + + #[test] + fn decodes_the_authoritative_event_shape() { + let ev = CombatEvent::from_json(&shot_json(41)).expect("decodes"); + assert_eq!(ev.id, 41); + assert_eq!(ev.shooter_actor_id, "1:1"); + assert_eq!(ev.target_actor_id, "npc:9"); + assert_eq!(ev.origin, Some([10.0, 12.0])); + assert_eq!(ev.hit_point, Some([14.0, 12.0])); + assert_eq!(ev.outcome, CombatOutcome::Blood); + assert!(ev.ranged); + assert!(!ev.killed); + assert_eq!(ev.weapon, WeaponVisual::Slugthrower); + } + + /// The shipping wire shape: `compactEvents` tuples per + /// `GameCompactCombatEvent` (protocol.ts) / `compactCombatEvent` + /// (shard.ts). A tuple must decode identically to its verbose twin — + /// this is the contract that keeps combat FX alive on the live wire. + fn shot_tuple(id: i64) -> serde_json::Value { + json!([ + id, // 0 id + null, // 1 commandId + 900, // 2 tick + "1:1", // 3 shooterActorId + "npc:9", // 4 targetActorId + 14.0, // 5 hitX + 12.0, // 6 hitY + 18, // 7 damage + "torso", // 8 zone + "alive", // 9 previousLifeState + "alive", // 10 lifeState + 3, // 11 targetLifecycleSeq + 0, // 12 bleedStackCount + null, // 13 lifecycleKind + null, // 14 lifecycleFrom + null, // 15 lifecycleTo + null, // 16 lifecycleCause + "slugthrower", // 17 weaponId + null, // 18 ammoTypeId + null, // 19 effectKind + null, // 20 effectStacks + null, // 21 effectThreshold + null, // 22 effectRemainingMs + 10.0, // 23 originX + 12.0, // 24 originY + "ranged_roll", // 25 kind + ]) + } + + #[test] + fn decodes_the_compact_tuple_identically_to_the_object() { + let object = CombatEvent::from_json(&shot_json(41)).expect("object decodes"); + let tuple = CombatEvent::from_json(&shot_tuple(41)).expect("tuple decodes"); + assert_eq!(tuple, object); + } + + #[test] + fn compact_kill_lifecycle_reads_killed_and_boosts_magnitude() { + let mut t = shot_tuple(7); + t[13] = json!("killed"); + t[9] = json!("alive"); + t[10] = json!("respawning"); + let ev = CombatEvent::from_json(&t).expect("decodes"); + assert!(ev.killed); + assert!(ev.magnitude() > CombatEvent::from_json(&shot_tuple(8)).unwrap().magnitude()); + } + + #[test] + fn compact_shield_effect_wins_outcome_precedence() { + let mut t = shot_tuple(9); + t[19] = json!("shield"); + assert_eq!( + CombatEvent::from_json(&t).unwrap().outcome, + CombatOutcome::Deflect + ); + } + + #[test] + fn compact_tuple_without_id_is_dropped() { + assert!(CombatEvent::from_json(&json!([null, null, 900])).is_none()); + assert!(CombatEvent::from_json(&json!([])).is_none()); + } + + #[test] + fn outcome_precedence_deflect_beats_damage() { + let mut v = shot_json(1); + v["effect"] = json!({ "kind": "deflected" }); + let ev = CombatEvent::from_json(&v).unwrap(); + assert_eq!(ev.outcome, CombatOutcome::Deflect); + + let mut v = shot_json(2); + v["lifecycle"] = json!({ "kind": "hit", "cause": "personal shield" }); + v["damage"] = json!(0); + assert_eq!( + CombatEvent::from_json(&v).unwrap().outcome, + CombatOutcome::Deflect + ); + + let mut v = shot_json(3); + v["effect"] = json!({ "kind": "dodge" }); + assert_eq!( + CombatEvent::from_json(&v).unwrap().outcome, + CombatOutcome::Dodge + ); + + let mut v = shot_json(4); + v["effect"] = json!({ "kind": "sleep" }); + v["damage"] = json!(0); + assert_eq!( + CombatEvent::from_json(&v).unwrap().outcome, + CombatOutcome::Sleep + ); + } + + #[test] + fn lifecycle_transitions_mark_killed_and_downed() { + let mut v = shot_json(5); + v["lifecycle"] = json!({ "kind": "killed" }); + let ev = CombatEvent::from_json(&v).unwrap(); + assert!(ev.killed); + assert!(ev.magnitude() > 1.0, "kill boosts burst magnitude"); + + let mut v = shot_json(6); + v["previousLifeState"] = json!("alive"); + v["lifeState"] = json!("downed"); + assert!(CombatEvent::from_json(&v).unwrap().downed); + } + + #[test] + fn missing_id_or_nonfinite_damage_is_rejected() { + let mut v = shot_json(7); + v.as_object_mut().unwrap().remove("id"); + assert!(CombatEvent::from_json(&v).is_none()); + } + + #[test] + fn trigger_is_idempotent_per_event_id() { + let mut fx = CombatFx::new(7); + let ev = CombatEvent::from_json(&shot_json(100)).unwrap(); + let o = [10.5, 1.35, 12.5]; + let h = [14.5, 1.0, 12.5]; + assert!(fx.trigger(&ev, o, h)); + let first = fx.pool().alive(); + assert!(first > 0, "first trigger emits particles"); + assert!(!fx.trigger(&ev, o, h), "duplicate id is a no-op"); + assert_eq!(fx.pool().alive(), first, "no double emission"); + } + + #[test] + fn watermark_rejects_ids_behind_the_window() { + let mut fx = CombatFx::new(7); + let mk = |id: i64| { + let mut e = CombatEvent::from_json(&shot_json(id)).unwrap(); + e.id = id; + e + }; + let o = [0.0, 1.0, 0.0]; + // Stream a long run of ids; the watermark advances behind them. + for id in 0..(SEEN_WINDOW as i64 * 3) { + fx.trigger(&mk(id), o, o); + } + // An ancient id (resent snapshot) must not re-fire. + assert!(!fx.trigger(&mk(1), o, o)); + } + + #[test] + fn melee_events_skip_muzzle_and_tracer() { + let mut fx = CombatFx::new(9); + let v = json!({ + "id": 500, + "shooterActorId": "a", + "targetActorId": "b", + "damage": 10, + "previousLifeState": "alive", + "lifeState": "alive", + "weaponId": "vibrosword", + }); + let ev = CombatEvent::from_json(&v).unwrap(); + assert!(!ev.ranged, "no origin point + no ranged_roll → melee"); + assert!(fx.trigger(&ev, [0.0, 1.0, 0.0], [0.5, 1.0, 0.0])); + // Blood burst only (normal layer); no additive muzzle/tracer quads. + assert_eq!(fx.pool().additive.alive(), 0); + assert!(fx.pool().normal.alive() > 0); + } +} diff --git a/client-rust/source/app/src/game/command_queue.rs b/client-rust/source/app/src/game/command_queue.rs new file mode 100644 index 00000000..16e5e6b6 --- /dev/null +++ b/client-rust/source/app/src/game/command_queue.rs @@ -0,0 +1,361 @@ +//! Outbound authority command queue — port of `authorityCommandSystem.ts` +//! queue semantics: a per-process command-id floor (ms-timestamp namespace so +//! reconnects never restart at 1), priority-ordered flush (not global FIFO) with +//! command-id tiebreak, and settle-on-receipt / defer-in-flight. Commands reuse +//! `successor_net::{ClientCommand, ClientCommandEnvelope}`. + +use successor_net::{ClientCommand, ClientCommandEnvelope, PlayerId, SessionId}; + +/// Server tick rate the move cadence targets (`targetMoveCommandsPerSecond`). +pub const MOVE_COMMANDS_PER_SECOND: u32 = 20; + +/// Milliseconds between move-intent sends at the target cadence. +pub fn move_interval_ms() -> f32 { + 1000.0 / MOVE_COMMANDS_PER_SECOND as f32 +} + +/// Movement receipts are diagnostic, not a serialization barrier. Retain only +/// a bounded recent set while newer intent commands continue transmitting. +const MAX_MOVEMENT_IN_FLIGHT: usize = 8; + +/// A fresh command-id floor: `ms * 1000 + seq` (seq wraps 1..=999) so two +/// clients created in the same millisecond, and reconnects, never collide. +pub fn next_command_id_floor(now_ms: u64, seq: &mut u64) -> u64 { + *seq = if *seq >= 999 { 1 } else { *seq + 1 }; + now_ms.max(1) * 1000 + *seq +} + +/// Priority class for flush ordering (lower = sent first). Mirrors +/// `gameAuthoritySystem.ts::commandPriority`. +pub fn command_priority(command: &ClientCommand) -> u8 { + match command_kind(command).as_str() { + "CloneRespawn" | "ReviveActor" | "UseConsumable" | "RefillAmmo" | "ApplyServiceBuff" + | "SampleResource" | "HarvestCorpse" | "TakeLootItem" | "CraftItem" + | "PurchaseSkillBox" | "SetProfessionTitle" | "SetCareerGoal" => 0, + "Move" => 2, + _ => 3, + } +} + +/// The externally-tagged variant name of a command (its serde object key). +pub fn command_kind(command: &ClientCommand) -> String { + serde_json::to_value(command) + .ok() + .and_then(|v| v.as_object().and_then(|o| o.keys().next().cloned())) + .unwrap_or_default() +} + +#[derive(Clone)] +pub struct CommandQueue { + session: SessionId, + player: PlayerId, + next_id: u64, + pending: Vec, + in_flight: Option, + movement_in_flight: Vec, + pub total_queued: u64, +} + +impl CommandQueue { + pub fn new(session: SessionId, player: PlayerId, command_id_floor: u64) -> Self { + CommandQueue { + session, + player, + next_id: command_id_floor.max(1), + pending: Vec::new(), + in_flight: None, + movement_in_flight: Vec::with_capacity(MAX_MOVEMENT_IN_FLIGHT), + total_queued: 0, + } + } + + fn is_move(command: &ClientCommand) -> bool { + matches!(command, ClientCommand::SetMoveIntent { .. }) + } + + /// Enqueue a command; returns its assigned command id. A queued movement + /// heartbeat is superseded by the latest intent while another command is + /// in flight, keeping held movement bounded during authority latency. + pub fn enqueue(&mut self, command: ClientCommand, issued_at_tick: u64) -> u64 { + let command_id = self.next_id; + self.next_id += 1; + self.total_queued += 1; + let envelope = ClientCommandEnvelope { + session: self.session, + player: self.player, + command_id, + issued_at_tick, + command, + }; + if Self::is_move(&envelope.command) { + if let Some(index) = self + .pending + .iter() + .rposition(|pending| Self::is_move(&pending.command)) + { + self.pending[index] = envelope; + return command_id; + } + } + self.pending.push(envelope); + command_id + } + + /// A copy of the pending commands in transmission order (priority, then id). + pub fn flush_order(&self) -> Vec { + let mut out = self.pending.clone(); + out.sort_by(|a, b| { + command_priority(&a.command) + .cmp(&command_priority(&b.command)) + .then(a.command_id.cmp(&b.command_id)) + }); + out + } + + /// Move the highest-priority eligible command into its lane and return it. + /// Transactional commands remain single-flight; movement uses an independent + /// bounded lane so a delayed inventory/crafting receipt cannot stall input. + pub fn take_next(&mut self) -> Option { + if self.pending.is_empty() { + return None; + } + let mut best = None; + for (index, pending) in self.pending.iter().enumerate() { + if self.in_flight.is_some() && !Self::is_move(&pending.command) { + continue; + } + let replace = best.is_none_or(|current: usize| { + let candidate = (command_priority(&pending.command), pending.command_id); + let selected = ( + command_priority(&self.pending[current].command), + self.pending[current].command_id, + ); + candidate < selected + }); + if replace { + best = Some(index); + } + } + let env = self.pending.remove(best?); + if Self::is_move(&env.command) { + if self.movement_in_flight.len() == MAX_MOVEMENT_IN_FLIGHT { + self.movement_in_flight.remove(0); + } + self.movement_in_flight.push(env.clone()); + } else { + self.in_flight = Some(env.clone()); + } + Some(env) + } + + /// Settle the command named by a receipt: clears the in-flight slot if it + /// matches, else removes it from pending. Returns true if found. + pub fn settle(&mut self, command_id: u64) -> bool { + if self.in_flight.as_ref().map(|e| e.command_id) == Some(command_id) { + self.in_flight = None; + return true; + } + if let Some(pos) = self + .movement_in_flight + .iter() + .position(|envelope| envelope.command_id == command_id) + { + self.movement_in_flight.remove(pos); + return true; + } + if let Some(pos) = self.pending.iter().position(|e| e.command_id == command_id) { + self.pending.remove(pos); + return true; + } + false + } + + /// Requeue an unsettled in-flight command at the pending head (flush still + /// re-orders by priority). + pub fn defer_in_flight(&mut self) -> bool { + if let Some(env) = self.in_flight.take() { + self.pending.insert(0, env); + true + } else { + false + } + } + + pub fn pending_len(&self) -> usize { + self.pending.len() + } + + pub fn in_flight(&self) -> Option<&ClientCommandEnvelope> { + self.in_flight.as_ref() + } + /// Reconnects replay transactional commands idempotently. Movement already + /// sent on the old transport is discarded; the controller re-announces the + /// current desired state on its bounded wall-clock cadence. + pub fn reconcile_reconnect(&mut self) { + let _ = self.defer_in_flight(); + self.movement_in_flight.clear(); + } + + pub fn settle_many(&mut self, command_ids: impl IntoIterator) { + for id in command_ids { + self.settle(id); + } + } + + pub fn pending_envelopes(&self) -> impl Iterator { + self.pending + .iter() + .chain(self.in_flight.iter()) + .chain(self.movement_in_flight.iter()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn q() -> CommandQueue { + CommandQueue::new(SessionId(1), PlayerId(1), 1000) + } + + #[test] + fn id_floor_namespaced() { + let mut seq = 0; + let a = next_command_id_floor(5, &mut seq); + let b = next_command_id_floor(5, &mut seq); + assert_eq!(a, 5001); + assert_eq!(b, 5002); + // Wraps seq at 999. + let mut seq = 999; + assert_eq!(next_command_id_floor(2, &mut seq), 2001); + } + + #[test] + fn priority_classes() { + assert_eq!( + command_priority(&ClientCommand::CloneRespawn { facility_id: None }), + 0 + ); + assert_eq!(command_priority(&ClientCommand::Peace {}), 3); + } + + #[test] + fn enqueue_assigns_increasing_ids() { + let mut q = q(); + let a = q.enqueue(ClientCommand::Peace {}, 1); + let b = q.enqueue(ClientCommand::Peace {}, 1); + assert_eq!(a, 1000); + assert_eq!(b, 1001); + assert_eq!(q.total_queued, 2); + } + + #[test] + fn flush_order_priority_then_id() { + let mut q = q(); + // Enqueue a low-priority Peace FIRST, then a high-priority CloneRespawn. + q.enqueue(ClientCommand::Peace {}, 1); // id 1000, priority 3 + q.enqueue(ClientCommand::CloneRespawn { facility_id: None }, 1); // id 1001, priority 0 + let order = q.flush_order(); + assert_eq!( + order[0].command_id, 1001, + "high-priority CloneRespawn first" + ); + assert_eq!(order[1].command_id, 1000); + } + #[test] + fn queued_movement_is_coalesced_while_receipt_is_delayed() { + let mut q = q(); + let first = q.enqueue( + ClientCommand::SetMoveIntent { + dx: 1, + dy: 0, + facing: None, + sprint: false, + }, + 1, + ); + assert_eq!(q.take_next().unwrap().command_id, first); + q.enqueue( + ClientCommand::SetMoveIntent { + dx: 1, + dy: 0, + facing: None, + sprint: false, + }, + 2, + ); + let latest = q.enqueue( + ClientCommand::SetMoveIntent { + dx: 0, + dy: -1, + facing: None, + sprint: true, + }, + 3, + ); + assert_eq!(q.pending_len(), 1); + assert!(q.settle(first)); + assert_eq!(q.take_next().unwrap().command_id, latest); + } + + #[test] + fn movement_lane_bypasses_delayed_transaction_receipt() { + let mut q = q(); + let transaction = q.enqueue(ClientCommand::CloneRespawn { facility_id: None }, 1); + assert_eq!(q.take_next().unwrap().command_id, transaction); + let movement = q.enqueue( + ClientCommand::SetMoveIntent { + dx: 1, + dy: 0, + facing: None, + sprint: false, + }, + 2, + ); + assert_eq!(q.take_next().unwrap().command_id, movement); + assert!(q + .in_flight() + .is_some_and(|env| env.command_id == transaction)); + assert!(q.settle(movement)); + assert!(q.settle(transaction)); + } + + #[test] + fn reconnect_drops_old_transport_movement_but_replays_transaction() { + let mut q = q(); + let transaction = q.enqueue(ClientCommand::CloneRespawn { facility_id: None }, 1); + assert_eq!(q.take_next().unwrap().command_id, transaction); + let movement = q.enqueue( + ClientCommand::SetMoveIntent { + dx: 0, + dy: -1, + facing: None, + sprint: true, + }, + 2, + ); + assert_eq!(q.take_next().unwrap().command_id, movement); + q.reconcile_reconnect(); + assert_eq!(q.pending_len(), 1); + assert!(!q.pending_envelopes().any(|env| env.command_id == movement)); + assert_eq!(q.take_next().unwrap().command_id, transaction); + } + + #[test] + fn take_settle_and_defer() { + let mut q = q(); + q.enqueue(ClientCommand::Peace {}, 1); // 1000 + let cr = q.enqueue(ClientCommand::CloneRespawn { facility_id: None }, 1); // 1001 + // take_next picks the high-priority one. + let taken = q.take_next().unwrap(); + assert_eq!(taken.command_id, cr); + // While one is in flight, take_next yields nothing. + assert!(q.take_next().is_none()); + // Defer requeues it. + assert!(q.defer_in_flight()); + assert_eq!(q.pending_len(), 2); + // Settle by id removes from pending. + assert!(q.settle(1000)); + assert_eq!(q.pending_len(), 1); + } +} diff --git a/client-rust/source/app/src/game/connected_scene.rs b/client-rust/source/app/src/game/connected_scene.rs new file mode 100644 index 00000000..2b9bd4e0 --- /dev/null +++ b/client-rust/source/app/src/game/connected_scene.rs @@ -0,0 +1,6124 @@ +//! The connected-mode scene: composes the real world backdrop (terrain + GLB +//! props), GLB pawns per streamed actor, environment lighting, HUD, and combat +//! FX into one `GameWorld`/`Renderer`, driven by the authoritative +//! [`AuthorityStore`]. This replaces the placeholder ground-plane + capsule +//! projection so the live client renders like `client-3d`. +//! +//! Coordinate contract: one authority cell is one metre/world unit. Actor +//! `(x, y)` addresses the cell whose world-space centre is `(x + 0.5, y + 0.5)`; +//! terrain supplies elevation, props use fixture footprints, and pawn source +//! geometry is normalized to the canonical adult height. + +use core::fmt::{self, Write}; +use std::collections::HashMap; + +use successor_client_proto::packets::{ + GameActorSnapshot, GameCommandReceipt, GameServerPacket, GameShardDelta, GameShardSnapshot, +}; +use successor_engine_core::ecs::{Entity, WorldOps}; +use successor_engine_core::input::Key; +use successor_engine_core::math::{vec3, Mat4, Quat, Vec3}; +use successor_engine_render::components::{ + CamTarget, Camera, CompositeQuad, DirectionalLight, MeshRenderer, Projection, RectNorm, + SkinRef, Transform, +}; +use successor_engine_render::gpu::{ + ClearSpec, Filter, Gpu, PassTarget, RectPx, RenderTargetDesc, RenderTargetId, +}; +use successor_engine_render::renderer::Renderer; +use successor_engine_render::ui::UiBuilder; +use successor_engine_render::{environment, fx::glow_sprite}; +use successor_net::ClientCommand; + +use crate::game::actions::{self, DispatchOutcome}; +use crate::game::authority::AuthorityStore; +use crate::game::chat_net::{ChatChannel, ChatMessage}; +use crate::game::combat_fx::CombatFx; +use crate::game::command_queue::CommandQueue; +use crate::game::interp::ActorInterp; +use crate::game::movement; +use crate::game::prediction::MovePredictor; +use crate::hud::{self, HudState, Icons}; +use crate::item_preview::ItemPreviewRenderer; +use crate::pawn::animator::{weapon_hand_bone, PawnAnimator, WeaponLane}; +use crate::pawn::appearance::{faction_tinted, skin_tint, weapon_lane}; +use crate::pawn::catalog::{route_for, BodyRoute, PawnCatalog, SupportArmPosture}; +use crate::world::area::{biome_for_area, effective_world_seed}; +use crate::world::camera::smooth_follow; +use crate::world::chunks::TerrainStreamer; +use crate::world::collision_debug::CollisionDebugOverlay; +use crate::world::environs::Environs; +use crate::world::props::{building_terrain_exclusions, PropsLoader}; +use crate::world::streamed::StreamedWorld; +use crate::world::terrain::Biome; +use crate::world::{ADULT_PAWN_HEIGHT_METERS, WORLD_UNITS_PER_CELL}; +use crate::GameWorld; +use successor_engine_render::cursor::{self, CursorKind, CursorStyle}; +use successor_platform::Platform; + +#[derive(Clone, Copy)] +struct HeldWeaponRig { + mount: Mat4, + grip: Vec3, + foregrip: Vec3, + muzzle: Vec3, + foregrip_contact: Vec3, + resting_yaw_rad: f32, + support_arm: Option, + support_hand: bool, +} + +struct WeaponAttachment { + entities: Vec<(Entity, Mat4)>, + hand: usize, + held: HeldWeaponRig, + plasma_blade: Option, + stow: Option<(usize, Mat4, f32)>, + stow_blend: f32, + stow_target: bool, + stow_seconds: f32, + ik_weight: f32, +} + +#[derive(Clone, PartialEq, Eq)] +struct WornPresentation { + item_id: String, + colors: Vec, +} + +#[derive(Clone)] +struct PawnPresentation { + skin: Option, + faction: Option, + sprite: Option, + role: Option, + hair: Option, + hair_material: Option, + worn: Vec, + weapon: Option, + weapon_item_id: Option, +} + +impl PawnPresentation { + fn matches(&self, actor: &GameActorSnapshot) -> bool { + let appearance = actor.appearance.as_ref(); + let mut actor_worn = actor.worn.iter().filter_map(|piece| { + piece + .item_id + .as_deref() + .map(|item_id| (item_id, &piece.colors)) + }); + let worn_matches = self.worn.iter().all(|expected| { + actor_worn.next().is_some_and(|(item_id, colors)| { + item_id == expected.item_id && colors == &expected.colors + }) + }) && actor_worn.next().is_none(); + self.skin.as_deref() == appearance.and_then(|value| value.skin_tone.as_deref()) + && self.faction.as_deref() == actor.faction_id.as_deref() + && self.sprite.as_deref() == actor.sprite.as_deref() + && self.role.as_deref() == actor.role.as_deref() + && self.hair.as_deref() == appearance.and_then(|value| value.hair.as_deref()) + && self.hair_material.as_deref() + == appearance.and_then(|value| value.hair_material.as_deref()) + && worn_matches + && self.weapon.as_deref() + == actor + .weapon + .as_ref() + .and_then(|weapon| weapon.weapon_id.as_deref()) + && self.weapon_item_id + == actor + .weapon + .as_ref() + .and_then(|weapon| weapon.weapon_item_id) + } +} + +struct ActorPawn { + id: String, + name: String, + descriptor: Option, + presentation: PawnPresentation, + entities: Vec, + weapon: Option, + animator: PawnAnimator, + route: BodyRoute, + lane: WeaponLane, + scale: f32, + alive: bool, + interp: ActorInterp, + predictor: MovePredictor, + lifecycle_seq: i64, + /// Authoritative sim target position (from the store). + target: (f32, f32), + /// Smoothed rendered position (lerped toward `target` each frame) — this is + /// what drives both the transform and the gait speed, so neither snaps. + render_pos: (f32, f32), + /// Presentation height after floor/terrain transition filtering. + ground_y: f32, + speed: f32, + yaw: f32, + present: bool, + pending_equipment: Vec, + pending_weapon: bool, +} + +impl ActorPawn { + fn owned_entities(&self) -> impl Iterator + '_ { + self.entities.iter().copied().chain( + self.weapon + .iter() + .flat_map(|weapon| weapon.entities.iter().map(|(entity, _)| *entity)), + ) + } +} + +/// Motion held across a wardrobe rebuild. +/// +/// Changing clothes swaps the pawn's meshes, not the person wearing them. The +/// rebuild destroys the old entities, so a naive respawn hands the actor a +/// fresh `PawnAnimator` sitting at its bind pose and the arms snap up into a +/// T-pose while the blend runs. Carrying the animator, gait and interpolation +/// across the swap keeps the stride unbroken: only the clothing changes. +struct CarriedMotion { + animator: PawnAnimator, + route: BodyRoute, + lane: WeaponLane, + interp: ActorInterp, + predictor: MovePredictor, + render_pos: (f32, f32), + ground_y: f32, + target: (f32, f32), + speed: f32, + yaw: f32, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct MovementDiagnostics { + pub authoritative: (f32, f32), + pub predicted: (f32, f32), + pub rendered: (f32, f32), + pub correction_cells: f32, + pub intent: (i32, i32, bool), + pub applied_command_id: u64, + pub blocker_count: usize, + pub presented_ground_y: f32, + pub sampled_ground_y: f32, + pub frame_dt_ms: f32, + pub last_change_ms: u64, + pub last_send_ms: u64, + pub next_send_ms: u64, + pub sampled_at_ms: u64, +} + +struct DebugText { + bytes: [u8; N], + len: usize, +} + +impl DebugText { + fn new() -> Self { + Self { + bytes: [0; N], + len: 0, + } + } + + fn as_str(&self) -> &str { + core::str::from_utf8(&self.bytes[..self.len]).expect("debug text remains UTF-8") + } +} + +impl Write for DebugText { + fn write_str(&mut self, value: &str) -> fmt::Result { + let remaining = N.saturating_sub(self.len); + if value.len() > remaining { + return Err(fmt::Error); + } + self.bytes[self.len..self.len + value.len()].copy_from_slice(value.as_bytes()); + self.len += value.len(); + Ok(()) + } +} + +fn draw_movement_debug(ui: &mut UiBuilder, movement: MovementDiagnostics) { + let mut position = DebugText::<192>::new(); + let mut timing = DebugText::<192>::new(); + let mut terrain = DebugText::<192>::new(); + let _ = write!( + &mut position, + "AUTH {:.3},{:.3} PRED {:.3},{:.3} RENDER {:.3},{:.3} CORR {:.3}c", + movement.authoritative.0, + movement.authoritative.1, + movement.predicted.0, + movement.predicted.1, + movement.rendered.0, + movement.rendered.1, + movement.correction_cells, + ); + let send_age = movement.sampled_at_ms.saturating_sub(movement.last_send_ms); + let next_send = movement.next_send_ms.saturating_sub(movement.sampled_at_ms); + let _ = write!( + &mut timing, + "INTENT {},{}{} APPLIED {} BLOCKERS {} FRAME {:.2}ms SEND AGE {}ms NEXT {}ms", + movement.intent.0, + movement.intent.1, + if movement.intent.2 { " SPRINT" } else { "" }, + movement.applied_command_id, + movement.blocker_count, + movement.frame_dt_ms, + send_age, + next_send, + ); + let _ = write!( + &mut terrain, + "GROUND PRESENT {:.3}m SAMPLE {:.3}m DELTA {:.3}m", + movement.presented_ground_y, + movement.sampled_ground_y, + movement.sampled_ground_y - movement.presented_ground_y, + ); + ui.rect(8.0, 110.0, 610.0, 59.0, [3, 8, 12, 224]); + ui.border(8.0, 110.0, 610.0, 59.0, 1.0, [42, 225, 231, 255]); + ui.text( + "SHIFT-C COLLISION RED STATIC ORANGE DYNAMIC GREEN CLEARANCE CYAN AUTH YELLOW PRED MAGENTA RENDER", + 14.0, + 115.0, + 1.0, + [220, 245, 248, 255], + ); + ui.text(position.as_str(), 14.0, 129.0, 1.0, [220, 245, 248, 255]); + ui.text(timing.as_str(), 14.0, 142.0, 1.0, [220, 245, 248, 255]); + ui.text(terrain.as_str(), 14.0, 155.0, 1.0, [220, 245, 248, 255]); +} + +struct LiveActor { + name: String, + id: String, + x: f32, + y: f32, + skin: Option, + faction: Option, + sprite: Option, + role: Option, + hair: Option, + hair_material: Option, + worn: Vec, + weapon: Option, + weapon_item_id: Option, + in_combat: bool, + alive: bool, + lifecycle_seq: i64, +} + +#[derive(Clone, Copy)] +struct InteractionProp<'a> { + id: &'a str, + kind: &'a str, + label: &'a str, + x: f32, + y: f32, +} + +fn spatial_chat_payload(message: &ChatMessage) -> Option<(&str, &str)> { + (message.channel == ChatChannel::Local + && !message.sender_id.is_empty() + && !message.text.trim().is_empty()) + .then_some((message.sender_id.as_str(), message.text.as_str())) +} + +/// Keys sampled by both connected hosts. The permanent-window entries are +/// deliberately represented by their advertised `KeyboardEvent.code` mapping +/// below rather than a second hand-maintained registry. +pub const CONNECTED_INPUT_KEYS: [Key; 24] = [ + Key::C, + Key::I, + Key::P, + Key::K, + Key::B, + Key::M, + Key::O, + Key::G, + Key::Escape, + Key::X, + Key::R, + Key::F, + Key::Space, + Key::Tab, + Key::Digit1, + Key::Digit2, + Key::Digit3, + Key::Digit4, + Key::Digit5, + Key::Digit6, + Key::Digit7, + Key::Digit8, + Key::Digit9, + Key::Digit0, +]; + +#[derive(Clone, Copy, Debug, PartialEq)] +enum ContextMenu { + Actor { x: f32, y: f32 }, + InventoryRadial { x: f32, y: f32 }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum InventoryRadialAction { + Use, + Equip, + Unequip, + Examine, + Drop, + Split, + Splice, +} + +impl InventoryRadialAction { + const fn label(self) -> &'static str { + match self { + Self::Use => "USE", + Self::Equip => "EQUIP", + Self::Unequip => "UNEQUIP", + Self::Examine => "EXAMINE", + Self::Drop => "DISCARD", + Self::Split => "SPLIT", + Self::Splice => "SPLICE", + } + } +} + +fn inventory_has_genome(row: &crate::windows::InventoryRow) -> bool { + row.metadata + .as_ref() + .is_some_and(|metadata| metadata.get("genome").is_some()) +} + +fn inventory_radial_can_use(row: &crate::windows::InventoryRow) -> bool { + matches!( + row.kind(), + crate::windows::ItemKind::Medical + | crate::windows::ItemKind::Ammo + | crate::windows::ItemKind::Currency + ) +} + +fn inventory_radial_can_equip(row: &crate::windows::InventoryRow) -> bool { + matches!( + row.kind(), + crate::windows::ItemKind::Gear | crate::windows::ItemKind::Weapon + ) +} + +fn inventory_radial_actions( + row: &crate::windows::InventoryRow, + splice_available: bool, + out: &mut [InventoryRadialAction; 6], +) -> usize { + let mut count = 0; + if inventory_radial_can_equip(row) { + out[count] = if row.equipped { + InventoryRadialAction::Unequip + } else { + InventoryRadialAction::Equip + }; + count += 1; + } + out[count] = InventoryRadialAction::Examine; + count += 1; + if inventory_radial_can_use(row) { + out[count] = InventoryRadialAction::Use; + count += 1; + } + out[count] = InventoryRadialAction::Drop; + count += 1; + if row.available > 1 { + out[count] = InventoryRadialAction::Split; + count += 1; + } + if splice_available || inventory_has_genome(row) { + out[count] = InventoryRadialAction::Splice; + count += 1; + } + count +} + +fn inventory_radial_window_action( + row: &crate::windows::InventoryRow, + action: InventoryRadialAction, +) -> Option { + use crate::windows::WindowAction; + + let command = match action { + InventoryRadialAction::Use if row.is_credit_chip() => ClientCommand::RedeemCreditChip { + container: row.container.clone(), + stack_id: row.stack_id.clone(), + }, + InventoryRadialAction::Use if row.kind() == crate::windows::ItemKind::Ammo => { + ClientCommand::RefillAmmo { + item_id: row + .item_key + .clone() + .unwrap_or_else(|| row.item_id.to_string()), + } + } + InventoryRadialAction::Use if inventory_radial_can_use(row) => { + ClientCommand::UseConsumable { + item_id: row + .item_key + .clone() + .unwrap_or_else(|| row.item_id.to_string()), + item_numeric_id: Some(row.item_id), + variant_id: Some(row.variant_id), + } + } + InventoryRadialAction::Equip | InventoryRadialAction::Unequip + if row.kind() == crate::windows::ItemKind::Gear => + { + ClientCommand::SetEquippedClothing { + item_id: row.item_id, + equipped: action == InventoryRadialAction::Equip, + container: Some(row.container.clone()), + stack_id: Some(row.stack_id.clone()), + variant_id: Some(row.variant_id), + } + } + InventoryRadialAction::Equip | InventoryRadialAction::Unequip + if row.kind() == crate::windows::ItemKind::Weapon => + { + ClientCommand::SetEquippedWeapon { + weapon_id: None, + weapon_item_id: (action == InventoryRadialAction::Equip).then_some(row.item_id), + weapon_variant_id: (action == InventoryRadialAction::Equip) + .then_some(row.variant_id), + } + } + InventoryRadialAction::Drop => ClientCommand::DiscardStack { + container: row.container.clone(), + stack_id: row.stack_id.clone(), + item_id: row.item_id, + variant_id: row.variant_id, + }, + InventoryRadialAction::Split if row.available > 1 => ClientCommand::SplitStack { + container: row.container.clone(), + stack_id: row.stack_id.clone(), + item_id: row.item_id, + variant_id: row.variant_id, + quantity: (row.available / 2).max(1) as u32, + }, + InventoryRadialAction::Examine => return Some(WindowAction::OpenWindow("examine".into())), + InventoryRadialAction::Splice => return Some(WindowAction::OpenWindow("splice".into())), + _ => return None, + }; + Some(WindowAction::Command(command)) +} + +/// Select an authoritative corpse in interact range without creating one +/// locally. A missing/invalid row is simply not interactable. +fn nearest_loot_corpse_id<'a>( + corpses: &'a [serde_json::Value], + area_id: &str, + player: (f32, f32), +) -> Option<&'a str> { + let reach_sq = crate::windows::EXTRACTOR_REACH_CELLS.powi(2); + corpses + .iter() + .filter_map(|corpse| { + let id = corpse.get("id").and_then(serde_json::Value::as_str)?; + if corpse.get("areaId").and_then(serde_json::Value::as_str) != Some(area_id) { + return None; + } + let coordinate = |axis: &str, cell_axis: &str| { + corpse + .get(axis) + .and_then(serde_json::Value::as_f64) + .or_else(|| { + corpse + .get(cell_axis) + .and_then(serde_json::Value::as_i64) + .map(|value| value as f64) + }) + .map(|value| value as f32) + }; + let (x, y) = (coordinate("x", "cellX")?, coordinate("y", "cellY")?); + let dx = x - player.0; + let dy = y - player.1; + let distance_sq = dx * dx + dy * dy; + (distance_sq.is_finite() && distance_sq <= reach_sq).then_some((id, distance_sq)) + }) + .min_by(|left, right| left.1.total_cmp(&right.1).then_with(|| left.0.cmp(right.0))) + .map(|(id, _)| id) +} + +fn dismiss_context_or_focused( + context_menu: &mut Option, + graphics_tuner: &mut crate::graphics_tuning::GraphicsTuner, + manager: &mut successor_engine_render::window::WindowManager, +) -> bool { + context_menu.take().is_some() || graphics_tuner.dismiss() || manager.close_focused().is_some() +} + +const INVENTORY_RADIAL_RADIUS: f32 = 48.0; +const INVENTORY_RADIAL_INNER_RADIUS: f32 = 27.0; +const INVENTORY_RADIAL_NUMBERS: [&str; 6] = ["1", "2", "3", "4", "5", "6"]; +const INVENTORY_RADIAL_ANGLES: [f32; 6] = [ + -core::f32::consts::FRAC_PI_2, + -core::f32::consts::FRAC_PI_4, + 0.0, + core::f32::consts::FRAC_PI_4, + core::f32::consts::FRAC_PI_2, + core::f32::consts::FRAC_PI_2 * 1.5, +]; + +// ── Character viewer, matched to the original client's object viewer ──────── +// Values derived from the decompiled `CuiWidget3dObjectListViewer` paperdoll +// configuration via the legacy oracle: a narrow portrait FOV, a drag that spins +// the doll, a multiplicative flick decay, and a resting park angle. + +/// Windows that host a live 3D character viewer, and the viewport each one +/// draws through. Viewport 0 is the world; item icons start after these. +const DOLL_WINDOWS: [&str; 4] = ["inventory", "character", "examine", "converse"]; + +/// Composite layers reserved per window band. A band holds one doll plus the +/// item-icon lanes belonging to the same window, so `z_rank * DOLL_BAND` never +/// collides with its neighbours. +pub const DOLL_BAND: i16 = 64; + +/// One window's 3D viewer: its own camera and surface over the shared pawn. +struct DollSlot { + camera: Entity, + quad: Entity, + target: RenderTargetId, + /// Live viewer cell in framebuffer pixels, for drag hit-testing. + viewport: Option<[f32; 4]>, +} + +/// Viewport id a doll slot draws through. Viewport 0 is the world. +const fn doll_viewport(slot: usize) -> u8 { + 1 + slot as u8 +} + +/// Floats per UI quad: 6 vertices of 8 floats. Splitting the UI vertex stream +/// at a quad boundary means slicing at a multiple of this. +const QUAD_FLOATS: usize = 6 * 8; + +/// Viewports a pawn is visible in: always the world, plus one bit per open +/// viewer showing that same pawn. One instance, many views. +fn doll_viewport_mask(subjects: &[Option<&str>; DOLL_WINDOWS.len()], pawn_id: &str) -> u32 { + subjects + .iter() + .enumerate() + .filter(|(_, subject)| **subject == Some(pawn_id)) + .fold(1u32, |mask, (slot, _)| mask | (1 << doll_viewport(slot))) +} + +/// Viewer field of view: 22.5°, the original's paperdoll FOV. Narrow on +/// purpose — it keeps the doll portrait-flat instead of wide-angle distorted. +const PAPERDOLL_FOVY: f32 = core::f32::consts::PI / 8.0; +/// Yaw applied per pixel of horizontal drag. +const PAPERDOLL_DRAG_YAW_PER_PX: f32 = 0.010; +/// Flick decay per 60 Hz frame; resolved against real dt when applied. +const PAPERDOLL_SPIN_DECAY: f32 = 0.96; +/// Spin ceiling in rad/s. +const PAPERDOLL_MAX_SPIN: f32 = 1.0; +/// Below this the flick is spent and the doll holds where the player left it. +const PAPERDOLL_SPIN_EPS: f32 = 0.02; +/// Default viewer offset: zero, so the camera sits in front of the character's +/// own facing and the doll always presents front-on no matter which way the +/// pawn is turned in the world. The original's viewers likewise open on a +/// fixed front view (`ViewerState::new` starts at yaw PI in its own frame); +/// its 215° resting angle is only where an auto-rotating object parks, and +/// inventory objects do not auto-rotate unless the player asks them to. +const PAPERDOLL_RESTING_YAW: f32 = 0.0; + +/// Pointer slop for selecting a streamed actor, in framebuffer pixels. Sized +/// to the projected torso at the shipped camera pitch, not to the sprite. +const ACTOR_PICK_RADIUS_PX: f32 = 32.0; +/// Pointer slop for a world verb prop. Tighter than an actor: a door is a +/// fixed volume and should not steal the pointer from the ground behind it. +const PROP_PICK_RADIUS_PX: f32 = 26.0; + +fn draw_inventory_radial_arc( + ui: &mut successor_engine_render::ui::UiBuilder, + x: f32, + y: f32, + start: f32, + end: f32, +) { + let steps = 12; + let mut previous = ( + x + libm::cosf(start) * INVENTORY_RADIAL_RADIUS, + y + libm::sinf(start) * INVENTORY_RADIAL_RADIUS, + ); + for step in 1..=steps { + let t = step as f32 / steps as f32; + let angle = start + (end - start) * t; + let next = ( + x + libm::cosf(angle) * INVENTORY_RADIAL_RADIUS, + y + libm::sinf(angle) * INVENTORY_RADIAL_RADIUS, + ); + ui.line( + previous.0, + previous.1, + next.0, + next.1, + 1.5, + [34, 206, 222, 236], + ); + previous = next; + } +} + +fn inventory_radial_capsule_rect( + ui: &UiBuilder, + anchor: (f32, f32), + action_index: usize, + label: &str, + screen: (f32, f32), +) -> [f32; 4] { + let angle = INVENTORY_RADIAL_ANGLES[action_index]; + let (sin, cos) = libm::sincosf(angle); + let edge_x = anchor.0 + cos * INVENTORY_RADIAL_RADIUS; + let edge_y = anchor.1 + sin * INVENTORY_RADIAL_RADIUS; + let width = (ui.measure_text(label, 1.15) + 27.0).ceil(); + let height = 18.0; + let x = if cos >= -0.1 { + edge_x + cos * 9.0 + 3.0 + } else { + edge_x + cos * 9.0 - width - 3.0 + } + .clamp(3.0, (screen.0 - width - 3.0).max(3.0)); + let y = (edge_y + sin * 9.0 - height * 0.5).clamp(3.0, (screen.1 - height - 3.0).max(3.0)); + [x, y, width, height] +} + +fn draw_inventory_radial( + ui: &mut successor_engine_render::ui::UiBuilder, + row: &crate::windows::InventoryRow, + anchor: (f32, f32), + splice_available: bool, + screen: (f32, f32), +) -> (bool, Option) { + let mut actions = [InventoryRadialAction::Examine; 6]; + let action_count = inventory_radial_actions(row, splice_available, &mut actions); + draw_inventory_radial_arc( + ui, + anchor.0, + anchor.1, + -core::f32::consts::PI * 0.82, + core::f32::consts::FRAC_PI_2 * 1.12, + ); + ui.ring( + anchor.0, + anchor.1, + INVENTORY_RADIAL_INNER_RADIUS, + 12, + 1.0, + [21, 122, 135, 236], + ); + ui.line( + anchor.0, + anchor.1 - INVENTORY_RADIAL_INNER_RADIUS, + anchor.0, + anchor.1 - INVENTORY_RADIAL_RADIUS + 5.0, + 1.4, + [51, 221, 231, 246], + ); + + let (mx, my) = ui.mouse(); + let mut close = false; + let mut selected = None; + for (index, action) in actions.iter().take(action_count).copied().enumerate() { + let angle = INVENTORY_RADIAL_ANGLES[index]; + let (sin, cos) = libm::sincosf(angle); + let from = ( + anchor.0 + cos * INVENTORY_RADIAL_RADIUS, + anchor.1 + sin * INVENTORY_RADIAL_RADIUS, + ); + let [x, y, width, height] = + inventory_radial_capsule_rect(ui, anchor, index, action.label(), screen); + let response = ui.interact(x, y, width, height); + let fill = if response.hovered { + [12, 63, 70, 248] + } else { + [3, 31, 36, 238] + }; + ui.line( + from.0, + from.1, + x + if cos >= -0.1 { 2.0 } else { width - 2.0 }, + y + height * 0.5, + 1.0, + [34, 174, 188, 222], + ); + ui.rect(x, y, width, height, fill); + ui.line( + x + 3.0, + y + 1.0, + x + width - 3.0, + y + 1.0, + 1.0, + [50, 191, 202, 220], + ); + ui.text( + INVENTORY_RADIAL_NUMBERS[index], + x + 5.0, + y + 5.0, + 1.1, + [214, 242, 244, 255], + ); + ui.text(action.label(), x + 15.0, y + 5.0, 1.1, [229, 244, 245, 255]); + if response.clicked { + selected = inventory_radial_window_action(row, action); + close = true; + } + } + let dx = mx - anchor.0; + let dy = my - anchor.1; + let in_ring = dx * dx + dy * dy <= (INVENTORY_RADIAL_RADIUS + 8.0).powi(2); + let in_capsule = actions + .iter() + .take(action_count) + .enumerate() + .any(|(index, action)| { + let [x, y, width, height] = + inventory_radial_capsule_rect(ui, anchor, index, action.label(), screen); + UiBuilder::hit(x, y, width, height, mx, my) + }); + if ui.interact(0.0, 0.0, screen.0, screen.1).pressed && !in_ring && !in_capsule { + close = true; + } + (close, selected) +} + +/// UI cue an accepted command earns on settlement. +/// +/// Mirrors the web client's `item_transfer` / `credits_chime` / +/// `area_transition` vocabulary: the sound belongs to the authority's receipt, +/// not to the click, so a refused transfer never sounds like a completed one. +fn ui_cue_for_settled(command: &ClientCommand) -> Option { + match command { + ClientCommand::SetEquippedWeapon { .. } + | ClientCommand::SetEquippedClothing { .. } + | ClientCommand::BankStoreItem { .. } + | ClientCommand::BankRetrieveItem { .. } + | ClientCommand::TakeLootItem { .. } + | ClientCommand::HarvestCorpse { .. } + | ClientCommand::DiscardStack { .. } + | ClientCommand::SplitStack { .. } + | ClientCommand::MergeStacks { .. } + | ClientCommand::StoreToExchange { .. } => Some(crate::audio::UiCue::ItemTransfer), + ClientCommand::RedeemCreditChip { .. } => Some(crate::audio::UiCue::CreditsChime), + ClientCommand::EnterTransition { .. } => Some(crate::audio::UiCue::AreaTransition), + _ => None, + } +} + +/// One optional JSON value per persisted section, in the order +/// `ConnectedScene::take_persisted` returns them: theme, toolbar, split snap, +/// waypoints, macros, window layout, UI opacity. +pub type PersistedSections = ( + Option, + Option, + Option, + Option, + Option, + Option, + Option, +); + +/// Active travel transition: the loading screen stays up until the +/// destination's spawn-neighborhood props stream in, or the deadline forces +/// fail-closed marker placement. +struct TravelHold { + deadline_ms: u64, +} + +pub struct ConnectedScene { + pub world: GameWorld, + pub renderer: Renderer, + pub store: AuthorityStore, + pawn_catalog: PawnCatalog, + terrain: TerrainStreamer, + slice: successor_engine_core::json::Json, + props_loader: PropsLoader, + collision_debug: CollisionDebugOverlay, + movement_collision: crate::world::movement_collision::MovementCollisionWorld, + loaded_area_id: String, + streamed_world: StreamedWorld, + pawns: HashMap, + missing_pawns: Vec, + /// Motion parked by a wardrobe rebuild, claimed by the respawn that + /// follows it in the same frame. + carried_motion: HashMap, + stale_pawns: Vec, + center: Vec3, + /// Smoothed camera anchor. Gameplay, streaming, and GI continue to use + /// `center`, which is the player's exact rendered position. + camera_center: Vec3, + camera_follow_initialized: bool, + follow: Entity, + sun: Entity, + combat_fx: CombatFx, + /// One live 3D viewer per doll-capable window. The original gives every + /// `CuiWidget3dObjectViewer` its own camera over the *same* world object, + /// so the player is on screen in the world and in every open viewer at + /// once; these slots are that, one per window. + dolls: [DollSlot; DOLL_WINDOWS.len()], + /// Viewer turntable yaw offset, in radians, applied on top of the pawn's + /// own facing. A drag spins it; the flick then decays and parks. + paperdoll_yaw: f32, + /// Viewer spin velocity in rad/s, decaying after a drag is released. + paperdoll_spin: f32, + /// Pointer x at the start of a viewer drag, while the button is held. + paperdoll_drag_x: Option, + item_previews: ItemPreviewRenderer, + fx_buf: Vec, + icons: Icons, + ui: successor_engine_render::ui::UiBuilder, + hud_state: HudState, + overlays: hud::overlays::Overlays, + last_dialogue_tick: i64, + last_applied_move_command_id: u64, + toolbar: hud::toolbar::Toolbar, + waypoints: hud::waypoints::WaypointStore, + macro_runtime: crate::game::macro_runtime::MacroRuntime, + macro_actions: Vec, + hud_actions: Vec, + theme_index: usize, + dust_strength: f32, + /// Workspace frame fill opacity (persisted player setting). + window_opacity: f32, + /// HUD pane fill opacity, separate because the HUD sits over gameplay for + /// the whole session while a window is transient. + hud_opacity: f32, + split_snap: u32, + rebind_pending: Option, + preferences_dirty: bool, + /// Layout changes that do not alter manager geometry: visibility, focus + /// order, iconification, and HUD pane locks. + window_layout_dirty: bool, + pending_bug_report: Option, + bug_report_sequence: u32, + right_was_down: bool, + hud_right_was_down: bool, + /// A pointer route consumed a right-click to toggle one HUD pane's layout + /// lock. The render pass consumes this once to gate HUD controls on that + /// same input edge without toggling the lock a second time. + hud_layout_input_consumed: bool, + /// Whether the client currently owns the pointer and is drawing it. Only + /// the transition touches the platform, so a hidden desktop cursor is not + /// re-asserted every frame. + owns_cursor: bool, + /// View-projection retained from the last drawn frame; see + /// [`ConnectedScene::viewport_projection`]. + pointer_vp: Option, + /// A fresh registry or an older workspace document can be missing + /// individual panes. Only those slots receive first-run defaults. + hud_defaults_pending: [bool; hud::HUD_SURFACE_COUNT], + /// Viewport the live HUD rects are anchored to. `load_persisted` stamps it + /// from the restored document; `frame` re-anchors whenever the real + /// framebuffer disagrees. Construction cannot decide this: the scene is + /// built before the first framebuffer is known, so a layout matched at that + /// point is matched against a placeholder. + hud_layout_viewport: Option<(f32, f32)>, + framebuffer: (u32, u32), + selected_actor_id: Option, + selected_inventory: Option<(String, String)>, + loot_corpse_id: Option, + context_menu: Option, + left_was_down: bool, + pointer_prev: (f32, f32), + zoom_percent: f32, + key_was_down: [bool; Key::COUNT], + /// Persistent sprint toggle (X), independent of held Shift. + sprint_toggle: bool, + pub loading_screen: crate::screens::LoadingScreen, + pub loading: bool, + window_order: Vec, + /// `(ui quad count, window draw rank)` captured while the windows draw, so + /// the frame can flush the UI up to each window and composite its 3D + /// surfaces there. Reused every frame; never reallocated. + composite_marks: Vec<(u32, i16)>, + window_id_scratch: String, + wm: successor_engine_render::window::WindowManager, + win_model: crate::windows::WindowModel, + graphics_tuner: crate::graphics_tuning::GraphicsTuner, + command_queue: Option, + pending_window_commands: Vec, + window_rejection: Option, + weather: successor_engine_render::weather::Weather, + environs: Environs, + sfx: crate::audio::SfxPlayer, + weather_audio: Option<&'static str>, + music_audio: Option<&'static str>, + music_combat_index: u32, + music_was_in_combat: bool, + settlement_audio: bool, + footstep_distance: f32, + footstep_index: u32, + footstep_position: Option<(f32, f32)>, + ambience_timer: f32, + ambience_roll: u32, + player_id: String, + shard_id: String, + area_id: String, + /// Transient muzzle-flash point lights: (entity, remaining seconds). + muzzle_lights: Vec<(Entity, f32)>, + sim_time: f32, + last_frame_dt: f32, + movement_timing: movement::MovementTiming, + movement_sampled_at_ms: u64, + move_intent: (i32, i32, bool), + walk_speed_cells_per_second: f32, + sprint_speed_cells_per_second: f32, + click_route: Vec<(i32, i32)>, + click_route_index: usize, + click_goal: Option<(i32, i32)>, + /// Inspection orbit. `None` is the shipped locked camera. + debug_camera: Option, + /// Bounded async asset fetches (wardrobe, creatures, region props). + streamer: crate::assets::stream::AssetStreamer, + /// Travel transition in progress, if any. + travel_hold: Option, + /// Scratch id list for the streamed-gear retry pass (reused each frame). + gear_retry: Vec, +} + +fn follow_focus(ground: Vec3) -> Vec3 { + ground.add(vec3(0.0, ADULT_PAWN_HEIGHT_METERS * 0.5, 0.0)) +} + +fn follow_eye(ground: Vec3) -> Vec3 { + // Locked north-up reference camera: 96 m from focus at a 60° pitch. + let distance = 96.0; + let pitch = 60.0_f32.to_radians(); + follow_focus(ground).add(vec3(0.0, distance * pitch.sin(), distance * pitch.cos())) +} + +/// Free orbit for inspection, off by default. +/// +/// The shipped camera is locked north-up at a fixed pitch, which is correct for +/// play and useless for judging geometry: you cannot see which way a door +/// slides, how far it travels, or whether a wall sits where its collision says, +/// from one angle directly overhead. This orbits the same focus without +/// touching the game, so the world can be read from any side. +#[derive(Clone, Copy)] +struct DebugCamera { + yaw: f32, + pitch: f32, + distance: f32, +} + +impl Default for DebugCamera { + fn default() -> Self { + // Opens exactly on the shipped view, so switching it on changes nothing + // until it is actually moved. + Self { + yaw: 0.0, + pitch: 60.0_f32.to_radians(), + distance: 96.0, + } + } +} + +impl DebugCamera { + /// Smallest pitch that still looks down at the ground, and the largest that + /// has not flipped over the top. + const MIN_PITCH: f32 = 0.12; + const MAX_PITCH: f32 = 1.50; + + fn eye(self, ground: Vec3) -> Vec3 { + let horizontal = self.distance * self.pitch.cos(); + follow_focus(ground).add(vec3( + horizontal * self.yaw.sin(), + self.distance * self.pitch.sin(), + horizontal * self.yaw.cos(), + )) + } + + fn orbit(&mut self, yaw: f32, pitch: f32) { + self.yaw = (self.yaw + yaw).rem_euclid(core::f32::consts::TAU); + self.pitch = (self.pitch + pitch).clamp(Self::MIN_PITCH, Self::MAX_PITCH); + } + + fn dolly(&mut self, factor: f32) { + self.distance = (self.distance * factor).clamp(6.0, 240.0); + } +} + +/// Registration geometry for a window id. The surface spec owns default bounds +/// and the resize floor (`windows::spec`), so a frame's size, its minimum, and +/// its family density all come from one table. `index` is retained for the +/// caller's registration order; the spec's per-surface anchor replaces the old +/// cascade offset, which only produced distinct positions at one framebuffer. +fn window_geometry(id: &str, _index: usize) -> ([f32; 4], f32, f32) { + crate::windows::spec::geometry(id, WINDOW_BASELINE.0, WINDOW_BASELINE.1) +} + +/// Viewport the registration defaults are evaluated against. `WindowManager` +/// clamps to the live framebuffer on the first frame, and the inventory frame is +/// a fixed 660x521 at every framebuffer, so this baseline only decides where +/// viewport-relative frames start before the player moves them. +const WINDOW_BASELINE: (f32, f32) = (1280.0, 720.0); + +const WINDOW_LAYOUT_SCHEMA: &str = "successor.window-layout.v1"; + +/// Viewport a saved layout document was captured at, when it records one. +/// A document predating the field returns `None` and is treated as foreign. +fn layout_viewport(value: Option<&serde_json::Value>) -> Option<(f32, f32)> { + let saved = value? + .get("viewport")? + .as_array() + .filter(|saved| saved.len() == 2)?; + Some((saved[0].as_f64()? as f32, saved[1].as_f64()? as f32)) +} + +/// Whether HUD rects stamped for `stamp` still belong to the `live` viewport. +/// A document with no stamp is foreign and always re-anchors. +fn hud_anchor_is_stale(stamp: Option<(f32, f32)>, live: (f32, f32)) -> bool { + stamp.is_none_or(|stamp| (stamp.0 - live.0).abs() >= 0.5 || (stamp.1 - live.1).abs() >= 0.5) +} + +/// Restore geometry plus workspace visibility/order and HUD lock state. +/// Returns one `true` slot for each HUD pane absent from the document, so a +/// schema predating a newly registered pane preserves every rect it did save. +/// +/// Saved HUD rects are applied verbatim here. Whether they still fit is not +/// knowable at restore time — the scene is constructed before the first +/// framebuffer arrives — so the caller stamps [`layout_viewport`] and +/// `ConnectedScene::frame` re-anchors once the real viewport disagrees. +fn restore_window_layout( + manager: &mut successor_engine_render::window::WindowManager, + value: Option<&serde_json::Value>, +) -> [bool; crate::hud::HUD_SURFACE_COUNT] { + let Some(rows) = value + .filter(|document| { + document.get("schema").and_then(serde_json::Value::as_str) == Some(WINDOW_LAYOUT_SCHEMA) + }) + .and_then(|document| document.get("windows")) + .and_then(serde_json::Value::as_array) + else { + return [true; crate::hud::HUD_SURFACE_COUNT]; + }; + + let mut missing_hud = [true; crate::hud::HUD_SURFACE_COUNT]; + let mut open_rows = Vec::new(); + for (row_index, row) in rows.iter().enumerate() { + let Some(id) = row.get("id").and_then(serde_json::Value::as_str) else { + continue; + }; + let Some(raw) = row.get("bounds").and_then(serde_json::Value::as_array) else { + continue; + }; + if raw.len() != 4 { + continue; + } + let mut bounds = [0.0; 4]; + let mut valid = true; + for (index, value) in raw.iter().enumerate() { + let Some(number) = value.as_f64() else { + valid = false; + break; + }; + bounds[index] = number as f32; + } + let is_hud = crate::hud::HUD_SURFACES + .iter() + .position(|surface| surface.id == id); + let apply_geometry = valid; + if apply_geometry && manager.set_rect(id, bounds) { + if let Some(index) = is_hud { + missing_hud[index] = false; + } + } + if let Some(locked) = row.get("locked").and_then(serde_json::Value::as_bool) { + crate::hud::set_hud_surface_locked(manager, id, locked); + } + if let Some(open) = row.get("open").and_then(serde_json::Value::as_bool) { + manager.close(id); + if open { + let z = row + .get("z") + .and_then(serde_json::Value::as_u64) + .unwrap_or(row_index as u64); + let iconified = row + .get("iconified") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + open_rows.push((z, row_index, id, iconified)); + } + } + } + // Opening back-to-front recreates the saved focus order without exposing + // the manager's internal z counter in the persistence schema. + open_rows.sort_unstable_by_key(|(z, row_index, _, _)| (*z, *row_index)); + for (_, _, id, iconified) in open_rows { + manager.open(id); + if iconified { + let _ = manager.iconify(id); + } + } + missing_hud +} + +fn save_window_layout( + manager: &successor_engine_render::window::WindowManager, + viewport: (f32, f32), +) -> serde_json::Value { + let mut z_order = Vec::new(); + manager.fill_z_order(&mut z_order); + let mut windows = Vec::new(); + manager.for_each_geometry(|id, bounds| { + let mut row = serde_json::Map::new(); + row.insert("id".into(), serde_json::Value::String(id.into())); + row.insert("bounds".into(), serde_json::json!(bounds)); + row.insert("open".into(), serde_json::Value::Bool(manager.is_open(id))); + if let Some(z) = z_order + .iter() + .position(|index| manager.window_id(*index) == id) + { + row.insert("z".into(), serde_json::Value::from(z as u64)); + } + if crate::hud::is_hud_surface(id) { + row.insert( + "locked".into(), + serde_json::Value::Bool(!manager.is_interactive(id)), + ); + row.insert( + "iconified".into(), + serde_json::Value::Bool(manager.is_iconified(id)), + ); + } + windows.push(serde_json::Value::Object(row)); + }); + serde_json::json!({ + "schema": WINDOW_LAYOUT_SCHEMA, + "viewport": [viewport.0, viewport.1], + "windows": windows, + }) +} + +fn filtered_gait_speed(previous: f32, displacement_cells: f32, dt_seconds: f32) -> f32 { + if dt_seconds <= 0.0 { + return previous; + } + if displacement_cells > 2.0 { + return 0.0; + } + let instantaneous = displacement_cells / dt_seconds; + let alpha = 1.0 - (-dt_seconds / 0.05).exp(); + previous + (instantaneous - previous) * alpha +} + +const GROUND_HYSTERESIS_METERS: f32 = 0.025; +const GROUND_SNAP_METERS: f32 = 3.0; +const GROUND_FOLLOW_METERS_PER_SECOND: f32 = 6.0; + +fn smooth_ground_height(current: f32, target: f32, dt_seconds: f32) -> f32 { + if !current.is_finite() || !target.is_finite() { + return target; + } + let delta = target - current; + let distance = delta.abs(); + if distance <= GROUND_HYSTERESIS_METERS { + return current; + } + if distance >= GROUND_SNAP_METERS { + return target; + } + let step = (GROUND_FOLLOW_METERS_PER_SECOND * dt_seconds.max(0.0)) + .min(distance - GROUND_HYSTERESIS_METERS); + current + delta.signum() * step +} + +impl ConnectedScene { + /// Build renderer resources from stable asset ids. Area-scoped terrain and + /// props are deferred until the first accepted authority snapshot. + pub fn build( + gpu: &mut G, + player_id: &str, + read_asset: &mut dyn FnMut(&str) -> Option>, + ) -> Result { + let mapping = read_asset("render/props-mapping.json") + .and_then(|bytes| String::from_utf8(bytes).ok()) + .ok_or_else(|| "required asset missing: render/props-mapping.json".to_string())?; + let slice_str = read_asset("successor-slice/open-desert-slice.json") + .and_then(|bytes| String::from_utf8(bytes).ok()) + .ok_or_else(|| { + "required asset missing: successor-slice/open-desert-slice.json".to_string() + })?; + let slice = successor_engine_core::json::Json::parse(&slice_str) + .map_err(|_| "slice parse".to_string())?; + let mut renderer = crate::configured_renderer(gpu).expect("renderer initialization failed"); + // Time-of-day owns fog and its authored base grade; the render settings + // asset owns ambient, sun, bloom, shadows, AA, AO, and mastering. + let env = environment::sample(720.0); + renderer.set_fog(env.fog, 160.0, 340.0); + renderer.set_grade( + env.bone_tint, + env.desaturate, + env.scene_darken, + env.black_lift, + ); + let mut world = GameWorld::new(); + + let center = Vec3::ZERO; + renderer.gi_set_focus([center.x, center.y, center.z]); + + // Empty until the accepted player area is known. `sync_active_area` + // creates the correctly seeded/biomed streamer and scoped prop set. + let streamer = TerrainStreamer::new( + crate::world::area::FALLBACK_WORLD_SEED as i32, + Biome::Desert, + 64.0 * WORLD_UNITS_PER_CELL as f64, + 3, + 0b1, + ); + let loader = PropsLoader::new(&mapping).map_err(|_| "props loader".to_string())?; + let pawn_catalog = PawnCatalog::load(gpu, &mut renderer, read_asset)?; + let sun_angle = -45.0_f32.to_radians(); + let (sun_sin, sun_cos) = sun_angle.sin_cos(); + let sun_dir = vec3( + env.sun_dir[0] * sun_cos + env.sun_dir[2] * sun_sin, + env.sun_dir[1], + -env.sun_dir[0] * sun_sin + env.sun_dir[2] * sun_cos, + ) + .normalize(); + let sun = world.spawn(); + world.set_component( + sun, + DirectionalLight { + dir: sun_dir, + color: env.sun_color, + cast_shadows: true, + }, + ); + + // Follow camera. The tactical radar is rendered by the HUD from + // authority contacts; a second live world view duplicated it, obscured + // the top-right instrument and cost an avoidable render pass. + let follow = world.spawn(); + world.set_component( + follow, + Camera { + viewport_id: 0, + order: 0, + projection: Projection::Ortho { + half_height: 12.5, + near: 0.1, + far: 320.0, + }, + target: CamTarget::Screen(RectNorm::FULL), + clear: ClearSpec { + color: Some([env.fog[0], env.fog[1], env.fog[2], 1.0]), + depth: Some(1.0), + }, + eye: follow_eye(center), + look_at: follow_focus(center), + up: Vec3::Y, + }, + ); + let dolls = core::array::from_fn(|_| DollSlot { + camera: world.spawn(), + quad: world.spawn(), + target: gpu.create_render_target(&RenderTargetDesc { + width: 384, + height: 640, + color: true, + depth: true, + filter: Filter::Linear, + }), + viewport: None, + }); + + let item_previews = ItemPreviewRenderer::new(gpu, &mut world); + // Combat FX + HUD. + let glow = glow_sprite(64); + renderer.set_particle_atlas(gpu, 64, 64, &glow); + let icons = Icons::load(); + renderer.set_ui_atlas(gpu, icons.meta.width, icons.meta.height, &icons.rgba); + let ui = icons.ui_builder(); + // Interactive window manager: register the game windows with cascaded + // bounds + toolbar icons (opened from the action bar). + let mut wm = successor_engine_render::window::WindowManager::new(); + let mut window_index = 0usize; + for (id, title, icon, _) in crate::hud::PERMANENT_WINDOWS { + let (bounds, min_w, min_h) = window_geometry(id, window_index); + wm.register(id, title, icons.cell(icon), bounds, min_w, min_h); + window_index += 1; + } + for (id, title, icon) in crate::hud::CONTEXT_WINDOWS { + let (bounds, min_w, min_h) = window_geometry(id, window_index); + wm.register(id, title, icons.cell(icon), bounds, min_w, min_h); + window_index += 1; + } + hud::register_hud_surfaces(&mut wm, &icons); + wm.set_title( + "inventory", + &format!("{} (INVENTORY)", player_id.to_ascii_uppercase()), + ); + let weather = successor_engine_render::weather::Weather::new(0x0d3d); + let sfx = { + let mut player = crate::audio::SfxPlayer::new(); + if let Some(manifest) = read_asset("successor-audio/sfx/manifest.json") + .and_then(|bytes| String::from_utf8(bytes).ok()) + { + player.load_with(&manifest, read_asset); + } + player + }; + let collision_debug = CollisionDebugOverlay::new(&mut renderer, gpu); + + Ok(Self { + world, + renderer, + store: AuthorityStore::new(), + pawn_catalog, + terrain: streamer, + carried_motion: HashMap::new(), + pawns: HashMap::new(), + missing_pawns: Vec::with_capacity(32), + stale_pawns: Vec::with_capacity(8), + follow, + slice, + props_loader: loader, + collision_debug, + movement_collision: crate::world::movement_collision::MovementCollisionWorld::default(), + sun, + loaded_area_id: String::new(), + streamed_world: StreamedWorld::new(), + dolls, + paperdoll_yaw: PAPERDOLL_RESTING_YAW, + paperdoll_spin: 0.0, + paperdoll_drag_x: None, + item_previews, + combat_fx: CombatFx::new(0x51ce_57ed), + fx_buf: Vec::with_capacity(64 * 1024), + icons, + ui, + hud_state: HudState::default(), + overlays: hud::overlays::Overlays::new(), + last_dialogue_tick: i64::MIN, + last_applied_move_command_id: 0, + toolbar: hud::toolbar::Toolbar::new(hud::toolbar::ToolbarDoc::default_loadout()), + waypoints: hud::waypoints::WaypointStore::new(), + macro_runtime: crate::game::macro_runtime::MacroRuntime::default(), + macro_actions: Vec::with_capacity(crate::game::macro_runtime::STEPS_PER_TICK_MAX), + hud_actions: Vec::with_capacity(8), + theme_index: 0, + dust_strength: 0.5, + window_opacity: 0.92, + hud_opacity: 0.90, + split_snap: 100, + rebind_pending: None, + preferences_dirty: false, + window_layout_dirty: false, + pending_bug_report: None, + bug_report_sequence: 0, + hud_right_was_down: false, + right_was_down: false, + hud_layout_input_consumed: false, + owns_cursor: false, + pointer_vp: None, + hud_defaults_pending: [true; hud::HUD_SURFACE_COUNT], + left_was_down: false, + pointer_prev: (0.0, 0.0), + zoom_percent: 100.0, + key_was_down: [false; Key::COUNT], + sprint_toggle: false, + hud_layout_viewport: None, + framebuffer: (1280, 720), + selected_actor_id: None, + selected_inventory: None, + loot_corpse_id: None, + context_menu: None, + wm, + window_order: Vec::with_capacity(32), + composite_marks: Vec::with_capacity(32), + window_id_scratch: String::with_capacity(32), + win_model: crate::windows::WindowModel::default(), + graphics_tuner: crate::graphics_tuning::GraphicsTuner::new(), + command_queue: None, + pending_window_commands: Vec::with_capacity(16), + window_rejection: None, + weather, + player_id: player_id.to_string(), + shard_id: String::new(), + environs: Environs::new(), + sfx, + weather_audio: None, + music_audio: None, + music_combat_index: 0, + music_was_in_combat: false, + settlement_audio: false, + footstep_distance: 0.0, + footstep_index: 0, + footstep_position: None, + ambience_timer: 1.0, + ambience_roll: 0, + area_id: String::new(), + center, + camera_center: center, + camera_follow_initialized: false, + muzzle_lights: Vec::with_capacity(32), + sim_time: 0.0, + last_frame_dt: 0.0, + movement_timing: movement::MovementTiming::default(), + movement_sampled_at_ms: 0, + move_intent: (0, 0, false), + click_route: Vec::new(), + walk_speed_cells_per_second: crate::game::prediction::BASE_SPEED_CELLS, + sprint_speed_cells_per_second: crate::game::prediction::BASE_SPEED_CELLS + * crate::game::prediction::SPRINT_MULTIPLIER, + click_route_index: 0, + click_goal: None, + debug_camera: None, + loading_screen: { + let mut screen = + crate::screens::LoadingScreen::new("PLANETFALL", "AWAITING WORLD SNAPSHOT"); + screen.set_indeterminate(true); + screen + }, + loading: true, + streamer: crate::assets::stream::AssetStreamer::new(), + travel_hold: None, + gear_retry: Vec::new(), + }) + } + + pub fn on_snapshot(&mut self, snap: &GameShardSnapshot) { + self.loading = false; + self.shard_id = snap.shard_id.clone(); + self.area_id = snap + .actors + .get(&snap.player_actor_id) + .map(|a| a.area_id.clone()) + .unwrap_or_default(); + self.store.apply_snapshot(snap); + self.project_windows(); + } + + pub fn set_loading(&mut self, loading: bool) { + self.loading = loading; + } + pub fn shard_id(&self) -> Option<&str> { + (!self.shard_id.is_empty()).then_some(self.shard_id.as_str()) + } + pub fn area_id(&self) -> Option<&str> { + (!self.area_id.is_empty()).then_some(self.area_id.as_str()) + } + pub fn player_actor(&self) -> Option<&successor_client_proto::packets::GameActorSnapshot> { + self.store.actors.get(&self.store.player_actor_id) + } + pub fn on_delta(&mut self, delta: &GameShardDelta) { + self.shard_id = delta.shard_id.clone(); + self.store.apply_delta(delta); + if let Some(actor) = self.player_actor() { + self.area_id = actor.area_id.clone(); + } + self.project_windows(); + } + /// Apply a targeted room message - the per-player results of gameplay + /// commands, which arrive beside the world packet stream rather than in it. + pub fn apply_room_message(&mut self, msg_type: &str, payload: &serde_json::Value) { + self.store.apply_room_message(msg_type, payload); + self.project_windows(); + } + + pub fn apply_server_packet(&mut self, packet: GameServerPacket) { + match packet { + GameServerPacket::Snapshot { + snapshot, + receipts, + events, + compact_events, + } => { + self.on_snapshot(&snapshot); + self.settle_packet_receipts(&receipts); + self.ingest_packet_events(&events); + self.ingest_packet_events(compact_events.as_deref().unwrap_or(&[])); + } + GameServerPacket::Delta { + delta, + receipts, + events, + compact_events, + } => { + self.on_delta(&delta); + self.settle_packet_receipts(&receipts); + self.ingest_packet_events(&events); + self.ingest_packet_events(compact_events.as_deref().unwrap_or(&[])); + } + GameServerPacket::Receipts { + receipts, + events, + compact_events, + } => { + self.settle_packet_receipts(&receipts); + self.ingest_packet_events(&events); + self.ingest_packet_events(compact_events.as_deref().unwrap_or(&[])); + } + GameServerPacket::Acks { + acks, + movement_profile, + player_actor, + player_position, + events, + compact_events, + } => { + for ack in acks { + self.settle_command(ack.0, ack.1 != 0, ack.3); + } + if let Some(player_actor) = player_actor { + self.on_player_pos(player_actor.x, player_actor.y); + } else if let Some(position) = player_position.as_ref() { + self.on_player_pos(position.0, position.1); + } + if let Some(applied_command_id) = player_position.and_then(|position| position.2) { + self.last_applied_move_command_id = + self.last_applied_move_command_id.max(applied_command_id); + } + if let Some(profile) = movement_profile { + self.walk_speed_cells_per_second = + profile.walk_speed_milli_per_second.max(0) as f32 / 1_000.0; + self.sprint_speed_cells_per_second = + profile.sprint_speed_milli_per_second.max(0) as f32 / 1_000.0; + } + if let Some(events) = events { + self.ingest_packet_events(&events); + } + self.ingest_packet_events(compact_events.as_deref().unwrap_or(&[])); + } + _ => {} + } + } + + fn settle_packet_receipts(&mut self, receipts: &[GameCommandReceipt]) { + for receipt in receipts { + self.settle_command( + receipt.command_id, + receipt.accepted, + receipt.reason_code.clone(), + ); + } + } + + fn ingest_packet_events(&mut self, events: &[serde_json::Value]) { + for event in events { + if let Some(combat) = crate::game::combat_fx::CombatEvent::from_json(event) { + self.ingest_combat(&combat); + } + } + } + + /// Rebuild the live window sections from the accepted store. Wholesale, so + /// a present-empty wire section clears the prior rows and an absent player + /// actor clears the player-scoped summaries. Runs per applied packet, not + /// per frame. + fn project_windows(&mut self) { + use crate::windows::model::{ + BuildCatalogItem, BuildGhost, Gate, TrainerView, TravelCity, TravelPlanet, + }; + use crate::windows::project::ProjectContext; + + let pending = self + .command_queue + .as_ref() + .map(|queue| { + queue + .pending_envelopes() + .map(|envelope| { + ( + envelope.command_id, + crate::game::command_queue::command_kind(&envelope.command), + ) + }) + .collect() + }) + .unwrap_or_default(); + let player = self.store.actors.get(&self.player_id); + if let Some(actor) = player { + let name = hud::clean_actor_name(&actor.display_name, &actor.label, &self.player_id); + self.wm.set_title( + "inventory", + &format!("{} (INVENTORY)", name.to_ascii_uppercase()), + ); + } + let player_cell = player.map(|actor| (actor.x, actor.y)).unwrap_or((0.0, 0.0)); + let mut context = ProjectContext { + selected_actor_id: self.selected_actor_id.clone(), + selected_inventory: self.selected_inventory.clone(), + loot_corpse_id: self.loot_corpse_id.clone(), + pending, + now_ms: successor_platform::now_ms(), + ..ProjectContext::default() + }; + context.build_catalog = [ + ( + "floor_1x1", + "FLOOR PANEL", + "floors", + vec![("structural", 2)], + 1, + 1, + false, + ), + ( + "wall_1m", + "WALL SEGMENT", + "walls", + vec![("structural", 2)], + 1, + 0, + false, + ), + ( + "door_slide_1m", + "SLIDE DOOR", + "openings", + vec![("structural", 3), ("mechanical", 1)], + 1, + 0, + true, + ), + ( + "window_1m", + "WINDOW", + "openings", + vec![("structural", 2), ("glass", 1)], + 1, + 0, + false, + ), + ( + "roof_1x1", + "ROOF PANEL", + "roofs", + vec![("structural", 2)], + 1, + 1, + false, + ), + ] + .into_iter() + .map( + |(id, label, category, costs, w, h, is_door)| BuildCatalogItem { + catalog_id: id.into(), + label: label.into(), + category: category.into(), + costs: costs + .into_iter() + .map(|(material, units)| (material.into(), units)) + .collect(), + w, + h, + is_door, + }, + ) + .collect(); + context.build_ghost = Some(BuildGhost { + cell_x: player_cell.0.floor() as i64, + cell_y: player_cell.1.floor() as i64, + valid: true, + ..BuildGhost::default() + }); + + if let Some(props) = self + .slice + .get("props") + .and_then(successor_engine_core::json::Json::as_array) + { + for prop in props { + if prop + .get("areaId") + .and_then(successor_engine_core::json::Json::as_str) + != Some(self.area_id.as_str()) + { + continue; + } + let Some(cell) = prop.get("cell") else { + continue; + }; + let x = cell + .get("x") + .and_then(successor_engine_core::json::Json::as_f32) + .unwrap_or(f32::INFINITY); + let y = cell + .get("y") + .and_then(successor_engine_core::json::Json::as_f32) + .unwrap_or(f32::INFINITY); + let distance = ((x - player_cell.0).powi(2) + (y - player_cell.1).powi(2)).sqrt(); + let id = prop + .get("id") + .and_then(successor_engine_core::json::Json::as_str) + .unwrap_or(""); + let kind = prop + .get("kind") + .and_then(successor_engine_core::json::Json::as_str) + .unwrap_or(""); + if distance <= crate::windows::KIOSK_REACH_CELLS { + match kind { + kind if kind.contains("bank") => context.bank_gate = Gate::open(id), + kind if kind.contains("clone_terminal") => { + context.clone_gate = Gate::open(id) + } + kind if kind.contains("factory") => context.factory_gate = Gate::open(id), + kind if kind.contains("guild") || kind.contains("association") => { + context.guild_gate = Gate::open(id) + } + _ => {} + } + } + if distance <= crate::windows::TRAVEL_USE_RANGE_CELLS + && kind.contains("travel_terminal") + { + context.travel_gate = Gate::open(id); + } + } + } + + context.trainer = self + .store + .actors + .iter() + .filter(|(_, actor)| { + actor.area_id == self.area_id + && actor + .role + .as_deref() + .is_some_and(|role| role.contains("trainer")) + }) + .filter_map(|(id, actor)| { + let distance = + ((actor.x - player_cell.0).powi(2) + (actor.y - player_cell.1).powi(2)).sqrt(); + let in_range = distance <= 2.5; + (distance <= 16.0).then_some(TrainerView { + actor_id: id.clone(), + name: if actor.display_name.is_empty() { + actor.label.clone() + } else { + actor.display_name.clone() + }, + profession_id: actor + .role + .as_deref() + .and_then(|role| role.strip_prefix("profession_trainer:")) + .unwrap_or("") + .to_string(), + in_range, + }) + }) + .next(); + if context.trainer.is_some() { + context.career_goals = [ + ("rifle_utility", "Rifle Utility"), + ("ranged_specialist", "Ranged Specialist"), + ("melee_specialist", "Melee Specialist"), + ("rifle_quartermaster", "Rifle Quartermaster"), + ] + .into_iter() + .map(|(id, label)| (id.to_string(), label.to_string())) + .collect(); + } + + if let Some(planets) = self + .slice + .get("travelCatalog") + .and_then(|catalog| catalog.get("planets")) + .and_then(successor_engine_core::json::Json::as_array) + { + for planet in planets { + let planet_id = planet + .get("id") + .and_then(successor_engine_core::json::Json::as_str) + .unwrap_or("") + .to_string(); + if planet + .get("areaId") + .and_then(successor_engine_core::json::Json::as_str) + == Some(self.area_id.as_str()) + { + context.planet_id = planet_id.clone(); + } + let mut cities = Vec::new(); + if let Some(rows) = planet + .get("cities") + .and_then(successor_engine_core::json::Json::as_array) + { + for city in rows { + let city_id = city + .get("id") + .and_then(successor_engine_core::json::Json::as_str) + .unwrap_or("") + .to_string(); + let terminal = city + .get("terminalPropId") + .and_then(successor_engine_core::json::Json::as_str) + .unwrap_or("") + .to_string(); + if context.travel_gate.prop_id.as_deref() == Some(terminal.as_str()) { + context.travel_origin = Some((planet_id.clone(), city_id.clone())); + } + cities.push(TravelCity { + id: city_id, + label: city + .get("label") + .and_then(successor_engine_core::json::Json::as_str) + .unwrap_or("") + .to_string(), + terminal_prop_id: terminal, + price: 0, + }); + } + } + context.travel_planets.push(TravelPlanet { + id: planet_id, + label: planet + .get("label") + .and_then(successor_engine_core::json::Json::as_str) + .unwrap_or("") + .to_string(), + cities, + }); + } + } + crate::windows::project::project( + &self.store, + &self.player_id, + &context, + &mut self.win_model, + ); + // The host-owned identity and the inventory pane's thread-local + // selection are one contract. A snapshot can remove or reserve the + // picked stack between frames, so never leave the footer or radial + // pointing at a row that is no longer live. + let selected_inventory_is_live = + self.selected_inventory + .as_ref() + .is_some_and(|(container, stack_id)| { + self.win_model + .inventory + .row(container, stack_id) + .is_some_and(|row| !row.in_exchange()) + }); + if selected_inventory_is_live { + if let Some((container, stack_id)) = &self.selected_inventory { + crate::windows::inventory::select_identity(container, stack_id); + } + } else { + self.selected_inventory = None; + crate::windows::inventory::clear_selection(); + if matches!(self.context_menu, Some(ContextMenu::InventoryRadial { .. })) { + self.context_menu = None; + } + } + self.hud_state.project( + &self.store, + &self.player_id, + self.selected_actor_id.as_deref(), + ); + self.hud_state.world_seed = effective_world_seed(&self.slice, &self.area_id) as i32; + self.hud_state.biome = biome_for_area(&self.slice, &self.area_id); + if let Some(weapon) = &self.win_model.character.player.weapon { + let melee = weapon.ammo_type == "melee"; + let reloading = weapon.reload_remaining_ticks > 0; + let recovery_frac = if reloading && weapon.reload_total_ticks > 0 { + 1.0 - (weapon.reload_remaining_ticks as f32 / weapon.reload_total_ticks as f32) + .clamp(0.0, 1.0) + } else { + 1.0 + }; + self.hud_state.weapon = Some(hud::WeaponHud { + label: weapon.weapon_id.replace(['_', '-'], " ").to_uppercase(), + melee, + magazine_size: weapon.magazine_size.max(0) as u32, + loaded_rounds: weapon.loaded_rounds.max(0) as u32, + rounds_text: if melee { + if reloading { + "RECOVERING...".into() + } else { + "READY".into() + } + } else if reloading { + "REARMING...".into() + } else { + format!( + "{}/{}", + weapon.loaded_rounds.max(0), + weapon.magazine_size.max(0) + ) + }, + reloading, + reload_frac: recovery_frac, + swing_ready: !reloading, + swing_frac: recovery_frac, + }); + } + self.hud_state.group_members = self + .win_model + .group + .group + .members + .iter() + .filter(|member| member.actor_id != self.player_id) + .take(hud::GROUP_CHIP_MAX) + .map(|member| hud::GroupMemberHud { + actor_id: member.actor_id.clone(), + name: member.name.to_uppercase(), + leader: member.is_leader, + health_frac: if member.max_vitals.health > 0.0 { + member.vitals.health / member.max_vitals.health + } else { + 0.0 + }, + down: member.life_state != "alive", + link_dead: member.link_dead, + }) + .collect(); + self.hud_state.group_invite_from = self + .win_model + .group + .group + .pending_invite + .as_ref() + .map(|invite| invite.inviter_name.to_uppercase()); + self.hud_state.sampler_text = + (self.win_model.survey.sample_cooldown_ticks > 0).then(|| { + format!( + "AUTO-SAMPLE / {} TICKS", + self.win_model.survey.sample_cooldown_ticks + ) + }); + self.hud_state.sheltered = self + .win_model + .survey + .camps + .iter() + .any(|camp| camp.in_footprint); + self.hud_state.camp_countdown = self + .win_model + .survey + .camps + .iter() + .find_map(|camp| camp.vm.abandon_seconds_remaining) + .map(|seconds| format!("CAMP COLLAPSE / {:02}:{:02}", seconds / 60, seconds % 60)); + self.hud_state.extraction_toast = self + .win_model + .survey + .extractors + .iter() + .find(|extractor| extractor.vm.collectable_units > 0) + .map(|extractor| hud::BannerHud { + text: format!( + "{} / {} READY", + extractor.vm.family_label.to_uppercase(), + extractor.vm.collectable_units + ), + bad: false, + until_ms: successor_platform::now_ms() as u64 + 2_000, + }); + // A refused command is the only feedback the player gets that the + // press did something. The reason code is rendered as-is rather than + // through a lookup table: the authority owns 156 of them and any table + // here would silently go stale as it adds more. + if let Some(rejection) = self.store.command_rejection.take() { + let reason = rejection + .get("reasonCode") + .and_then(|v| v.as_str()) + .unwrap_or("refused"); + let mut text = String::with_capacity(reason.len() + 8); + for ch in reason.chars() { + text.push(if ch == '_' { + ' ' + } else { + ch.to_ascii_uppercase() + }); + } + self.hud_state.banner = Some(hud::BannerHud { + text, + bad: true, + until_ms: successor_platform::now_ms() as u64 + 3_000, + }); + } + let dialogue_floor = self.last_dialogue_tick; + let mut received_dialogue = false; + for delivery in self + .win_model + .converse + .deliveries + .iter() + .filter(|delivery| delivery.tick > dialogue_floor) + { + self.overlays + .push_bubble(&delivery.actor_id, &delivery.body); + received_dialogue = true; + self.last_dialogue_tick = self.last_dialogue_tick.max(delivery.tick); + } + if received_dialogue && self.win_model.converse.npc.is_some() { + self.open_workspace_window("converse"); + } + self.hud_state.interact = if let Some(prop) = + Self::nearest_interaction_prop(&self.slice, &self.store, &self.player_id, &self.area_id) + { + Some(hud::InteractHud { + label: format!("[F] {}", prop.label.to_uppercase()), + hold_frac: None, + }) + } else if let Some(actor_id) = self.selected_actor_id.as_deref() { + self.store.actors.get(actor_id).and_then(|actor| { + let player = self.store.actors.get(&self.player_id)?; + (((actor.x - player.x).powi(2) + (actor.y - player.y).powi(2)).sqrt() <= 2.5).then( + || hud::InteractHud { + label: "[F] INTERACT".into(), + hold_frac: None, + }, + ) + }) + } else { + None + }; + self.win_model.waypoints = self.waypoints.waypoints().to_vec(); + if let Some((px, py)) = self.hud_state.position { + self.hud_state.radar_waypoints = self + .waypoints + .active_in_area(&self.area_id) + .map(|waypoint| hud::RadarWaypointHud { + id: waypoint.id, + dx_cells: waypoint.x - px, + dy_cells: waypoint.y - py, + }) + .collect(); + } + self.win_model.macros = self.macro_runtime.macros().to_vec(); + crate::windows::set_options_model(crate::windows::options::OptionsModel { + theme_index: self.theme_index, + dust_strength: self.dust_strength, + window_opacity: self.window_opacity, + hud_opacity: self.hud_opacity, + zoom_percent: self.zoom_percent.round() as u16, + split_snap: self.split_snap, + toolbar_binds: self.toolbar.doc.binds.clone(), + rebind_pending: self.rebind_pending, + binding_reference: vec![ + ("MOVE".into(), "W A S D".into()), + ("SPRINT".into(), "SHIFT / X".into()), + ("INTERACT".into(), "F".into()), + ("TARGET".into(), "POINTER / RADAR".into()), + ("RELOAD".into(), "R".into()), + ("PRIMARY ATTACK".into(), "SPACE".into()), + ], + }); + if let Some(result) = self.store.bug_report_result.as_ref() { + crate::windows::apply_bug_report_result(result); + } + } + pub fn on_player_pos(&mut self, x: f32, y: f32) { + self.store.apply_player_position(x, y); + } + pub fn last_applied_move_command_id(&self) -> u64 { + self.last_applied_move_command_id + } + pub fn movement_diagnostics(&self) -> Option { + let pawn = self.pawns.get(&self.player_id)?; + let authoritative = pawn.predictor.authoritative(); + let predicted = pawn.predictor.render_pos(); + let correction_cells = ((predicted.0 - authoritative.0).powi(2) + + (predicted.1 - authoritative.1).powi(2)) + .sqrt(); + let wx = (pawn.render_pos.0 + 0.5) * WORLD_UNITS_PER_CELL; + let wz = (pawn.render_pos.1 + 0.5) * WORLD_UNITS_PER_CELL; + Some(MovementDiagnostics { + authoritative, + predicted, + rendered: pawn.render_pos, + correction_cells, + intent: self.move_intent, + applied_command_id: self.last_applied_move_command_id, + blocker_count: self.movement_collision.blocker_count(), + presented_ground_y: pawn.ground_y, + sampled_ground_y: self.ground_height_at(wx, wz), + frame_dt_ms: self.last_frame_dt * 1_000.0, + last_change_ms: self.movement_timing.last_change_ms, + last_send_ms: self.movement_timing.last_send_ms, + next_send_ms: self.movement_timing.next_send_ms, + sampled_at_ms: self.movement_sampled_at_ms, + }) + } + pub fn handle_tuning_toggle(&mut self, down: bool) -> bool { + self.graphics_tuner.handle_toggle(down) + } + + pub fn tuning_open(&self) -> bool { + self.graphics_tuner.is_open() + } + pub fn combat_fx_mut(&mut self) -> &mut CombatFx { + &mut self.combat_fx + } + pub fn ingest_chat_message(&mut self, message: &ChatMessage) { + // Own speech ticks on send, everyone else's on receipt — the web + // client's two chat cues. + if message.sender_id == self.store.player_actor_id { + crate::audio::play_ui(&mut self.sfx, crate::audio::UiCue::ChatSend); + } else { + crate::audio::play_ui(&mut self.sfx, crate::audio::UiCue::ChatReceive); + } + if let Some((actor_id, body)) = spatial_chat_payload(message) { + self.overlays.push_bubble(actor_id, body); + } + } + #[allow(clippy::too_many_arguments)] + pub fn load_persisted( + &mut self, + theme: Option<&serde_json::Value>, + toolbar: Option<&serde_json::Value>, + split_snap: Option<&serde_json::Value>, + waypoints: Option<&serde_json::Value>, + macros: Option<&serde_json::Value>, + window_layout: Option<&serde_json::Value>, + ui_opacity: Option<&serde_json::Value>, + ) { + if let Some(id) = theme.and_then(serde_json::Value::as_str) { + if let Some(index) = hud::THEME_IDS.iter().position(|candidate| *candidate == id) { + self.theme_index = index; + } + } + self.toolbar = hud::toolbar::Toolbar::new(hud::toolbar::ToolbarDoc::load(toolbar)); + self.split_snap = split_snap + .and_then(serde_json::Value::as_u64) + .map(|value| value as u32) + .filter(|value| crate::windows::options::SPLIT_SNAP_STEPS.contains(value)) + .unwrap_or(100); + self.waypoints = hud::waypoints::WaypointStore::load(waypoints); + self.macro_runtime = crate::game::macro_runtime::MacroRuntime::load(macros); + // A corrupt or out-of-band opacity resets to its default rather than + // clamping: a near-zero value would read as a broken client. + let opacity = |key: &str, default: f32| { + ui_opacity + .and_then(|doc| doc.get(key)) + .and_then(serde_json::Value::as_f64) + .map(|value| value as f32) + .filter(|value| { + value.is_finite() && (hud::MIN_UI_OPACITY..=hud::MAX_UI_OPACITY).contains(value) + }) + .unwrap_or(default) + }; + self.window_opacity = opacity("window", 0.92); + self.hud_opacity = opacity("hud", 0.90); + self.hud_layout_viewport = layout_viewport(window_layout); + self.hud_defaults_pending = restore_window_layout(&mut self.wm, window_layout); + self.window_layout_dirty = false; + self.preferences_dirty = false; + self.project_windows(); + } + + /// Live framebuffer as layout floats: the space every window rect is + /// measured in, and the stamp a saved layout is matched against. + fn viewport_size(&self) -> (f32, f32) { + (self.framebuffer.0 as f32, self.framebuffer.1 as f32) + } + + pub fn take_persisted(&mut self) -> PersistedSections { + let local = self.preferences_dirty; + self.preferences_dirty = false; + let waypoint = self.waypoints.dirty(); + let macros = self.macro_runtime.dirty(); + let window_layout = + self.wm.take_geometry_dirty() || core::mem::take(&mut self.window_layout_dirty); + let result = ( + local.then(|| serde_json::Value::String(hud::THEME_IDS[self.theme_index].into())), + local.then(|| self.toolbar.doc.save()), + local.then(|| serde_json::Value::from(self.split_snap)), + waypoint.then(|| self.waypoints.save()), + macros.then(|| self.macro_runtime.save()), + window_layout.then(|| save_window_layout(&self.wm, self.viewport_size())), + local.then(|| { + serde_json::json!({ + "window": self.window_opacity, + "hud": self.hud_opacity, + }) + }), + ); + if waypoint { + self.waypoints.mark_saved(); + } + if macros { + self.macro_runtime.mark_saved(); + } + result + } + pub fn take_bug_report(&mut self) -> Option { + self.pending_bug_report.take() + } + /// Workspace visibility/focus is part of the persisted layout, not a + /// transient UI preference. + fn open_workspace_window(&mut self, id: &str) { + if !self.wm.is_open(id) { + crate::audio::play_ui(&mut self.sfx, crate::audio::UiCue::PanelOpen); + } + self.wm.open(id); + self.window_layout_dirty = true; + } + + fn toggle_workspace_window(&mut self, id: &str) { + let cue = if self.wm.is_open(id) { + crate::audio::UiCue::PanelClose + } else { + crate::audio::UiCue::PanelOpen + }; + crate::audio::play_ui(&mut self.sfx, cue); + self.wm.toggle(id); + self.window_layout_dirty = true; + } + + /// Install the authenticated session queue. Until installed, command + /// intents are rejected visibly rather than assigned a synthetic identity. + pub fn pointer_captured(&self) -> bool { + self.graphics_tuner.is_open() + || self.wm.pointer_captured() + || self.context_menu.is_some() + || self.hud_layout_input_consumed + } + pub fn set_command_queue(&mut self, queue: CommandQueue) { + self.command_queue = Some(queue); + self.project_windows(); + } + + /// Restore renderer-neutral connected state after the browser recreates a + /// lost WebGL context. GPU resources come from `Self::build`; authority, + /// input, and workspace state survive without reconnecting or replaying launch tickets. + pub fn restore_projection_from(&mut self, previous: &Self) { + self.store = previous.store.clone(); + self.command_queue = previous.command_queue.clone(); + self.pending_window_commands = previous.pending_window_commands.clone(); + self.window_rejection = previous.window_rejection.clone(); + self.selected_actor_id = previous.selected_actor_id.clone(); + self.selected_inventory = previous.selected_inventory.clone(); + self.last_dialogue_tick = previous.last_dialogue_tick; + self.last_applied_move_command_id = previous.last_applied_move_command_id; + self.framebuffer = previous.framebuffer; + self.wm.restore_workspace_state_from(&previous.wm); + self.hud_defaults_pending = previous.hud_defaults_pending; + self.hud_layout_viewport = previous.hud_layout_viewport; + self.window_layout_dirty = previous.window_layout_dirty; + if let Some((container, stack_id)) = &self.selected_inventory { + crate::windows::inventory::select_identity(container, stack_id); + } else { + crate::windows::inventory::clear_selection(); + } + self.loot_corpse_id = previous.loot_corpse_id.clone(); + self.zoom_percent = previous.zoom_percent; + self.sprint_toggle = previous.sprint_toggle; + self.collision_debug + .set_enabled(&mut self.world, previous.collision_debug.enabled()); + self.theme_index = previous.theme_index; + self.dust_strength = previous.dust_strength; + self.window_opacity = previous.window_opacity; + self.hud_opacity = previous.hud_opacity; + self.split_snap = previous.split_snap; + self.shard_id.clone_from(&previous.shard_id); + self.area_id.clone_from(&previous.area_id); + self.move_intent = previous.move_intent; + self.weather_audio = previous.weather_audio; + self.music_audio = previous.music_audio; + self.music_combat_index = previous.music_combat_index; + self.music_was_in_combat = previous.music_was_in_combat; + self.settlement_audio = previous.settlement_audio; + self.loading = previous.loading; + self.footstep_distance = previous.footstep_distance; + self.footstep_index = previous.footstep_index; + self.footstep_position = previous.footstep_position; + self.ambience_timer = previous.ambience_timer; + self.ambience_roll = previous.ambience_roll; + self.project_windows(); + } + + pub fn pending_window_commands(&self) -> &[u64] { + &self.pending_window_commands + } + + pub fn window_rejection(&self) -> Option<&str> { + self.window_rejection.as_deref() + } + + pub fn open_window_ids(&self) -> Vec { + self.wm + .z_order() + .into_iter() + .map(|index| self.wm.window_id(index).to_owned()) + .collect() + } + + /// The looking player's guild/faction id, used to tell an allied player + /// from a merely neutral one. `None` when unaffiliated, which makes every + /// other player neutral rather than guessing at standing. + fn viewer_org(&self) -> Option<&str> { + self.store + .actors + .get(&self.store.player_actor_id) + .or_else(|| self.store.actors.get(&self.player_id)) + .and_then(|actor| actor.player_organization_id.as_deref()) + .filter(|org| !org.is_empty()) + } + + pub fn focused_window_id(&self) -> Option { + self.wm + .z_order() + .last() + .map(|index| self.wm.window_id(*index).to_owned()) + } + + /// Apply a developer inspection intent from the control protocol. + /// + /// Routed through the same entry points the player's own action uses, so a + /// captured pane is the pane the player would see — not a special + /// inspection rendering. Returns false only for an unregistered window id. + #[cfg(not(target_arch = "wasm32"))] + pub fn apply_control_ui_intent( + &mut self, + intent: &successor_platform::ControlUiIntent, + ) -> bool { + match intent { + successor_platform::ControlUiIntent::Window { id, open } => { + if self.wm.rect(id).is_none() { + return false; + } + match open { + Some(true) => self.open_workspace_window(id), + Some(false) => { + self.wm.close(id); + self.window_layout_dirty = true; + } + None => self.toggle_workspace_window(id), + } + true + } + successor_platform::ControlUiIntent::Theme(index) => { + self.theme_index = index % hud::THEME_COUNT; + self.preferences_dirty = true; + self.project_windows(); + true + } + successor_platform::ControlUiIntent::Opacity { hud: is_hud, value } => { + let value = value.clamp(hud::MIN_UI_OPACITY, hud::MAX_UI_OPACITY); + if *is_hud { + self.hud_opacity = value; + } else { + self.window_opacity = value; + } + self.preferences_dirty = true; + self.project_windows(); + true + } + } + } + + /// Every registered frame with its live geometry, for the control + /// protocol. Development-only inspection: a UI journey asserts move, + /// resize, and layout persistence against these numbers rather than + /// against pixels. + #[cfg(not(target_arch = "wasm32"))] + pub fn window_frames(&self) -> Vec { + let mut frames = Vec::new(); + self.wm.for_each_geometry(|id, rect| { + frames.push(successor_platform::ControlWindowFrame { + id: id.to_owned(), + rect, + open: self.wm.is_open(id), + iconified: self.wm.is_iconified(id), + interactive: self.wm.is_interactive(id), + }); + }); + frames + } + + /// View-projection the last drawn frame used, or `None` before the first + /// sized frame. Pointer picking and pointer-shape resolution both read it + /// so a click resolves against the image the player actually saw, and so + /// neither path needs `&mut` access to the ECS. + fn viewport_projection(&self) -> Option { + self.pointer_vp + } + + /// Screen position of a world cell centre. + fn cell_to_screen(&self, vp: &Mat4, cell_x: f32, cell_y: f32, height_bias: f32) -> (f32, f32) { + let wx = (cell_x + 0.5) * WORLD_UNITS_PER_CELL; + let wz = (cell_y + 0.5) * WORLD_UNITS_PER_CELL; + let ndc = vp.project_point(vec3(wx, self.terrain.height_at(wx, wz) + height_bias, wz)); + ( + (ndc.x * 0.5 + 0.5) * self.framebuffer.0 as f32, + (0.5 - ndc.y * 0.5) * self.framebuffer.1 as f32, + ) + } + + /// Nearest streamed actor whose projected torso is inside the pick radius. + /// Borrowed, not cloned: the pointer shape resolves this every frame and + /// the frame loop must not allocate. + fn actor_id_at_pointer(&self, x: f32, y: f32) -> Option<&str> { + let vp = self.viewport_projection()?; + self.store + .actors + .iter() + .filter(|(id, actor)| { + id.as_str() != self.store.player_actor_id + && actor.area_id == self.area_id + && actor.life_state != "respawning" + }) + .filter_map(|(id, actor)| { + let (sx, sy) = self.cell_to_screen(&vp, actor.x, actor.y, 0.9); + let d2 = (sx - x) * (sx - x) + (sy - y) * (sy - y); + (d2 <= ACTOR_PICK_RADIUS_PX * ACTOR_PICK_RADIUS_PX).then_some((id.as_str(), d2)) + }) + .min_by(|left, right| left.1.total_cmp(&right.1)) + .map(|(id, _)| id) + } + + /// Pointer shape for this frame, resolved the way the original resolves a + /// cursor: the mediator under the pointer wins, and only when nothing owns + /// the pointer does the world get to speak. + fn pointer_cursor(&self, x: f32, y: f32) -> CursorKind { + if let Some(hint) = self.wm.cursor_hint(x, y) { + return hint; + } + if self.graphics_tuner.is_open() || self.context_menu.is_some() || self.wm.covers(x, y) { + return CursorKind::Arrow; + } + if self.hud_state.life != hud::LifeHud::Alive { + return CursorKind::Arrow; + } + if let Some(actor_id) = self.actor_id_at_pointer(x, y) { + let hostile = self + .store + .actors + .get(actor_id) + .map(|actor| hud::relation_for(actor, &self.player_id, self.viewer_org())) + .is_some_and(|relation| { + matches!( + relation, + hud::RelationHud::Hostile | hud::RelationHud::Attackable + ) + }); + // Attack only reads as a promise while something is wielded; the + // authority refuses the swing otherwise. + return if hostile && self.hud_state.weapon.is_some() { + CursorKind::Attack + } else { + CursorKind::Select + }; + } + // Proximity alone is not a pointer verb: the prop also has to be under + // the pointer, or every door in reach would claim the cursor. + if let (Some(vp), Some(prop)) = ( + self.viewport_projection(), + Self::nearest_interaction_prop( + &self.slice, + &self.store, + &self.player_id, + &self.area_id, + ), + ) { + let (sx, sy) = self.cell_to_screen(&vp, prop.x, prop.y, 1.45); + let d2 = (sx - x) * (sx - x) + (sy - y) * (sy - y); + if d2 <= PROP_PICK_RADIUS_PX * PROP_PICK_RADIUS_PX { + return CursorKind::Interact; + } + } + CursorKind::Arrow + } + + pub fn pending_command_kinds(&self) -> Vec { + self.command_queue + .as_ref() + .map(|queue| { + queue + .pending_envelopes() + .map(|envelope| crate::game::command_queue::command_kind(&envelope.command)) + .collect() + }) + .unwrap_or_default() + } + + pub fn dispatch_window_action(&mut self, action: crate::windows::WindowAction) { + let Some(queue) = self.command_queue.as_mut() else { + self.window_rejection = Some("not authenticated".into()); + crate::audio::play_ui(&mut self.sfx, crate::audio::UiCue::Deny); + return; + }; + match actions::enqueue_window_action(queue, action, self.store.tick) { + DispatchOutcome::Queued(id) => self.pending_window_commands.push(id), + DispatchOutcome::Rejected(reason) => { + self.window_rejection = Some(reason); + crate::audio::play_ui(&mut self.sfx, crate::audio::UiCue::Deny); + } + DispatchOutcome::Local(local) => self.apply_local_window_action(local), + } + } + + /// Queue a gameplay action. Authority-owned state changes only after a receipt. + pub fn dispatch_gameplay_action(&mut self, action: actions::GameplayAction) -> Option { + let queue = self.command_queue.as_mut()?; + actions::enqueue_action(queue, action, self.store.tick) + } + + /// Apply the only intents emitted by the authority-backed radar renderer. + fn dispatch_radar_hud_action(&mut self, action: hud::HudAction) { + match action { + hud::HudAction::RadarSelect(actor_id) => { + self.selected_actor_id = Some(actor_id); + self.selected_inventory = None; + crate::windows::inventory::clear_selection(); + self.project_windows(); + } + hud::HudAction::RadarMove { dx_cells, dy_cells } => { + let dx = dx_cells.signum() as i32; + let dy = dy_cells.signum() as i32; + self.dispatch_gameplay_action(actions::GameplayAction::Move { + dx, + dy, + facing: movement::facing_from_intent(dx, dy), + sprint: self.sprint_toggle, + }); + } + _ => unreachable!("radar renderer emits only radar actions"), + } + } + + pub fn selected_actor_id(&self) -> Option<&str> { + self.selected_actor_id.as_deref() + } + + #[cfg(not(target_arch = "wasm32"))] + pub fn audio_mixer( + &self, + ) -> std::sync::Arc> { + self.sfx.shared_mixer() + } + + /// Take the next authenticated command for transmission. + pub fn take_next_command(&mut self) -> Option { + self.command_queue + .as_mut() + .and_then(CommandQueue::take_next) + } + + /// Requeue an in-flight command after a lost connection. + pub fn reconcile_commands(&mut self) { + if let Some(queue) = self.command_queue.as_mut() { + queue.reconcile_reconnect(); + } + } + + /// Release all movement state and enqueue an authoritative stop intent. + pub fn release_movement(&mut self, _reason: movement::StopReason) -> Option { + self.dispatch_gameplay_action(actions::GameplayAction::Stop) + } + pub fn set_movement_timing(&mut self, timing: movement::MovementTiming, sampled_at_ms: u64) { + self.movement_timing = timing; + self.movement_sampled_at_ms = sampled_at_ms; + } + + /// Feed the held authority movement intent into local prediction. + pub fn set_move_intent(&mut self, dx: i32, dy: i32, sprint: bool) { + self.move_intent = (dx, dy, sprint); + } + /// Resolve the current click route against streamed authority position. + /// Manual movement always wins and cancels navigation. + pub fn navigation_intent(&mut self, manual_dx: i32, manual_dy: i32) -> (i32, i32) { + if manual_dx != 0 || manual_dy != 0 { + self.click_route.clear(); + self.click_route_index = 0; + self.click_goal = None; + self.streamed_world.set_waypoint(None); + return (manual_dx, manual_dy); + } + let Some(player) = self.store.actors.get(&self.store.player_actor_id) else { + return (0, 0); + }; + if let (Some(goal), Some(next)) = ( + self.click_goal, + self.click_route.get(self.click_route_index).copied(), + ) { + let next_blocked = !self + .movement_collision + .anchor_clear(next.0 as f32 + 0.5, next.1 as f32 + 0.5); + if next_blocked { + let start = (player.x.floor() as i32, player.y.floor() as i32); + self.click_route = movement::route_grid(start, goal, |x, y| { + !self + .movement_collision + .anchor_clear(x as f32 + 0.5, y as f32 + 0.5) + }) + .unwrap_or_default(); + self.click_route_index = 0; + } + } + while let Some(&(x, y)) = self.click_route.get(self.click_route_index) { + let dx = x as f32 + 0.5 - player.x; + let dy = y as f32 + 0.5 - player.y; + if dx * dx + dy * dy <= 0.35 * 0.35 { + self.click_route_index += 1; + continue; + } + return ( + if dx.abs() < 0.2 { + 0 + } else { + dx.signum() as i32 + }, + if dy.abs() < 0.2 { + 0 + } else { + dy.signum() as i32 + }, + ); + } + self.click_route.clear(); + self.click_route_index = 0; + self.click_goal = None; + self.streamed_world.set_waypoint(None); + (0, 0) + } + fn interaction_window_for_kind(kind: &str) -> Option<&'static str> { + if kind.contains("bank") { + Some("bank") + } else if kind.contains("clone") { + Some("clone") + } else if kind.contains("factory") { + Some("craft") + } else if kind.contains("trade") { + Some("trade") + } else if kind.contains("travel") { + Some("travel") + } else if kind.contains("guild") || kind.contains("association") { + Some("pa") + } else { + None + } + } + + /// Narrow field borrows (not `&self`) so callers can keep the result + /// alive across `&mut self.ui` / overlay work in the same frame. + fn nearest_interaction_prop<'a>( + slice: &'a successor_engine_core::json::Json, + store: &crate::game::authority::AuthorityStore, + player_id: &str, + area_id: &str, + ) -> Option> { + let player = store.actors.get(player_id)?; + slice + .get("props") + .and_then(successor_engine_core::json::Json::as_array)? + .iter() + .filter(|prop| { + prop.get("areaId") + .and_then(successor_engine_core::json::Json::as_str) + == Some(area_id) + }) + .filter_map(|prop| { + let cell = prop.get("cell")?; + let cell_x = cell + .get("x") + .and_then(successor_engine_core::json::Json::as_f32)?; + let cell_y = cell + .get("y") + .and_then(successor_engine_core::json::Json::as_f32)?; + let id = prop + .get("id") + .and_then(successor_engine_core::json::Json::as_str)?; + let authored_kind = prop + .get("kind") + .and_then(successor_engine_core::json::Json::as_str)?; + let label = prop + .get("label") + .and_then(successor_engine_core::json::Json::as_str) + .unwrap_or(authored_kind); + let (kind, target_x, target_y, radius) = if let Some(door) = prop.get("door") { + let blocker = door.get("blocker")?; + let x_milli = blocker.get("xMilli")?.as_f32()?; + let y_milli = blocker.get("yMilli")?.as_f32()?; + let w_milli = blocker.get("wMilli")?.as_f32()?; + let h_milli = blocker.get("hMilli")?.as_f32()?; + ( + "door", + cell_x + (x_milli + w_milli * 0.5) / 1_000.0, + cell_y + (y_milli + h_milli * 0.5) / 1_000.0, + door.get("interactRadiusCells") + .and_then(successor_engine_core::json::Json::as_f32) + .unwrap_or(2.5), + ) + } else { + (authored_kind, cell_x, cell_y, 2.5) + }; + if kind != "door" && Self::interaction_window_for_kind(kind).is_none() { + return None; + } + let distance = + ((target_x - player.x).powi(2) + (target_y - player.y).powi(2)).sqrt(); + (distance <= radius).then_some(( + InteractionProp { + id, + kind, + label, + x: target_x, + y: target_y, + }, + distance, + )) + }) + .min_by(|left, right| left.1.total_cmp(&right.1)) + .map(|(prop, _)| prop) + } + + /// Return the exact held stack under an open inventory card, together with + /// the card centre used as the radial anchor. This follows the inventory + /// renderer's pagination and card geometry rather than guessing from an + /// item id (which can be shared by multiple stacks). + fn inventory_item_at(&self, x: f32, y: f32) -> Option<(String, String, (f32, f32))> { + if !self.wm.is_open("inventory") { + return None; + } + let content = self.wm.content_rect("inventory")?; + let held_count = self.win_model.inventory.held().count(); + let (visible_start, visible_end) = + crate::windows::inventory::visible_held_range(content, held_count); + self.win_model + .inventory + .held() + .skip(visible_start) + .take(visible_end - visible_start) + .enumerate() + .find_map(|(index, row)| { + let [card_x, card_y, card_w, card_h] = + crate::windows::inventory::grid_card_rect(content, index)?; + successor_engine_render::ui::UiBuilder::hit(card_x, card_y, card_w, card_h, x, y) + .then(|| { + ( + row.container.clone(), + row.stack_id.clone(), + (card_x + card_w * 0.5, card_y + card_h * 0.5), + ) + }) + }) + } + + /// Handle edge-triggered connected bindings. Window actions stay local; + fn key_code(key: Key) -> &'static str { + match key { + Key::W => "KeyW", + Key::A => "KeyA", + Key::S => "KeyS", + Key::D => "KeyD", + Key::R => "KeyR", + Key::F => "KeyF", + Key::I => "KeyI", + Key::C => "KeyC", + Key::O => "KeyO", + Key::V => "KeyV", + Key::X => "KeyX", + Key::N => "KeyN", + Key::P => "KeyP", + Key::K => "KeyK", + Key::B => "KeyB", + Key::M => "KeyM", + Key::G => "KeyG", + Key::Digit0 => "Digit0", + Key::Digit1 => "Digit1", + Key::Digit2 => "Digit2", + Key::Digit3 => "Digit3", + Key::Digit4 => "Digit4", + Key::Digit5 => "Digit5", + Key::Digit6 => "Digit6", + Key::Digit7 => "Digit7", + Key::Digit8 => "Digit8", + Key::Digit9 => "Digit9", + Key::Space => "Space", + Key::Enter => "Enter", + Key::Escape => "Escape", + Key::Backspace => "Backspace", + Key::LeftShift => "ShiftLeft", + Key::Backquote => "Backquote", + Key::Semicolon => "Semicolon", + Key::Tab => "Tab", + Key::Up => "ArrowUp", + Key::Down => "ArrowDown", + Key::Left => "ArrowLeft", + Key::Right => "ArrowRight", + } + } + + fn permanent_window_for_key(key: Key) -> Option<&'static str> { + hud::window_for_code(Self::key_code(key)) + } + + fn collision_debug_chord(key: Key, shift: bool) -> bool { + key == Key::C && shift + } + + /// gameplay verbs are returned for the host to enqueue through the queue. + pub fn handle_key( + &mut self, + key: Key, + down: bool, + shift: bool, + ) -> Option { + let index = key as usize; + let pressed = down && !self.key_was_down[index]; + self.key_was_down[index] = down; + if !pressed { + return None; + } + if Self::collision_debug_chord(key, shift) { + self.collision_debug.toggle(&mut self.world); + return None; + } + // Shift+V frees the camera; the arrows then orbit it and Shift+arrow + // dollies. Arrows keep driving the player while it is off, so the + // inspection controls never sit on top of a gameplay binding. + if key == Key::V && shift { + self.debug_camera = match self.debug_camera { + Some(_) => None, + None => Some(DebugCamera::default()), + }; + return None; + } + if let Some(orbit) = self.debug_camera.as_mut() { + const STEP: f32 = 0.12; + match (key, shift) { + (Key::Left, false) => orbit.orbit(-STEP, 0.0), + (Key::Right, false) => orbit.orbit(STEP, 0.0), + (Key::Up, false) => orbit.orbit(0.0, STEP), + (Key::Down, false) => orbit.orbit(0.0, -STEP), + (Key::Up, true) => orbit.dolly(0.85), + (Key::Down, true) => orbit.dolly(1.18), + _ => return None, + } + return None; + } + if key == Key::Escape { + if dismiss_context_or_focused( + &mut self.context_menu, + &mut self.graphics_tuner, + &mut self.wm, + ) { + crate::audio::play_ui(&mut self.sfx, crate::audio::UiCue::PanelClose); + self.window_layout_dirty = true; + } + return None; + } + if let Some(window) = Self::permanent_window_for_key(key) { + crate::audio::play_ui(&mut self.sfx, crate::audio::UiCue::ButtonTick); + self.toggle_workspace_window(window); + return None; + } + let code = Self::key_code(key); + if let Some(slot) = self.rebind_pending.take() { + if slot < self.toolbar.doc.binds.len() { + self.toolbar.doc.binds[slot] = code.into(); + self.preferences_dirty = true; + } + self.project_windows(); + return None; + } + // A bound slot owns its key even when it cannot fire; the ineligible + // cue is the feedback that says why nothing happened. + match self.toolbar.press_code_result(code, &mut self.hud_actions) { + hud::toolbar::PressResult::Used => { + crate::audio::play_ui(&mut self.sfx, crate::audio::UiCue::ToolbarUse); + return None; + } + hud::toolbar::PressResult::Ineligible => { + crate::audio::play_ui(&mut self.sfx, crate::audio::UiCue::ToolbarIneligible); + return None; + } + hud::toolbar::PressResult::Passthrough => {} + } + crate::audio::play_ui(&mut self.sfx, crate::audio::UiCue::ButtonTick); + match key { + Key::X => self.sprint_toggle = !self.sprint_toggle, + Key::R => { + return Some(actions::GameplayAction::Reload { + weapon_id: None, + ammo_type: None, + }) + } + Key::Space => return self.shot_action("basic_shot"), + Key::Tab => { + self.cycle_target(); + return None; + } + Key::F => { + if let Some(prop) = Self::nearest_interaction_prop( + &self.slice, + &self.store, + &self.player_id, + &self.area_id, + ) { + if prop.kind.contains("door") { + return Some(actions::GameplayAction::ToggleDoor { + prop_id: prop.id.to_string(), + }); + } + if let Some(window) = Self::interaction_window_for_kind(prop.kind) { + self.project_windows(); + self.open_workspace_window(window); + return None; + } + } + let corpse_id = self + .store + .actors + .get(&self.player_id) + .and_then(|player| { + nearest_loot_corpse_id( + &self.store.player_corpses, + &self.area_id, + (player.x, player.y), + ) + }) + .map(str::to_owned) + .or_else(|| self.nearest_lootable_actor_corpse()); + if let Some(corpse_id) = corpse_id { + self.loot_corpse_id = Some(corpse_id); + self.project_windows(); + self.open_workspace_window("loot"); + return None; + } + let target = self + .selected_actor_id + .as_ref() + .and_then(|id| self.store.actors.get(id).map(|_| id.clone())) + .or_else(|| { + let player = self.store.actors.get(&self.player_id)?; + self.store + .actors + .iter() + .filter(|(id, actor)| { + id.as_str() != self.player_id && actor.area_id == self.area_id + }) + .filter_map(|(id, actor)| { + let distance = ((actor.x - player.x).powi(2) + + (actor.y - player.y).powi(2)) + .sqrt(); + (distance <= 2.5).then_some((id.clone(), distance)) + }) + .min_by(|left, right| left.1.total_cmp(&right.1)) + .map(|(id, _)| id) + })?; + return Some(actions::GameplayAction::Interact { + verb: "interact".into(), + target_id: target, + }); + } + _ => {} + } + None + } + + /// Queue a combat action against the selected target, else the nearest + /// hostile — the shared body behind Space, `1` (basic) and `2` (aimed). + fn shot_action(&self, action_id: &str) -> Option { + let player = self.store.actors.get(&self.store.player_actor_id); + let player_faction = player.and_then(|actor| actor.faction_id.as_deref()); + let player_position = player.map(|actor| (actor.x, actor.y)).unwrap_or_default(); + let selected_target = self + .selected_actor_id + .as_ref() + .filter(|id| id.as_str() != self.store.player_actor_id) + .filter(|id| { + self.store + .actors + .get(id.as_str()) + .is_some_and(|actor| actor.life_state == "alive") + }) + .cloned(); + let target = selected_target.or_else(|| { + self.store + .actors + .values() + .filter(|actor| { + actor.id != self.store.player_actor_id + && actor.life_state == "alive" + && (actor.pvp_status.as_deref() == Some("overt") + || actor.role.as_deref() == Some("skirmisher")) + && actor.faction_id.as_deref() != player_faction + }) + .min_by(|left, right| { + let left_distance = + (left.x - player_position.0).powi(2) + (left.y - player_position.1).powi(2); + let right_distance = (right.x - player_position.0).powi(2) + + (right.y - player_position.1).powi(2); + left_distance.total_cmp(&right_distance) + }) + .map(|actor| actor.id.clone()) + })?; + Some(actions::GameplayAction::Attack { + action_id: action_id.to_string(), + target_actor_id: target, + }) + } + + /// Tab targeting: cycle the live non-self actors in this area by distance, + /// starting after the current selection (wrapping). + fn cycle_target(&mut self) { + let Some(player) = self.store.actors.get(&self.store.player_actor_id) else { + return; + }; + let (px, py) = (player.x, player.y); + let mut candidates: Vec<(String, f32)> = self + .store + .actors + .values() + .filter(|actor| { + actor.id != self.store.player_actor_id + && actor.area_id == self.area_id + && actor.life_state == "alive" + }) + .map(|actor| { + ( + actor.id.clone(), + (actor.x - px).powi(2) + (actor.y - py).powi(2), + ) + }) + .collect(); + if candidates.is_empty() { + return; + } + candidates.sort_by(|left, right| left.1.total_cmp(&right.1).then(left.0.cmp(&right.0))); + let next = match self + .selected_actor_id + .as_deref() + .and_then(|current| candidates.iter().position(|(id, _)| id == current)) + { + Some(at) => (at + 1) % candidates.len(), + None => 0, + }; + self.selected_actor_id = Some(candidates[next].0.clone()); + self.project_windows(); + } + + /// Nearest authority-marked lootable world corpse (rogues, wildlife) — + /// `player_corpses` only carries player bodies, but the actor snapshot + /// flags every corpse the authority will open for us. + fn nearest_lootable_actor_corpse(&self) -> Option { + let player = self.store.actors.get(&self.player_id)?; + let (px, py) = (player.x, player.y); + let reach_sq = crate::windows::EXTRACTOR_REACH_CELLS.powi(2); + self.store + .actors + .values() + .filter(|actor| { + actor.id != self.player_id + && actor.area_id == self.area_id + && actor.lootable == Some(true) + }) + .map(|actor| { + ( + actor.id.clone(), + (actor.x - px).powi(2) + (actor.y - py).powi(2), + ) + }) + .filter(|(_, distance_sq)| *distance_sq <= reach_sq) + .min_by(|left, right| left.1.total_cmp(&right.1)) + .map(|(id, _)| id) + } + + pub fn sprint_toggled(&self) -> bool { + self.sprint_toggle + } + + /// Apply wheel zoom in the connected orthographic camera. + pub fn handle_scroll(&mut self, y: f32) { + if !y.is_finite() || y == 0.0 { + return; + } + self.zoom_percent = (self.zoom_percent - y * 5.0).clamp(55.0, 125.0); + if let Some(cam) = self.world.get_component::(self.follow) { + cam.projection = Projection::Ortho { + half_height: 12.5 * self.zoom_percent / 100.0, + near: 0.1, + far: 320.0, + }; + } + } + + /// Route pointer grammar against streamed actor targets. Empty left clicks + /// become directional authority intents, never local teleports. + pub fn handle_pointer( + &mut self, + x: f32, + y: f32, + left: bool, + right: bool, + captured: bool, + ) -> Option { + let dx = x - self.pointer_prev.0; + let dy = y - self.pointer_prev.1; + self.pointer_prev = (x, y); + let left_pressed = left && !self.left_was_down; + let right_pressed = right && !self.right_was_down; + self.left_was_down = left; + self.right_was_down = right; + // A left-drag inside the live viewer cell spins the doll instead of + // routing a world intent — the original's draggable object viewer. + let in_viewer = self.dolls.iter().any(|slot| { + slot.viewport.is_some_and(|[vx, vy, vw, vh]| { + successor_engine_render::ui::UiBuilder::hit(vx, vy, vw, vh, x, y) + }) + }); + if left && (self.paperdoll_drag_x.is_some() || (left_pressed && in_viewer)) { + if self.paperdoll_drag_x.is_some() { + let step = dx * PAPERDOLL_DRAG_YAW_PER_PX; + self.paperdoll_yaw += step; + // Carry the drag as velocity so releasing mid-sweep flicks the + // doll on, exactly as the original viewer does. + self.paperdoll_spin = (step * 60.0).clamp(-PAPERDOLL_MAX_SPIN, PAPERDOLL_MAX_SPIN); + } + self.paperdoll_drag_x = Some(x); + return None; + } + self.paperdoll_drag_x = None; + // A press that lands on a panel belongs to the panel. `captured` alone + // cannot say so: it latches while the windows run, which is after the + // host has already routed this press, so on the press itself it still + // reads false and the click walks a move intent out from under the + // open window - which then clears the very selection the player was + // about to act on. + // + // One press IS window content and still has to be resolved here: the + // right-click that opens an inventory card's object radial. The cards + // only exist inside the inventory window, so leaving this below the + // swallow made the radial unreachable - every right-press over the + // grid returned early as "belongs to the panel" and no panel code + // could see the button (the widget response carries left-click only). + if right_pressed && !left && !captured { + if let Some((container, stack_id, (anchor_x, anchor_y))) = self.inventory_item_at(x, y) + { + crate::windows::inventory::select_identity(&container, &stack_id); + self.selected_inventory = Some((container, stack_id)); + self.selected_actor_id = None; + self.project_windows(); + self.context_menu = Some(ContextMenu::InventoryRadial { + x: anchor_x, + y: anchor_y, + }); + return None; + } + } + if right_pressed && !left { + // A right-click on a HUD pane toggles its layout lock and belongs + // to that pane regardless of its current interaction state. + if hud::toggle_hud_surface_lock_at(&mut self.wm, x, y).is_some() { + self.window_layout_dirty = true; + self.hud_layout_input_consumed = true; + return None; + } + } + if captured || self.wm.covers(x, y) { + return None; + } + let picked_actor = self.actor_id_at_pointer(x, y).map(str::to_owned); + if right_pressed && !left { + if let Some(target_id) = picked_actor.clone() { + self.selected_actor_id = Some(target_id.clone()); + self.selected_inventory = None; + crate::windows::inventory::clear_selection(); + self.project_windows(); + self.context_menu = Some(ContextMenu::Actor { x, y }); + return Some(actions::GameplayAction::Interact { + verb: "radial".into(), + target_id, + }); + } + } + + if right && (right_pressed || dx.abs() + dy.abs() > 0.5) { + let (mx, my) = if dx.abs() >= dy.abs() { + (dx.signum() as i32, 0) + } else { + (0, dy.signum() as i32) + }; + return Some(actions::GameplayAction::Move { + dx: mx, + dy: my, + facing: movement::facing_from_intent(mx, my), + sprint: self.sprint_toggle, + }); + } + if !left_pressed { + return None; + } + if let Some(target_id) = picked_actor { + self.selected_actor_id = Some(target_id); + self.selected_inventory = None; + crate::windows::inventory::clear_selection(); + self.project_windows(); + return None; + } + self.selected_actor_id = None; + self.selected_inventory = None; + crate::windows::inventory::clear_selection(); + self.project_windows(); + if self.framebuffer.0 == 0 || self.framebuffer.1 == 0 { + return None; + } + let camera = self.world.get_component::(self.follow).copied()?; + let Projection::Ortho { + half_height, + near, + far, + } = camera.projection + else { + return None; + }; + let aspect = self.framebuffer.0 as f32 / self.framebuffer.1 as f32; + let vp = Mat4::ortho( + -half_height * aspect, + half_height * aspect, + -half_height, + half_height, + near, + far, + ) + .mul(Mat4::look_at(camera.eye, camera.look_at, camera.up)); + let inv = vp.inverse(); + let ndc_x = x / self.framebuffer.0 as f32 * 2.0 - 1.0; + let ndc_y = 1.0 - y / self.framebuffer.1 as f32 * 2.0; + let near_point = inv.project_point(vec3(ndc_x, ndc_y, -1.0)); + let far_point = inv.project_point(vec3(ndc_x, ndc_y, 1.0)); + let ray = far_point.sub(near_point); + if ray.y.abs() < 1e-5 { + return None; + } + let hit = near_point.add(ray.scale(-near_point.y / ray.y)); + let goal = ( + (hit.x / WORLD_UNITS_PER_CELL).floor() as i32, + (hit.z / WORLD_UNITS_PER_CELL).floor() as i32, + ); + let player = self.store.actors.get(&self.store.player_actor_id)?; + let start = (player.x.floor() as i32, player.y.floor() as i32); + self.click_route = movement::route_grid(start, goal, |cx, cy| { + !self + .movement_collision + .anchor_clear(cx as f32 + 0.5, cy as f32 + 0.5) + }) + .unwrap_or_default(); + self.click_route_index = 0; + self.click_goal = (!self.click_route.is_empty()).then_some(goal); + self.streamed_world + .set_waypoint((!self.click_route.is_empty()).then_some((goal.0 as f32, goal.1 as f32))); + None + } + + /// Apply a receipt to the queue and visible pending/rejection state. + pub fn settle_window_command( + &mut self, + command_id: u64, + accepted: bool, + reason: Option, + ) { + if let Some(queue) = self.command_queue.as_mut() { + queue.settle(command_id); + } + self.pending_window_commands.retain(|id| *id != command_id); + if !accepted { + self.window_rejection = Some(reason.unwrap_or_else(|| "command rejected".into())); + } + } + pub fn settle_command(&mut self, command_id: u64, accepted: bool, reason: Option) { + // The envelope is still queued at this point, so the settling command + // can be identified before it is retired. Both the diegetic clip and + // the UI cue are chosen here for that reason. + let (accepted_audio, accepted_cue) = accepted + .then(|| { + self.command_queue + .as_ref()? + .pending_envelopes() + .find_map(|envelope| { + (envelope.command_id == command_id).then(|| match envelope.command { + ClientCommand::ToggleDoor { .. } + | ClientCommand::BuildToggleDoor { .. } => { + (Some(crate::audio::DOOR_CLIP), None) + } + ClientCommand::ReloadWeapon { .. } => { + (Some(crate::audio::RELOAD_CLIP), None) + } + _ => (None, ui_cue_for_settled(&envelope.command)), + }) + }) + }) + .flatten() + .unwrap_or((None, None)); + if let Some(queue) = self.command_queue.as_mut() { + queue.settle(command_id); + } + self.store.last_receipt = Some(GameCommandReceipt { + command_id, + accepted, + tick: self.store.tick, + reason_code: reason.clone(), + }); + if let Some(clip) = accepted_audio { + self.sfx.play_ui(clip); + } + if let Some(cue) = accepted_cue { + crate::audio::play_ui(&mut self.sfx, cue); + } + if !accepted { + crate::audio::play_ui(&mut self.sfx, crate::audio::UiCue::Deny); + self.window_rejection = Some(reason.unwrap_or_else(|| "command rejected".into())); + } + } + + fn open_inventory_radial(&mut self, container: String, stack_id: String) { + let selected = self + .win_model + .inventory + .row(&container, &stack_id) + .is_some_and(|row| !row.in_exchange()); + if !selected { + return; + } + crate::windows::inventory::select_identity(&container, &stack_id); + self.selected_inventory = Some((container, stack_id)); + self.selected_actor_id = None; + self.context_menu = Some(ContextMenu::InventoryRadial { + x: self.pointer_prev.0, + y: self.pointer_prev.1, + }); + self.project_windows(); + } + + fn apply_local_window_action(&mut self, local: crate::windows::WindowLocalAction) { + use crate::windows::WindowLocalAction::*; + match local { + Close => self.window_rejection = Some("local: close".into()), + Select(id) => { + self.selected_inventory = crate::windows::inventory::selected_identity() + .filter(|(container, stack_id)| { + self.win_model + .inventory + .row(container, stack_id) + .is_some_and(|row| !row.in_exchange()) + }) + .or_else(|| { + self.win_model + .inventory + .rows + .iter() + .find(|row| row.item_id == id && !row.in_exchange()) + .map(|row| (row.container.clone(), row.stack_id.clone())) + }); + if let Some((container, stack_id)) = &self.selected_inventory { + crate::windows::inventory::select_identity(container, stack_id); + } else { + crate::windows::inventory::clear_selection(); + } + self.selected_actor_id = None; + self.context_menu = None; + self.window_rejection = Some(format!("local: select {id}")); + self.project_windows(); + } + OpenWindow(id) => self.open_workspace_window(&id), + OpenInventoryRadial { + container, + stack_id, + } => self.open_inventory_radial(container, stack_id), + SetTheme(i) => { + self.theme_index = i % hud::THEME_COUNT; + self.preferences_dirty = true; + self.project_windows(); + } + SetDust(v) => { + self.dust_strength = v.clamp(0.0, 1.0); + self.project_windows(); + } + SetWindowOpacity(v) => { + self.window_opacity = v.clamp(hud::MIN_UI_OPACITY, hud::MAX_UI_OPACITY); + self.preferences_dirty = true; + self.project_windows(); + } + SetHudOpacity(v) => { + self.hud_opacity = v.clamp(hud::MIN_UI_OPACITY, hud::MAX_UI_OPACITY); + self.preferences_dirty = true; + self.project_windows(); + } + SetSplitSnap(v) => { + self.split_snap = v; + self.preferences_dirty = true; + self.project_windows(); + } + RebindToolbarSlot(i) => { + self.rebind_pending = (i < self.toolbar.doc.binds.len()).then_some(i); + self.project_windows(); + } + BeginAssignAction(id) => self.window_rejection = Some(format!("local: assign {id}")), + RunMacro(id) => { + self.window_rejection = self.macro_runtime.start(&id).err().map(str::to_string); + } + StopMacro(id) => self.macro_runtime.stop(&id), + SaveMacro { name, body } => { + self.window_rejection = self + .macro_runtime + .save_macro(&name, &body) + .err() + .map(str::to_string); + self.project_windows(); + } + DeleteMacro(id) => { + if !self.macro_runtime.delete(&id) { + self.window_rejection = Some("macro_not_found".into()); + } + self.project_windows(); + } + SubmitBugReport { category, body } => { + self.bug_report_sequence = self.bug_report_sequence.wrapping_add(1); + let request_id = format!( + "00000000-0000-4000-8000-{:012x}", + (successor_platform::now_ms() as u64) + .wrapping_mul(1_000) + .wrapping_add(self.bug_report_sequence as u64) + & 0x000f_ffff_ffff_ffff + ); + let player = self.store.actors.get(&self.player_id); + let diagnostics = crate::windows::bugreport::collect_diagnostics( + &crate::windows::bugreport::DiagnosticsInput { + client_release_id: option_env!("SUCCESSOR_CLIENT_RELEASE_ID") + .unwrap_or("source-build") + .into(), + server_release_id: String::new(), + shard_id: self.shard_id.clone(), + source_state_hash: self.store.source_state_hash.clone().unwrap_or_default(), + area_id: self.area_id.clone(), + position: player.map(|actor| (actor.x, actor.y)), + life_state: player + .map(|actor| actor.life_state.clone()) + .unwrap_or_default(), + selected_actor_id: self.selected_actor_id.clone(), + weapon_id: player + .and_then(|actor| actor.weapon.as_ref()) + .and_then(|weapon| weapon.weapon_id.clone()), + connected: true, + authority_tick: self.store.tick, + accepted_commands: 0, + rejected_commands: 0, + recent_receipts: self + .store + .last_receipt + .as_ref() + .map(|receipt| { + vec![( + receipt.command_id, + receipt.accepted, + receipt.reason_code.clone().unwrap_or_default(), + )] + }) + .unwrap_or_default(), + recent_errors: self.window_rejection.iter().cloned().collect(), + open_windows: self.open_window_ids(), + viewport: self.framebuffer, + fps: 0.0, + uptime_ms: successor_platform::now_ms() as u64, + }, + ); + self.pending_bug_report = Some(serde_json::json!({ + "schema": "successor.bug-report-submission.v1", + "requestId": request_id, + "category": category, + "body": crate::hud::sanitize_text(&body, crate::windows::bugreport::BODY_MAX_CHARS), + "diagnostics": diagnostics, + })); + crate::windows::set_bug_report_pending(request_id); + } + BugReportReset => { + self.window_rejection = None; + crate::windows::reset_bug_report(); + } + CreateWaypoint { x, y, name } => { + let result = self.waypoints.create( + name.as_deref(), + x, + y, + &self.area_id, + true, + successor_platform::now_ms() as u64, + ); + self.window_rejection = Some(result.status); + self.project_windows(); + } + RenameWaypoint { id, name } => { + let result = self.waypoints.rename(id, &name); + self.window_rejection = Some(result.status); + self.project_windows(); + } + SetWaypointActive { id, active } => { + let result = self.waypoints.set_active(id, active); + self.window_rejection = Some(result.status); + self.project_windows(); + } + DeleteWaypoint(id) => { + let result = self.waypoints.delete(id); + self.window_rejection = Some(result.status); + self.project_windows(); + } + } + } + /// Ingest a combat event: fire its VFX and, if new, spawn a short-lived + /// muzzle-flash point light at the shot origin (decays over 0.12 s). + pub fn ingest_combat(&mut self, ev: &crate::game::combat_fx::CombatEvent) { + let actor_point = |actor_id: &str| { + self.store.actors.get(actor_id).map(|actor| { + [ + (actor.x + 0.5) * WORLD_UNITS_PER_CELL, + (actor.y + 0.5) * WORLD_UNITS_PER_CELL, + ] + }) + }; + let Some(origin) = ev + .origin + .map(|point| { + [ + point[0] * WORLD_UNITS_PER_CELL, + point[1] * WORLD_UNITS_PER_CELL, + ] + }) + .or_else(|| actor_point(&ev.shooter_actor_id)) + else { + return; + }; + let Some(hit) = ev + .hit_point + .map(|point| { + [ + point[0] * WORLD_UNITS_PER_CELL, + point[1] * WORLD_UNITS_PER_CELL, + ] + }) + .or_else(|| actor_point(&ev.target_actor_id)) + else { + return; + }; + let origin_world = [ + origin[0], + self.terrain.height_at(origin[0], origin[1]) + ADULT_PAWN_HEIGHT_METERS * 0.7, + origin[1], + ]; + let hit_world = [ + hit[0], + self.terrain.height_at(hit[0], hit[1]) + ADULT_PAWN_HEIGHT_METERS * 0.5, + hit[1], + ]; + if self.combat_fx.trigger(ev, origin_world, hit_world) { + let (text, tone) = match ev.outcome { + crate::game::combat_fx::CombatOutcome::Dodge => { + ("MISS".to_string(), hud::overlays::FloatTone::Miss) + } + crate::game::combat_fx::CombatOutcome::Deflect => { + ("DEFLECT".to_string(), hud::overlays::FloatTone::Deflect) + } + crate::game::combat_fx::CombatOutcome::Sleep => { + ("SLEEP".to_string(), hud::overlays::FloatTone::Status) + } + _ if ev.damage > 0.0 => ( + format!("-{:.0}", ev.damage), + hud::overlays::FloatTone::Damage, + ), + _ => ("0".to_string(), hud::overlays::FloatTone::Deflect), + }; + self.overlays.push_float(&ev.target_actor_id, &text, tone); + crate::audio::play_combat(&mut self.sfx, ev, origin_world, hit_world); + let e = self.world.spawn(); + self.world.set_component( + e, + Transform { + pos: vec3(origin_world[0], origin_world[1], origin_world[2]), + rot: successor_engine_core::math::Quat::IDENTITY, + scale: Vec3::ONE, + }, + ); + self.world.set_component( + e, + successor_engine_render::components::PointLight { + color: ev.weapon.color(), + intensity: 6.0, + radius: 5.0, + }, + ); + self.muzzle_lights.push((e, 0.12)); + self.world.flush(); + } + } + + /// Decay transient muzzle lights; despawn expired ones. + fn decay_muzzle_lights(&mut self, dt: f32) { + let mut i = 0; + while i < self.muzzle_lights.len() { + let (e, ttl) = self.muzzle_lights[i]; + let ttl = ttl - dt; + if ttl <= 0.0 { + self.world.destroy(e); + self.muzzle_lights.swap_remove(i); + } else { + self.muzzle_lights[i].1 = ttl; + if let Some(pl) = self + .world + .get_component::(e) + { + let mut pl = *pl; + pl.intensity = 6.0 * (ttl / 0.12); + self.world.set_component(e, pl); + } + i += 1; + } + } + self.world.flush(); + } + + fn ground_height_at(&self, x: f32, z: f32) -> f32 { + self.props_loader + .floor_height_at(x, z) + .unwrap_or_else(|| self.terrain.height_at(x, z)) + } + + /// The player's current world position (falls back to the slice centre). + pub fn player_pos(&self) -> Vec3 { + if let Some(pawn) = self.pawns.get(&self.player_id) { + return vec3( + (pawn.render_pos.0 + 0.5) * WORLD_UNITS_PER_CELL, + pawn.ground_y, + (pawn.render_pos.1 + 0.5) * WORLD_UNITS_PER_CELL, + ); + } + if let Some(actor) = self.store.actors.get(&self.player_id) { + let x = (actor.x + 0.5) * WORLD_UNITS_PER_CELL; + let z = (actor.y + 0.5) * WORLD_UNITS_PER_CELL; + return vec3(x, self.ground_height_at(x, z), z); + } + self.center + } + + /// The player's current smoothed gait speed (diagnostic: should be stable + /// while walking, not oscillating 0↔spike). + pub fn player_speed(&self) -> f32 { + self.pawns + .get(&self.player_id) + .map(|p| p.speed) + .unwrap_or(0.0) + } + pub fn actor_count(&self) -> usize { + self.store.actors.len() + } + + /// Spawn a pawn using the actor's authoritative archetype and attachments. + /// Streamed-gear retry: pawns spawned while wardrobe/weapon models were + /// still in flight are rebuilt through the ordinary stale-pawn path once + /// every outstanding model settles (Ready or terminally Missing). + fn retry_pending_gear(&mut self, gpu: &mut G, platform: &mut dyn Platform) { + self.gear_retry.clear(); + for (id, pawn) in &self.pawns { + if !pawn.pending_equipment.is_empty() || pawn.pending_weapon { + self.gear_retry.push(id.clone()); + } + } + for id in self.gear_retry.drain(..) { + let Some(pawn) = self.pawns.get(&id) else { + continue; + }; + let route = pawn.route; + let pending_equipment = pawn.pending_equipment.clone(); + let pending_weapon = pawn.pending_weapon; + let weapon_id = pawn.presentation.weapon.clone(); + let weapon_item_id = pawn.presentation.weapon_item_id; + let Some(body) = self.pawn_catalog.body_mut(route) else { + continue; + }; + let joints = body.template.joint_count(); + let female = matches!(route, BodyRoute::Human { female: true }); + let mut settled = true; + for item_id in &pending_equipment { + let stream = self.pawn_catalog.equipment_piece( + gpu, + &mut self.renderer, + platform, + &mut self.streamer, + item_id, + joints, + female, + ); + if matches!(stream, crate::assets::stream::Streamed::Pending) { + settled = false; + } + } + if pending_weapon { + let stream = self.pawn_catalog.weapon_rig_for( + gpu, + &mut self.renderer, + platform, + &mut self.streamer, + weapon_id.as_deref(), + weapon_item_id, + ); + if matches!(stream, crate::assets::stream::Streamed::Pending) { + settled = false; + } + } + if settled { + self.stale_pawns.push(id); + } + } + } + + #[allow(clippy::too_many_arguments)] + fn spawn_pawn( + &mut self, + gpu: &mut G, + platform: &mut dyn Platform, + actor: &LiveActor, + faction: Option<[f32; 3]>, + ) { + let requested = route_for(actor.sprite.as_deref(), &actor.id); + // A streamed body still in flight defers the whole pawn: it stays + // absent (projection continues, no meshes) and spawn retries on a + // later frame. A terminal miss keeps the typed fallback presentation. + let body_stream = self.pawn_catalog.body_for( + gpu, + &mut self.renderer, + platform, + &mut self.streamer, + requested, + ); + let route = match body_stream { + crate::assets::stream::Streamed::Pending => return, + crate::assets::stream::Streamed::Ready(body) => { + if body.is_some() { + requested + } else { + BodyRoute::Human { female: false } + } + } + }; + let (body_parts, scale, joints, hand, animator) = { + let body = self + .pawn_catalog + .body_mut(route) + .expect("required fallback body loaded"); + ( + body.part_meshes + .iter() + .zip(&body.part_material_names) + .map(|((mesh, material), name)| (*mesh, *material, name.clone())) + .collect::>(), + body.scale, + body.template.joint_count(), + weapon_hand_bone(&body.template), + PawnAnimator::new(&body.template), + ) + }; + let mut equipment = actor.worn.clone(); + if equipment.is_empty() + && matches!(route, BodyRoute::Human { .. }) + && actor.role.as_deref() != Some("player") + { + let mut defaults = Vec::new(); + self.pawn_catalog.default_outfit( + &actor.id, + actor.role.as_deref(), + actor.hair.as_deref(), + &mut defaults, + ); + equipment.extend(defaults.into_iter().map(|item_id| WornPresentation { + item_id: item_id.to_string(), + colors: Vec::new(), + })); + } + if let Some(hair) = actor + .hair + .as_deref() + .filter(|item_id| self.pawn_catalog.knows_equipment(item_id)) + { + if !equipment.iter().any(|piece| piece.item_id == hair) { + equipment.push(WornPresentation { + item_id: hair.to_string(), + colors: Vec::new(), + }); + } + } + let mut equipment_meshes = Vec::new(); + let mut attached_equipment_ids = Vec::with_capacity(equipment.len()); + let mut pending_equipment: Vec = Vec::new(); + for worn_piece in &equipment { + let piece_stream = self.pawn_catalog.equipment_piece( + gpu, + &mut self.renderer, + platform, + &mut self.streamer, + &worn_piece.item_id, + joints, + matches!(route, BodyRoute::Human { female: true }), + ); + let loaded = match piece_stream { + crate::assets::stream::Streamed::Pending => { + pending_equipment.push(worn_piece.item_id.clone()); + continue; + } + crate::assets::stream::Streamed::Ready(piece) => piece + .map(|piece| (piece.part_meshes.clone(), piece.part_material_names.clone())), + }; + let Some((part_meshes, material_names)) = loaded else { + continue; + }; + if part_meshes.is_empty() { + continue; + } + for ((mesh, authored_material), material_name) in + part_meshes.into_iter().zip(material_names) + { + let material = self + .pawn_catalog + .equipment_part_color( + &worn_piece.item_id, + material_name.as_deref(), + &worn_piece.colors, + actor.hair_material.as_deref(), + ) + .map(|color| { + let mut desc = self + .renderer + .material_desc(authored_material) + .unwrap_or_default(); + desc.base_color = color; + desc.blend = color[3] < 1.0; + self.renderer.add_material_desc(desc) + }) + .unwrap_or(authored_material); + equipment_meshes.push((mesh, material)); + } + attached_equipment_ids.push(worn_piece.item_id.as_str()); + } + // Coverage is only defined for the standard human bodies. A failed + // special-body load may fall back to this mesh, but must retain its + // pre-existing special/creature presentation semantics. Only renderable + // apparel can claim skin, so an optional-asset degradation stays whole. + let hidden_body_zones = if matches!(requested, BodyRoute::Human { .. }) { + self.pawn_catalog.hidden_body_zones(attached_equipment_ids) + } else { + Default::default() + }; + + let base = skin_tint(actor.skin.as_deref()); + let color = faction_tinted(base, faction); + let mut tinted_body_parts = Vec::with_capacity(body_parts.len()); + for (mesh, authored_material, material_name) in body_parts { + if hidden_body_zones.hides_material_name(material_name.as_deref()) { + continue; + } + let material = if material_name.as_deref() == Some("RB_Face") { + authored_material + } else { + let mut desc = self + .renderer + .material_desc(authored_material) + .unwrap_or_default(); + desc.base_color = color; + desc.blend = color[3] < 1.0; + self.renderer.add_material_desc(desc) + }; + tinted_body_parts.push((mesh, material)); + } + let mut entities = Vec::with_capacity(tinted_body_parts.len() + equipment_meshes.len()); + for (mesh, material) in tinted_body_parts.into_iter().chain(equipment_meshes) { + let e = self.world.spawn(); + self.world.set_component( + e, + Transform { + pos: self.center, + rot: Quat::IDENTITY, + scale: vec3(scale, scale, scale), + }, + ); + self.world.set_component( + e, + MeshRenderer { + mesh, + material, + viewport_mask: 0b1, + skin: SkinRef::NONE, + }, + ); + entities.push(e); + } + + let weapon_stream = self.pawn_catalog.weapon_rig_for( + gpu, + &mut self.renderer, + platform, + &mut self.streamer, + actor.weapon.as_deref(), + actor.weapon_item_id, + ); + let mut pending_weapon = false; + let resolved_weapon = match weapon_stream { + crate::assets::stream::Streamed::Pending => { + pending_weapon = true; + None + } + crate::assets::stream::Streamed::Ready(rig) => rig, + }; + let resolved_weapon = resolved_weapon.map(|rig| { + ( + rig.parts.clone(), + HeldWeaponRig { + mount: rig.mount, + grip: rig.grip, + foregrip: rig.foregrip, + muzzle: rig.muzzle, + foregrip_contact: rig.foregrip_contact, + resting_yaw_rad: rig.resting_yaw_rad, + support_arm: rig.support_arm, + support_hand: rig.support_hand, + }, + rig.melee, + rig.plasma_blade_part, + rig.stow.clone(), + ) + }); + let lane = resolved_weapon + .as_ref() + .map(|(_, _, melee, _, _)| { + if *melee { + WeaponLane::Melee + } else { + WeaponLane::Rifle + } + }) + .unwrap_or_else(|| weapon_lane(actor.weapon.as_deref())); + let stow = resolved_weapon + .as_ref() + .and_then(|(_, _, _, _, stow)| stow.as_ref()) + .and_then(|stow| { + self.pawn_catalog + .body_mut(route) + .and_then(|body| body.template.skeleton.find_bone(&stow.bone)) + .map(|bone| (bone, stow.mount, stow.arc_lift)) + }); + let weapon = resolved_weapon + .map(|(parts, held, _, plasma_blade_part, _)| (parts, held, plasma_blade_part)) + .zip(hand) + .map(|((parts, held, plasma_blade_part), hand)| { + let mut weapon_entities = Vec::with_capacity(parts.len()); + let mut plasma_blade = None; + for (part_index, (mesh, material, local)) in parts.into_iter().enumerate() { + let entity = self.world.spawn(); + let (pos, rot, part_scale) = local.to_trs(); + self.world.set_component( + entity, + Transform { + pos, + rot, + scale: part_scale, + }, + ); + self.world.set_component( + entity, + MeshRenderer { + mesh, + material, + viewport_mask: 0b1, + skin: SkinRef::NONE, + }, + ); + if plasma_blade_part == Some(part_index) { + plasma_blade = Some(entity); + } + weapon_entities.push((entity, local)); + } + WeaponAttachment { + entities: weapon_entities, + hand, + held, + plasma_blade, + stow, + stow_blend: if stow.is_some() && !actor.in_combat { + 1.0 + } else { + 0.0 + }, + stow_target: stow.is_some() && !actor.in_combat, + stow_seconds: 0.28, + ik_weight: if stow.is_some() && !actor.in_combat { + 0.0 + } else { + 1.0 + }, + } + }); + + // A wardrobe rebuild parked the stride this actor was mid-way through. + // Reclaim it when the body underneath is the same one, so a clothing + // swap never restarts the animation from its bind pose. A different + // body is a different skeleton, and starts clean. + let carried = self + .carried_motion + .remove(&actor.id) + .filter(|carried| carried.route == route); + + let (animator, lane, interp, predictor, target, render_pos, ground_y, speed, yaw) = + match carried { + Some(carried) => ( + carried.animator, + carried.lane, + carried.interp, + carried.predictor, + carried.target, + carried.render_pos, + carried.ground_y, + carried.speed, + carried.yaw, + ), + None => { + let mut interp = ActorInterp::new(); + interp.push(self.sim_time, actor.x, actor.y, actor.lifecycle_seq); + let wx = (actor.x + 0.5) * WORLD_UNITS_PER_CELL; + let wz = (actor.y + 0.5) * WORLD_UNITS_PER_CELL; + ( + animator, + lane, + interp, + MovePredictor::new(actor.x, actor.y), + (actor.x, actor.y), + (actor.x, actor.y), + self.ground_height_at(wx, wz), + 0.0, + 0.0, + ) + } + }; + + self.pawns.insert( + actor.id.clone(), + ActorPawn { + id: actor.id.clone(), + name: actor.name.clone(), + descriptor: actor + .role + .as_deref() + .map(|role| { + format!( + "({})", + hud::sanitize_text(&role.replace(['_', '-'], " "), 32) + ) + }) + .filter(|role| role != "()"), + presentation: PawnPresentation { + skin: actor.skin.clone(), + faction: actor.faction.clone(), + sprite: actor.sprite.clone(), + role: actor.role.clone(), + hair: actor.hair.clone(), + hair_material: actor.hair_material.clone(), + worn: actor.worn.clone(), + weapon: actor.weapon.clone(), + weapon_item_id: actor.weapon_item_id, + }, + entities, + weapon, + animator, + route, + lane, + scale, + interp, + predictor, + lifecycle_seq: actor.lifecycle_seq, + alive: actor.alive, + target, + render_pos, + ground_y, + speed, + yaw, + present: true, + pending_equipment, + pending_weapon, + }, + ); + } + + fn sync_active_area(&mut self, gpu: &mut G, platform: &mut dyn Platform) { + if self.area_id.is_empty() || self.loaded_area_id == self.area_id { + return; + } + let area_id = self.area_id.clone(); + let player = self.player_pos(); + let player_cell = ( + player.x / WORLD_UNITS_PER_CELL, + player.z / WORLD_UNITS_PER_CELL, + ); + self.props_loader.clear(&mut self.world); + self.collision_debug.clear_area(&mut self.world); + self.streamed_world.clear(&mut self.world); + self.terrain.clear(&mut self.world, &mut self.renderer, gpu); + let mut terrain = TerrainStreamer::new( + effective_world_seed(&self.slice, &area_id) as i32, + biome_for_area(&self.slice, &area_id), + 64.0 * WORLD_UNITS_PER_CELL as f64, + 3, + 0b1, + ); + let exclusions = building_terrain_exclusions(&self.slice, Some(&area_id), 1.5); + terrain.set_exclusions(&exclusions); + terrain.ensure_around( + &mut self.world, + &mut self.renderer, + gpu, + player.x as f64, + player.z as f64, + ); + self.props_loader.begin_area(&self.slice, Some(&area_id)); + self.collision_debug.load_area( + &mut self.world, + &mut self.renderer, + gpu, + &self.slice, + &area_id, + &terrain, + &self.store.prop_states, + ); + self.movement_collision + .rebuild(&self.slice, &area_id, &self.collision_debug); + self.terrain = terrain; + self.loaded_area_id = area_id; + // Crossing an area boundary is a teleport, not movement to trail. + self.camera_center = player; + self.camera_follow_initialized = true; + // Region-streamed props: place whatever of the destination spawn + // neighborhood is already cached, queue the rest, and hold the travel + // transition until the neighborhood settles (deadline: fail-closed + // marker placement via force_place_neighborhood). + self.travel_hold = Some(TravelHold { + deadline_ms: platform.monotonic_ms() + 30_000, + }); + let placed = self.props_loader.sync_regions( + &mut self.world, + &mut self.renderer, + gpu, + &self.terrain, + platform, + &mut self.streamer, + player_cell, + 0b1, + ); + eprintln!( + "connected: active area {} streaming, {placed} props placed, {} region-gated", + self.loaded_area_id, + self.props_loader.pending_count(), + ); + } + + /// Per-frame: reconcile pawns with the authoritative actor set, animate, and + /// render the full scene + FX + HUD. + #[allow(clippy::too_many_arguments)] + + pub fn frame( + &mut self, + gpu: &mut G, + w: u32, + h: u32, + dt: f32, + platform: &mut dyn Platform, + chat_client: &mut crate::game::chat_net::ChatClient, + chat_input: &mut successor_engine_render::ui::TextField, + ) { + self.last_frame_dt = dt.max(0.0); + // Stream completions land before anything consumes them this frame. + self.streamer.pump(platform); + + // Travel hold: keep streaming the destination spawn neighborhood and + // release the transition once it settles; the deadline forces + // fail-closed marker placement rather than hanging the transition. + if let Some(hold) = &self.travel_hold { + let deadline_ms = hold.deadline_ms; + let player_cell = self + .store + .actors + .get(&self.store.player_actor_id) + .map(|actor| (actor.x, actor.y)) + .unwrap_or((0.0, 0.0)); + self.props_loader.sync_regions( + &mut self.world, + &mut self.renderer, + gpu, + &self.terrain, + platform, + &mut self.streamer, + player_cell, + 0b1, + ); + if self.props_loader.pending_in_region(player_cell) == 0 { + self.travel_hold = None; + } else if platform.monotonic_ms() > deadline_ms { + eprintln!("connected: travel stream deadline; marker-placing stragglers"); + self.props_loader.force_place_region( + &mut self.world, + &mut self.renderer, + gpu, + &self.terrain, + player_cell, + 0b1, + ); + self.travel_hold = None; + } + } + if self.loading || self.travel_hold.is_some() { + self.ui.begin(w, h); + self.loading_screen.tick(dt); + self.loading_screen.draw(&mut self.ui, w as f32, h as f32); + gpu.begin_pass( + PassTarget::Screen, + RectPx { + x: 0, + y: 0, + w: w as i32, + h: h as i32, + }, + ClearSpec { + color: Some([0.012, 0.027, 0.039, 1.0]), + depth: Some(1.0), + }, + ); + gpu.end_pass(); + self.renderer + .render_ui(gpu, &self.ui.buf, self.ui.quads, w, h); + return; + } + self.framebuffer = (w, h); + // HUD panes are edge-anchored furniture, not player-arranged windows: a + // rect captured at one framebuffer strands the bottom band mid-screen at + // any other. This is the first point the real viewport is known, so it + // is where a restored layout is reconciled against it — and where a live + // window resize re-anchors the band to the new edges. + let live = (w as f32, h as f32); + if hud_anchor_is_stale(self.hud_layout_viewport, live) { + self.hud_defaults_pending = [true; hud::HUD_SURFACE_COUNT]; + self.hud_layout_viewport = Some(live); + } + if self.hud_defaults_pending.iter().any(|missing| *missing) { + hud::apply_missing_hud_surface_defaults(&mut self.wm, live, &self.hud_defaults_pending); + self.hud_defaults_pending = [false; hud::HUD_SURFACE_COUNT]; + } + self.sync_active_area(gpu, platform); + // Region watcher: stream prop models for the player's 3×3 region + // neighborhood; pending props place on the frame their bytes land. + if self.props_loader.pending_count() > 0 { + let player_cell = self + .store + .actors + .get(&self.store.player_actor_id) + .map(|actor| (actor.x, actor.y)) + .unwrap_or((0.0, 0.0)); + self.props_loader.sync_regions( + &mut self.world, + &mut self.renderer, + gpu, + &self.terrain, + platform, + &mut self.streamer, + player_cell, + 0b1, + ); + } + self.macro_actions.clear(); + + self.macro_runtime.tick( + self.store.tick, + self.selected_actor_id.as_deref(), + &mut self.macro_actions, + ); + let mut macro_actions = core::mem::take(&mut self.macro_actions); + for action in macro_actions.drain(..) { + self.dispatch_gameplay_action(action); + } + self.macro_actions = macro_actions; + // 1) Reconcile pawn set with live actors. + for pawn in self.pawns.values_mut() { + pawn.present = false; + } + self.missing_pawns.clear(); + self.stale_pawns.clear(); + self.retry_pending_gear(gpu, platform); + for (id, actor) in self.store.render_actors() { + if !self.pawns.contains_key(id) { + self.missing_pawns.push(id.clone()); + } + if let Some(pawn) = self.pawns.get_mut(id) { + if !pawn.presentation.matches(actor) { + self.stale_pawns.push(id.clone()); + continue; + } + pawn.present = true; + let authority_changed = + pawn.target != (actor.x, actor.y) || pawn.lifecycle_seq != actor.lifecycle_seq; + pawn.target = (actor.x, actor.y); + pawn.alive = actor.life_state == "alive"; + if let Some(weapon) = pawn.weapon.as_mut() { + weapon.stow_target = weapon.stow.is_some() && !actor.in_combat.unwrap_or(false); + } + if authority_changed { + pawn.lifecycle_seq = actor.lifecycle_seq; + if id == &self.player_id { + let moving = self.move_intent.0 != 0 || self.move_intent.1 != 0; + pawn.predictor + .reconcile(actor.x, actor.y, moving, self.move_intent.2); + } else { + pawn.interp + .push(self.sim_time, actor.x, actor.y, actor.lifecycle_seq); + } + } + } + } + while let Some(id) = self.stale_pawns.pop() { + if let Some(pawn) = self.pawns.remove(&id) { + for entity in pawn.owned_entities() { + self.world.destroy(entity); + } + self.carried_motion.insert( + id.clone(), + CarriedMotion { + animator: pawn.animator, + route: pawn.route, + lane: pawn.lane, + interp: pawn.interp, + predictor: pawn.predictor, + render_pos: pawn.render_pos, + ground_y: pawn.ground_y, + target: pawn.target, + speed: pawn.speed, + yaw: pawn.yaw, + }, + ); + } + self.missing_pawns.push(id); + } + self.world.flush(); + while let Some(id) = self.missing_pawns.pop() { + let Some(actor) = self.store.actors.get(&id) else { + continue; + }; + let live = LiveActor { + id, + x: actor.x, + name: hud::clean_actor_name(&actor.display_name, &actor.label, &actor.id), + y: actor.y, + skin: actor + .appearance + .as_ref() + .and_then(|ap| ap.skin_tone.clone()), + faction: actor.faction_id.clone(), + sprite: actor.sprite.clone(), + role: actor.role.clone(), + hair: actor.appearance.as_ref().and_then(|ap| ap.hair.clone()), + hair_material: actor + .appearance + .as_ref() + .and_then(|appearance| appearance.hair_material.clone()), + worn: actor + .worn + .iter() + .filter_map(|piece| { + piece.item_id.as_ref().map(|item_id| WornPresentation { + item_id: item_id.clone(), + colors: piece.colors.clone(), + }) + }) + .collect(), + weapon: actor + .weapon + .as_ref() + .and_then(|weapon| weapon.weapon_id.clone()), + weapon_item_id: actor + .weapon + .as_ref() + .and_then(|weapon| weapon.weapon_item_id), + in_combat: actor.in_combat.unwrap_or(false), + alive: actor.life_state == "alive", + lifecycle_seq: actor.lifecycle_seq, + }; + let faction = live.faction.as_deref().map(faction_rgb); + self.spawn_pawn(gpu, platform, &live, faction); + } + self.sim_time += dt.max(0.0); + + // Every open viewer renders its own subject this frame. Each composite + // is banded by the owning window's draw rank, so a viewer paints over + // its own panel and under anything stacked above it — the original + // flushes the UI queue, renders the widget's 3D scene, then carries on + // with the panels above. Nothing here picks a single winner. + let mut doll_subjects: [Option<&str>; DOLL_WINDOWS.len()] = [None; DOLL_WINDOWS.len()]; + for (slot, window) in DOLL_WINDOWS.iter().enumerate() { + if !self.wm.is_open(window) || self.wm.is_iconified(window) { + continue; + } + doll_subjects[slot] = match *window { + "examine" => self + .win_model + .examine + .actor + .as_ref() + .map(|actor| actor.actor_id.as_str()), + "converse" => self + .win_model + .converse + .npc + .as_ref() + .map(|npc| npc.actor_id.as_str()), + _ => Some(self.player_id.as_str()), + }; + } + if self.paperdoll_drag_x.is_none() { + let dt = dt.max(0.0); + if self.paperdoll_spin.abs() > PAPERDOLL_SPIN_EPS { + self.paperdoll_yaw += self.paperdoll_spin * dt; + // 0.96 per 60 Hz frame, resolved for this frame's dt. + self.paperdoll_spin *= PAPERDOLL_SPIN_DECAY.powf(dt * 60.0); + } else { + // The flick is spent: the doll holds the angle the player left + // it at rather than springing back or turning on its own. + self.paperdoll_spin = 0.0; + } + } + self.paperdoll_yaw = self.paperdoll_yaw.rem_euclid(core::f32::consts::TAU); + + // 2) Animate + place pawns (skinned). + self.renderer.begin_skin_frame(); + // The world pawn and every viewer share one instance: the same meshes, + // skinning and animation tick feed all of them, exactly as the original + // hands its live `ClientObject` to each widget rather than cloning. + // Visibility is per-viewport, so one pawn can be in the world and in + // several dolls in the same frame. + let doll_mask_for = |pawn_id: &str| doll_viewport_mask(&doll_subjects, pawn_id); + let terrain = &self.terrain; + let props_loader = &self.props_loader; + for pawn in self.pawns.values_mut() { + if !pawn.present { + for entity in pawn.owned_entities() { + if let Some(transform) = self.world.get_component::(entity) { + transform.pos = vec3(0.0, -10_000.0, 0.0); + } + } + continue; + } + let (rx, ry) = pawn.render_pos; + let (nx, ny, gait_distance) = if pawn.id == self.player_id { + let input_distance = pawn.predictor.predict( + &self.movement_collision, + self.move_intent.0 as f32, + self.move_intent.1 as f32, + self.move_intent.2, + if self.move_intent.2 { + self.sprint_speed_cells_per_second + } else { + self.walk_speed_cells_per_second + }, + dt, + ); + let predicted = pawn.predictor.render_pos(); + (predicted.0, predicted.1, input_distance) + } else { + let sampled = pawn.interp.sample(self.sim_time).unwrap_or(pawn.target); + let distance = ((sampled.0 - rx) * (sampled.0 - rx) + + (sampled.1 - ry) * (sampled.1 - ry)) + .sqrt(); + (sampled.0, sampled.1, distance) + }; + let moved = ((nx - rx) * (nx - rx) + (ny - ry) * (ny - ry)).sqrt(); + pawn.speed = filtered_gait_speed(pawn.speed, gait_distance, dt); + if moved > 1e-4 { + pawn.yaw = (nx - rx).atan2(ny - ry); + } + pawn.render_pos = (nx, ny); + if let Some(weapon) = pawn.weapon.as_mut() { + let step = (dt.max(0.0) / weapon.stow_seconds.max(1.0e-3)).min(1.0); + weapon.stow_blend = if weapon.stow_target { + (weapon.stow_blend + step).min(1.0) + } else { + (weapon.stow_blend - step).max(0.0) + }; + let ik_target = if weapon.stow_blend < 1.0e-3 { 1.0 } else { 0.0 }; + let ik_step = (dt.max(0.0) / 0.12).min(1.0); + weapon.ik_weight += (ik_target - weapon.ik_weight).clamp(-ik_step, ik_step); + } + let armed_blend = pawn + .weapon + .as_ref() + .map_or(0.0, |weapon| 1.0 - weapon.stow_blend); + let transition = pawn.weapon.as_ref().and_then(|weapon| { + (pawn.lane == WeaponLane::Melee + && weapon.stow_blend > 0.0 + && weapon.stow_blend < 1.0) + .then_some(if weapon.stow_target { + ("melee_sheath", weapon.stow_blend) + } else { + ("melee_draw", 1.0 - weapon.stow_blend) + }) + }); + + let body = self + .pawn_catalog + .body_mut(pawn.route) + .expect("spawned pawn body remains loaded"); + pawn.animator.update_weapon_transition( + &mut body.template, + pawn.lane, + armed_blend, + transition, + pawn.speed, + false, + pawn.alive, + (pawn.id == self.player_id).then_some(self.move_intent.2), + dt, + ); + if pawn.alive && pawn.lane == WeaponLane::Rifle { + if let Some(weapon) = &pawn.weapon { + if weapon.held.support_hand { + let hand_global = body.template.skeleton.bone_global(weapon.hand); + let held_socket = apply_weapon_bore_correction( + hand_global.mul(weapon.held.mount), + weapon.held, + ); + let corrected_mount = hand_global.inverse().mul(held_socket); + pawn.animator.apply_rifle_support_ik_weighted( + &mut body.template, + corrected_mount, + weapon.held.foregrip, + weapon.held.foregrip_contact, + weapon.ik_weight, + weapon.held.support_arm, + ); + } + } + } + let palette = pawn.animator.palette(); + let count = palette.len() as u32; + let offset = self.renderer.push_skin_palette(palette); + let rotation = Quat::from_axis_angle(Vec3::Y, pawn.yaw); + let wx = (nx + 0.5) * WORLD_UNITS_PER_CELL; + let wz = (ny + 0.5) * WORLD_UNITS_PER_CELL; + let target_ground_y = props_loader + .floor_height_at(wx, wz) + .unwrap_or_else(|| terrain.height_at(wx, wz)); + pawn.ground_y = smooth_ground_height(pawn.ground_y, target_ground_y, dt); + let pawn_mask = doll_mask_for(&pawn.id); + for entity in &pawn.entities { + if let Some(transform) = self.world.get_component::(*entity) { + transform.pos = vec3(wx, pawn.ground_y, wz); + transform.rot = rotation; + } + if let Some(renderer) = self.world.get_component::(*entity) { + renderer.viewport_mask = pawn_mask; + renderer.skin = SkinRef { offset, count }; + } + } + let actor_world = Mat4::from_trs( + vec3(wx, pawn.ground_y, wz), + rotation, + vec3(pawn.scale, pawn.scale, pawn.scale), + ); + if let Some(weapon) = &pawn.weapon { + let raw_held_socket = body + .template + .skeleton + .bone_global(weapon.hand) + .mul(weapon.held.mount); + let held_socket = if pawn.lane == WeaponLane::Rifle { + apply_weapon_bore_correction(raw_held_socket, weapon.held) + } else { + raw_held_socket + }; + let rig_socket = weapon.stow.map_or(held_socket, |(bone, mount, arc_lift)| { + let ease = + weapon.stow_blend * weapon.stow_blend * (3.0 - 2.0 * weapon.stow_blend); + let blended = interpolate_mount( + held_socket, + body.template.skeleton.bone_global(bone).mul(mount), + ease, + ); + let (mut pos, rotation, scale) = blended.to_trs(); + pos.y += arc_lift * (core::f32::consts::PI * ease).sin(); + Mat4::from_trs(pos, rotation, scale) + }); + for &(entity, local) in &weapon.entities { + let part_local = if weapon.plasma_blade == Some(entity) { + let (_, rotation, scale) = local.to_trs(); + Mat4::from_trs( + vec3(0.0, 0.0, 0.09 + 0.375 * armed_blend), + rotation, + vec3(scale.x, scale.y * armed_blend, scale.z), + ) + } else { + local + }; + let (pos, rig_rotation, rig_scale) = + actor_world.mul(rig_socket).mul(part_local).to_trs(); + if let Some(transform) = self.world.get_component::(entity) { + transform.pos = pos; + transform.rot = rig_rotation; + transform.scale = rig_scale; + } + if let Some(renderer) = self.world.get_component::(entity) { + // Inventory/examine portraits frame the actor silhouette; + // the wielded prop has its own rotating item preview. + renderer.viewport_mask = 0b1; + } + } + } + } + + // 3) Camera trails the player's rendered position exponentially while + // gameplay, streaming, and GI stay anchored to the exact position. + + let p = self.player_pos(); + self.center = p; + self.renderer.gi_set_focus([p.x, p.y, p.z]); + if self.camera_follow_initialized { + self.camera_center = smooth_follow(self.camera_center, p, dt); + } else { + self.camera_center = p; + self.camera_follow_initialized = true; + } + let focus = follow_focus(self.camera_center); + let eye = match self.debug_camera { + Some(orbit) => orbit.eye(self.camera_center), + None => follow_eye(self.camera_center), + }; + if let Some(cam) = self.world.get_component::(self.follow) { + cam.look_at = focus; + cam.eye = eye; + } + + if let Some(player) = self.store.actors.get(&self.store.player_actor_id) { + self.props_loader.update_cutaways( + &mut self.world, + self.store.tick, + player.x, + player.y, + p.y, + dt, + ); + } + + { + let mut read_asset = |stable_id: &str| platform.read_asset(stable_id).ok(); + self.streamed_world.sync( + &mut self.world, + &mut self.renderer, + gpu, + &self.terrain, + &self.store, + &self.area_id, + &mut read_asset, + dt, + ); + } + + // Streamed clock and weather own sun, clear color, grade, fog, and + + // precipitation. The noon/clear build state lasts only until accepted + // authority sections arrive. + self.environs.apply_clock(self.store.world_clock()); + let player_cell = self + .store + .actors + .get(&self.player_id) + .map(|actor| (actor.x, actor.y)) + .unwrap_or((0.0, 0.0)); + self.environs + .apply_weather(self.store.weather(), &self.area_id, player_cell); + let env = self.environs.sample(dt); + let half_height = 12.5 * self.zoom_percent / 100.0; + let (fog_near, fog_far) = self.environs.fog_range(half_height); + self.renderer.set_fog(env.fog, fog_near, fog_far); + self.renderer.set_grade( + env.bone_tint, + env.desaturate, + env.scene_darken, + env.black_lift, + ); + let sun_angle = -45.0_f32.to_radians(); + let (sun_sin, sun_cos) = sun_angle.sin_cos(); + if let Some(light) = self.world.get_component::(self.sun) { + light.dir = vec3( + env.sun_dir[0] * sun_cos + env.sun_dir[2] * sun_sin, + env.sun_dir[1], + -env.sun_dir[0] * sun_sin + env.sun_dir[2] * sun_cos, + ) + .normalize(); + light.color = env.sun_color; + } + if let Some(camera) = self.world.get_component::(self.follow) { + camera.clear.color = Some([env.fog[0], env.fog[1], env.fog[2], 1.0]); + } + for (slot, window) in DOLL_WINDOWS.iter().enumerate() { + let subject = doll_subjects[slot]; + let preview = subject + .and_then(|_| self.wm.content_rect(window)) + .map(|content| match *window { + "inventory" => crate::windows::inventory::layout(content).preview, + "character" => crate::windows::character::preview_rect(content), + "examine" => crate::windows::live::examine_preview_rect(content), + _ => crate::windows::live::converse_preview_rect(content), + }); + let (Some(subject), Some(preview)) = (subject, preview) else { + self.dolls[slot].viewport = None; + let camera = self.dolls[slot].camera; + let quad = self.dolls[slot].quad; + self.world.remove_component::(camera); + self.world.remove_component::(quad); + continue; + }; + // A collapsed cell renders nothing, matching the original's bail + // when its clipped widget rect comes out empty. + if preview[2] <= 1.0 || preview[3] <= 1.0 { + self.dolls[slot].viewport = None; + let camera = self.dolls[slot].camera; + self.world.remove_component::(camera); + self.world + .remove_component::(self.dolls[slot].quad); + continue; + } + let (portrait_ground, yaw) = self + .pawns + .get(subject) + .map(|pawn| { + let wx = (pawn.render_pos.0 + 0.5) * WORLD_UNITS_PER_CELL; + let wz = (pawn.render_pos.1 + 0.5) * WORLD_UNITS_PER_CELL; + (vec3(wx, pawn.ground_y, wz), pawn.yaw) + }) + .unwrap_or((p, 0.0)); + // Framing is preserved while adopting the original's 22.5° FOV: + // the subject height a camera covers is `2 * d * tan(fovy / 2)`, so + // pulling the lens in from the old wide angle means pushing the + // camera back by the ratio of those tangents. + let (focus_height, prior_distance, prior_fovy): (f32, f32, f32) = match *window { + "converse" => (1.28, 0.95, 0.58), + _ => (0.90, 2.75, 0.68), + }; + let camera_distance = + prior_distance * (prior_fovy * 0.5).tan() / (PAPERDOLL_FOVY * 0.5).tan(); + let focus = portrait_ground.add(vec3(0.0, focus_height, 0.0)); + // Drag spins the doll; the flick decays and parks at the resting + // The converse bust looks the player in the eye: camera is dead-on + // facing the NPC's front (+Z in pawn space). Paperdoll viewers for + // inventory/character follow pawn yaw + player orbit drag. + let orbit = if *window == "converse" { + yaw + core::f32::consts::PI + } else { + yaw + self.paperdoll_yaw + }; + let facing = vec3(orbit.sin(), 0.0, orbit.cos()); + let band = self + .wm + .z_rank(window) + .map_or(0, |rank| rank as i16 * DOLL_BAND); + let slot_state = &self.dolls[slot]; + self.world.set_component( + slot_state.camera, + Camera { + viewport_id: doll_viewport(slot), + order: -1, + projection: Projection::Perspective { + fovy: PAPERDOLL_FOVY, + near: 0.05, + far: 20.0, + }, + target: CamTarget::Texture(slot_state.target), + // Transparent behind the doll: the original clears only + // depth/stencil for its 3D viewers, leaving the panel + // visible around the character. + clear: ClearSpec { + color: Some([0.0, 0.0, 0.0, 0.0]), + depth: Some(1.0), + }, + eye: focus + .add(facing.scale(camera_distance)) + .add(vec3(0.0, 0.08, 0.0)), + look_at: focus, + up: Vec3::Y, + }, + ); + self.world.set_component( + slot_state.quad, + CompositeQuad { + source: slot_state.target, + rect: RectNorm { + x: preview[0] / w as f32, + y: 1.0 - (preview[1] + preview[3]) / h as f32, + w: preview[2] / w as f32, + h: preview[3] / h as f32, + }, + order: band, + }, + ); + self.dolls[slot].viewport = Some(preview); + } + if self.dolls.iter().all(|slot| slot.viewport.is_none()) { + self.paperdoll_drag_x = None; + } + self.item_previews.sync( + gpu, + &mut self.renderer, + &mut self.world, + &self.wm, + &self.win_model, + w, + h, + self.sim_time, + platform, + &mut self.streamer, + ); + // Enterable cutaways advance only against the accepted authority tick. + // Prefer the same authority actor centre used by the browser renderer; + // the rendered camera position is only a startup fallback. + let (cutaway_x, cutaway_z) = self + .store + .actors + .get(&self.store.player_actor_id) + .map(|actor| { + ( + (actor.x + 0.5) * WORLD_UNITS_PER_CELL, + (actor.y + 0.5) * WORLD_UNITS_PER_CELL, + ) + }) + .unwrap_or((p.x, p.z)); + let snapshot_tick = self.store.tick; + let prop_states = &self.store.prop_states; + self.props_loader.sync_enterable_presentation( + &mut self.world, + snapshot_tick, + cutaway_x, + p.y, + cutaway_z, + prop_states, + dt, + ); + let active_weather = self.environs.active_weather(); + self.weather + .set(active_weather.kind, active_weather.strength); + self.weather.update(dt); + + { + use successor_engine_core::audio::{Point, SpatialOpts}; + const WEATHER_LOOP_KEY: u32 = 0x5745_4154; + let listener = Point { x: p.x, y: p.z }; + self.sfx.set_listener(listener); + const MUSIC_LOOP_KEY: u32 = 0x4d55_5343; + const SETTLEMENT_LOOP_KEY: u32 = 0x5345_5454; + + if let Some((last_x, last_z)) = self.footstep_position { + let dx = p.x - last_x; + let dz = p.z - last_z; + let distance = (dx * dx + dz * dz).sqrt(); + if (0.002..=2.0).contains(&distance) { + self.footstep_distance += distance; + } else if distance > 2.0 { + self.footstep_distance = 0.0; + } + } + self.footstep_position = Some((p.x, p.z)); + let stride = if self.sprint_toggle || self.move_intent.2 { + 0.72 + } else { + 0.90 + }; + if self.footstep_distance >= stride { + self.footstep_distance %= stride; + let clip = crate::audio::footstep_id( + self.footstep_index, + self.props_loader.player_inside_enterable(), + ); + self.sfx.play_at(clip, listener, SpatialOpts::default()); + self.footstep_index = self.footstep_index.wrapping_add(1); + } + + let in_combat = self + .store + .actors + .get(&self.store.player_actor_id) + .and_then(|actor| actor.in_combat) + .unwrap_or(false); + if in_combat && !self.music_was_in_combat { + self.music_combat_index = self.music_combat_index.wrapping_add(1); + } + self.music_was_in_combat = in_combat; + if self.area_id == "open-desert-overworld" { + let minute = self.environs.minute_of_day(); + let is_day = (360.0..1080.0).contains(&minute); + let desired_music = + crate::audio::open_desert_music_id(is_day, in_combat, self.music_combat_index); + if self.music_audio != Some(desired_music) { + self.sfx.stop_loop(MUSIC_LOOP_KEY); + self.sfx.play_loop(desired_music, MUSIC_LOOP_KEY, None, 1.0); + self.music_audio = Some(desired_music); + } + if !self.settlement_audio { + self.sfx.play_loop( + crate::audio::SETTLEMENT_LOOP, + SETTLEMENT_LOOP_KEY, + None, + 0.14, + ); + self.settlement_audio = true; + } + } else { + if self.music_audio.take().is_some() { + self.sfx.stop_loop(MUSIC_LOOP_KEY); + } + if self.settlement_audio { + self.sfx.stop_loop(SETTLEMENT_LOOP_KEY); + self.settlement_audio = false; + } + } + let desired = + crate::audio::weather_loop_id(active_weather.kind, active_weather.strength); + if desired != self.weather_audio { + self.sfx.stop_loop(WEATHER_LOOP_KEY); + if let Some(clip) = desired { + self.sfx.play_loop(clip, WEATHER_LOOP_KEY, None, 1.0); + } + self.weather_audio = desired; + } + self.ambience_timer -= dt.max(0.0); + if self.ambience_timer <= 0.0 { + let biome = biome_for_area(&self.slice, &self.area_id); + let minute = self.environs.minute_of_day(); + let is_day = (360.0..1080.0).contains(&minute); + let clip = crate::audio::ambience_one_shot(biome, is_day, self.ambience_roll); + let offset = (self.ambience_roll as f32 * 2.399_963_1).sin_cos(); + self.sfx.play_at( + clip, + Point { + x: p.x + offset.0 * 14.0, + y: p.z + offset.1 * 14.0, + }, + SpatialOpts::default(), + ); + self.ambience_roll = self.ambience_roll.wrapping_add(1); + self.ambience_timer = 12.0 + (self.ambience_roll % 9) as f32; + } + } + let collision_changed = self.collision_debug.sync_dynamic( + &mut self.world, + &self.area_id, + &self.terrain, + &self.store.prop_states, + self.store.building.as_ref(), + ); + if collision_changed { + self.movement_collision + .rebuild(&self.slice, &self.area_id, &self.collision_debug); + } + if self.collision_debug.enabled() { + if let Some(pawn) = self.pawns.get(&self.player_id) { + self.collision_debug.update_player( + &mut self.world, + pawn.render_pos, + pawn.predictor.authoritative(), + pawn.predictor.render_pos(), + pawn.ground_y, + ); + } + } + + // 5) Render scene → screen (+ minimap composite). + + self.renderer + .render(gpu, &mut self.world, w, h) + .expect("render failed"); + + // 6) Weather (ambient dust) → the FX pool, then integrate + draw all + // billboards over the scene in the follow-camera frame. + self.weather + .emit_into(self.combat_fx.pool_mut(), [p.x, 0.0, p.z], 40.0); + self.combat_fx.update(dt); + self.decay_muzzle_lights(dt); + let camera = self + .world + .get_component::(self.follow) + .copied() + .expect("follow camera exists"); + let fwd = camera.look_at.sub(camera.eye).normalize(); + let right = fwd.cross(Vec3::Y).normalize(); + let up = right.cross(fwd); + let Projection::Ortho { + half_height, + near, + far, + } = camera.projection + else { + unreachable!("connected camera remains orthographic") + }; + let aspect = w as f32 / h as f32; + let vp_mat = Mat4::ortho( + -half_height * aspect, + half_height * aspect, + -half_height, + half_height, + near, + far, + ) + .mul(Mat4::look_at(camera.eye, camera.look_at, camera.up)); + let vp = vp_mat.to_cols_array(); + // Retained for pointer picking and pointer-shape resolution, which run + // outside the render borrow and must resolve against the image the + // player actually saw. + self.pointer_vp = Some(vp_mat); + let (r, u) = ([right.x, right.y, right.z], [up.x, up.y, up.z]); + self.fx_buf.clear(); + let qa = self + .combat_fx + .pool() + .additive + .fill_billboards(r, u, &mut self.fx_buf); + self.renderer + .render_particles(gpu, &self.fx_buf, qa, &vp, true, w, h); + self.fx_buf.clear(); + let mut qn = self + .combat_fx + .pool() + .normal + .fill_billboards(r, u, &mut self.fx_buf); + qn += self + .combat_fx + .pool() + .residue + .fill_billboards(r, u, &mut self.fx_buf); + self.renderer + .render_particles(gpu, &self.fx_buf, qn, &vp, false, w, h); + + // 7) HUD chrome + interactive windows (mouse-routed; action bar toggles + // windows, exactly as `--demo ui`). + let (mx, my) = successor_platform::mouse_position(); + let down = successor_platform::mouse_button_down(0); + self.ui.set_input(mx, my, down); + self.ui.begin(w, h); + self.overlays.update(dt * 1_000.0); + let palette = hud::palette(self.theme_index); + // One theme for the whole UI: window surfaces read their ink from the + // active palette instead of carrying their own literals, so a theme + // change reaches every frame and not just the HUD. + hud::set_active_palette(palette); + if self.collision_debug.enabled() { + if let Some(movement) = self.movement_diagnostics() { + draw_movement_debug(&mut self.ui, movement); + } + } + hud::set_fill_opacity(self.hud_opacity); + let interaction = Self::nearest_interaction_prop( + &self.slice, + &self.store, + &self.player_id, + &self.area_id, + ) + .map(|prop| (prop.label, prop.kind == "door", prop.x, prop.y)); + let store = &self.store; + let terrain = &self.terrain; + let area_id = self.area_id.as_str(); + let player_position = store + .actors + .get(&self.player_id) + .map(|actor| (actor.x, actor.y)); + let anchor = |actor_id: &str| { + let actor = store.actors.get(actor_id)?; + if actor.area_id != area_id { + return None; + } + let world_x = (actor.x + 0.5) * WORLD_UNITS_PER_CELL; + let world_z = (actor.y + 0.5) * WORLD_UNITS_PER_CELL; + let world = vec3( + world_x, + terrain.height_at(world_x, world_z) + ADULT_PAWN_HEIGHT_METERS + 0.35, + world_z, + ); + let ndc = vp_mat.project_point(world); + (ndc.x >= -1.05 + && ndc.x <= 1.05 + && ndc.y >= -1.05 + && ndc.y <= 1.05 + && ndc.z >= -1.0 + && ndc.z <= 1.0) + .then_some(( + (ndc.x * 0.5 + 0.5) * w as f32, + (0.5 - ndc.y * 0.5) * h as f32, + )) + }; + + // Bubbles are range-bound and render first; plates sit in front exactly + // as the original text manager's depth bias intended. + self.overlays + .draw(&mut self.ui, &palette, w as f32, h as f32, |actor_id| { + let actor = store.actors.get(actor_id)?; + let player = player_position?; + let distance = ((actor.x - player.0).powi(2) + (actor.y - player.1).powi(2)).sqrt(); + (distance <= 24.0).then(|| anchor(actor_id)).flatten() + }); + + // Read once: the loop below holds `self.ui` mutably, and standing does + // not change between nameplates in a frame. + let viewer_org = store + .actors + .get(&store.player_actor_id) + .or_else(|| store.actors.get(&self.player_id)) + .and_then(|actor| actor.player_organization_id.as_deref()) + .filter(|org| !org.is_empty()); + for (actor_id, actor) in store.actors.iter() { + if actor_id == &self.player_id { + continue; + } + let Some((screen_x, screen_y)) = anchor(actor_id) else { + continue; + }; + let distance = player_position + .map(|player| ((actor.x - player.0).powi(2) + (actor.y - player.1).powi(2)).sqrt()) + .unwrap_or(0.0); + if distance > 28.0 { + continue; + } + let opacity = if distance > 18.0 { + (1.0 - (distance - 18.0) / 10.0).clamp(0.0, 1.0) + } else { + 1.0 + }; + let life_tag = match actor.life_state.as_str() { + "downed" => Some("DOWN"), + "dead" | "respawning" => Some("DEAD"), + _ => None, + }; + let pawn = self.pawns.get(actor_id); + let name = pawn.map(|pawn| pawn.name.as_str()).unwrap_or(actor_id); + let descriptor = pawn.and_then(|pawn| pawn.descriptor.as_deref()); + hud::overlays::draw_nameplate( + &mut self.ui, + &palette, + name, + descriptor, + hud::relation_for(actor, &self.player_id, viewer_org), + life_tag, + self.selected_actor_id.as_deref() == Some(actor_id.as_str()), + opacity, + screen_x, + screen_y - hud::overlays::NAMEPLATE_SCREEN_LIFT_PX, + ); + } + + if let Some((label, door, cell_x, cell_y)) = interaction { + let world_x = (cell_x + 0.5) * WORLD_UNITS_PER_CELL; + let world_z = (cell_y + 0.5) * WORLD_UNITS_PER_CELL; + let world = vec3(world_x, terrain.height_at(world_x, world_z) + 1.45, world_z); + let ndc = vp_mat.project_point(world); + if ndc.x >= -1.0 && ndc.x <= 1.0 && ndc.y >= -1.0 && ndc.y <= 1.0 { + hud::overlays::draw_world_label( + &mut self.ui, + &palette, + &label, + Some(if door { "[F] OPEN" } else { "[F] USE" }), + (ndc.x * 0.5 + 0.5) * w as f32, + (0.5 - ndc.y * 0.5) * h as f32, + ); + } + } + let tuning_open = self.graphics_tuner.is_open(); + let context_open = self.context_menu.is_some(); + self.ui.set_input_enabled(!tuning_open && !context_open); + let right_down = successor_platform::mouse_button_down(1); + let right_pressed = right_down && !self.hud_right_was_down; + self.hud_right_was_down = right_down; + // `handle_pointer` owns the right-click lock transition so it can + // suppress gameplay/context routing. Consume its flag here only to + // gate same-frame HUD controls; never toggle the manager twice. + let hud_lock_changed = core::mem::take(&mut self.hud_layout_input_consumed); + let now_ms = successor_platform::now_ms().max(0.0) as u64; + if !tuning_open && !context_open { + self.wm.update_at(&self.ui, w, h, now_ms); + } + let manager_captured = self.wm.pointer_captured(); + if manager_captured { + // Focus order can change on a press before a drag completes, and + // therefore belongs to the same durable workspace record. + self.window_layout_dirty = true; + } + let captured = tuning_open || context_open || manager_captured || hud_lock_changed; + // No clear here: `handle_key`'s toolbar presses push into + // `hud_actions` from the host loop BEFORE this frame runs, and the + // drain below leaves the vec empty for the next frame. An early clear + // silently discarded every keyboard toolbar activation while click + // activations (pushed during `build_hud`) survived. + let mut hud_frame = hud::HudFrame { + state: &self.hud_state, + toolbar: &mut self.toolbar, + chat: Some((chat_client, chat_input)), + palette: hud::palette(self.theme_index), + now_ms, + captured, + right_pressed, + }; + // The chat console is a managed pane, so it draws inside `build_hud` + // with the rest of the HUD; only its input gate stays here. + self.ui.set_input_enabled(!captured); + hud::build_hud( + &mut self.ui, + &self.icons, + &mut hud_frame, + &self.wm, + w, + h, + &mut self.hud_actions, + ); + self.ui.set_input_enabled(!tuning_open && !context_open); + let mut hud_actions = core::mem::take(&mut self.hud_actions); + for action in hud_actions.drain(..) { + match action { + hud::HudAction::ToggleWindow(id) => { + self.wm.toggle(id); + self.window_layout_dirty = true; + } + hud::HudAction::OpenWindow(id) => { + self.wm.open(id); + self.window_layout_dirty = true; + } + hud::HudAction::CycleTheme => { + self.theme_index = (self.theme_index + 1) % hud::THEME_COUNT; + } + hud::HudAction::RunVerb(verb) => { + let gameplay = match verb { + "attack" => self.shot_action("basic_shot"), + "aimed" => self.shot_action("aimed_shot"), + "kneel" | "stand" => Some(actions::GameplayAction::SetPosture { + posture: verb.into(), + }), + "reload" => Some(actions::GameplayAction::Reload { + weapon_id: None, + ammo_type: None, + }), + "peace" => Some(actions::GameplayAction::Peace), + "clone" => { + Some(actions::GameplayAction::CloneRespawn { facility_id: None }) + } + _ => None, + }; + if let Some(gameplay) = gameplay { + self.dispatch_gameplay_action(gameplay); + } + } + hud::HudAction::UseToolbarItem(item_id) => { + self.dispatch_gameplay_action(actions::GameplayAction::UseConsumable { + item_id, + }); + } + hud::HudAction::ToggleSprint => self.sprint_toggle = !self.sprint_toggle, + hud::HudAction::GroupAccept => self.dispatch_window_action( + crate::windows::WindowAction::Command(ClientCommand::GroupAccept {}), + ), + hud::HudAction::GroupDecline => self.dispatch_window_action( + crate::windows::WindowAction::Command(ClientCommand::GroupDecline {}), + ), + hud::HudAction::CloneRespawn => { + self.dispatch_gameplay_action(actions::GameplayAction::CloneRespawn { + facility_id: None, + }); + } + action @ (hud::HudAction::RadarSelect(_) | hud::HudAction::RadarMove { .. }) => { + self.dispatch_radar_hud_action(action); + } + hud::HudAction::QueueCancel(entry_id) => { + self.dispatch_gameplay_action(actions::GameplayAction::CancelAbilityQueue { + queue_entry_id: Some(entry_id), + }); + } + hud::HudAction::ToolbarChanged => self.preferences_dirty = true, + } + } + self.hud_actions = hud_actions; + // Windows draw at the window opacity; the HUD already drew at its own. + hud::set_fill_opacity(self.window_opacity); + let mut style = hud::window_style(&palette); + style.fade_fills(self.window_opacity); + self.wm.fill_z_order(&mut self.window_order); + for order_index in 0..self.window_order.len() { + let index = self.window_order[order_index]; + let pane_id = self.wm.window_id(index); + let hud_pane = hud::is_hud_surface(pane_id); + // The dark backdrop a composited model needs belongs to the viewer + // cell, not the whole frame: `chrome::viewer_seat` paints it. A + // pane-wide override turned an empty examine or converse window + // into a black rectangle that no theme could reach. + let window_style = style; + let rect = self.wm.draw_chrome(&mut self.ui, index, window_style); + // HUD panes drew their own content and layout affordance in + // `build_hud`; the chromeless frame adds nothing here. + if !hud_pane { + self.window_id_scratch.clear(); + self.window_id_scratch.push_str(pane_id); + let mut actions = Vec::new(); + crate::windows::content( + &mut self.ui, + &self.window_id_scratch, + rect, + &self.win_model, + &self.icons, + &mut actions, + ); + for action in actions { + self.dispatch_window_action(action); + } + } + // Mark where this window's 2D content ends. Its 3D surfaces are + // flushed in at this point so later windows paint over them. + self.composite_marks + .push((self.ui.quads, order_index as i16)); + } + if let Some(menu) = self.context_menu { + self.ui.set_input_enabled(!tuning_open); + let mut close_menu = false; + match menu { + ContextMenu::Actor { + x: menu_x, + y: menu_y, + } => { + let selected_actor = self.selected_actor_id.as_deref(); + let can_converse = selected_actor.is_some_and(|selected| { + self.win_model + .converse + .npc + .as_ref() + .is_some_and(|npc| npc.actor_id == selected) + }); + let can_trade = selected_actor.is_some_and(|selected| { + self.win_model + .trade + .propose_target + .as_ref() + .is_some_and(|(actor_id, _)| actor_id == selected) + }); + let rows = 3 + usize::from(can_converse) + usize::from(can_trade); + let menu_w = 138.0; + let row_h = 24.0; + let x = menu_x.clamp(4.0, (w as f32 - menu_w - 4.0).max(4.0)); + let y = menu_y.clamp(4.0, (h as f32 - rows as f32 * row_h - 4.0).max(4.0)); + let style = successor_engine_render::ui::ButtonStyle { + fill: [6, 13, 14, 242], + hover: [14, 39, 43, 250], + active: [20, 60, 66, 255], + edge: [38, 82, 89, 255], + text: [220, 234, 235, 255], + }; + let mut action_y = y; + if self.ui.button(x, action_y, menu_w, row_h, "Examine", style) { + self.open_workspace_window("examine"); + close_menu = true; + } + action_y += row_h; + if can_converse { + if self + .ui + .button(x, action_y, menu_w, row_h, "Converse", style) + { + self.open_workspace_window("converse"); + close_menu = true; + } + action_y += row_h; + } + if can_trade { + if self.ui.button(x, action_y, menu_w, row_h, "Trade", style) { + self.open_workspace_window("trade"); + close_menu = true; + } + action_y += row_h; + } + if self.ui.button(x, action_y, menu_w, row_h, "Group", style) { + self.open_workspace_window("group"); + close_menu = true; + } + action_y += row_h; + if self.ui.button(x, action_y, menu_w, row_h, "Attack", style) { + if let Some(target_actor_id) = self.selected_actor_id.clone() { + self.dispatch_gameplay_action(actions::GameplayAction::Attack { + action_id: "basic_shot".into(), + target_actor_id, + }); + } + close_menu = true; + } + let (mx, my) = self.ui.mouse(); + if self.ui.interact(0.0, 0.0, w as f32, h as f32).pressed + && !UiBuilder::hit(x, y, menu_w, rows as f32 * row_h, mx, my) + { + close_menu = true; + } + } + ContextMenu::InventoryRadial { x, y } => { + let radial = self + .selected_inventory + .as_ref() + .and_then(|(container, stack_id)| { + self.win_model.inventory.row(container, stack_id) + }) + .map(|row| { + draw_inventory_radial( + &mut self.ui, + row, + (x, y), + self.win_model.splice.session.is_some(), + (w as f32, h as f32), + ) + }); + match radial { + Some((close, action)) => { + if let Some(action) = action { + self.dispatch_window_action(action); + } + close_menu = close; + } + None => { + self.selected_inventory = None; + crate::windows::inventory::clear_selection(); + close_menu = true; + } + } + } + } + if close_menu { + self.context_menu = None; + } + } + self.ui.set_input_enabled(true); + self.graphics_tuner + .draw(&mut self.ui, &mut self.renderer, gpu, w, h); + + // The pointer is the last thing drawn, after the composite bands + // below, so no 3D viewer can paint over it. The original hides the + // desktop cursor and clips the hardware one to the client rect + // (`Graphics::constrainMouseCursor`); GLFW has no portable clip, so + // the equivalent is to own the pointer while it is inside the + // framebuffer and hand it back the moment it leaves. + let inside = mx >= 0.0 && my >= 0.0 && mx < w as f32 && my < h as f32; + if inside != self.owns_cursor { + successor_platform::set_cursor_visible(!inside); + self.owns_cursor = inside; + } + if inside { + let kind = self.pointer_cursor(mx, my); + // The pointer is chrome: it takes the frame's tones so a theme + // change reaches it too. Opacity deliberately does not — a faded + // cursor is a lost cursor. + let chrome = hud::window_style(&palette); + let style = CursorStyle { + fill: chrome.edge, + edge: chrome.caption_text, + accent: palette.ink, + danger: palette.danger, + ..CursorStyle::default() + }; + cursor::draw( + &mut self.ui, + kind, + mx.clamp(0.0, w as f32 - 1.0), + my.clamp(0.0, h as f32 - 1.0), + style, + now_ms, + ); + } + // Walk the UI stream window by window: flush the 2D quads drawn so far, + // then composite the 3D surfaces belonging to that window. Panels drawn + // later land on top, so several live viewers coexist and none punches + // through the windows above it. This is the original's per-widget + // `flushRenderQueue()` then `renderScene()`, one band at a time. + let mut drawn = 0u32; + for index in 0..self.composite_marks.len() { + let (mark, rank) = self.composite_marks[index]; + if mark > drawn { + let floats = QUAD_FLOATS * drawn as usize..QUAD_FLOATS * mark as usize; + self.renderer + .render_ui(gpu, &self.ui.buf[floats], mark - drawn, w, h); + drawn = mark; + } + let base = rank * DOLL_BAND; + self.renderer.render_composites_overlay_band( + gpu, + &mut self.world, + w, + h, + base..=base + DOLL_BAND - 1, + ); + } + if self.ui.quads > drawn { + let floats = QUAD_FLOATS * drawn as usize..QUAD_FLOATS * self.ui.quads as usize; + self.renderer + .render_ui(gpu, &self.ui.buf[floats], self.ui.quads - drawn, w, h); + } + self.composite_marks.clear(); + } +} + +fn apply_weapon_bore_correction(socket: Mat4, held: HeldWeaponRig) -> Mat4 { + let grip = socket.transform_point(held.grip); + let bore = socket.transform_point(held.muzzle).sub(grip).normalize(); + let horizontal_sq = bore.x * bore.x + bore.z * bore.z; + if horizontal_sq < 1.0e-8 || bore.y.abs() > 0.995 { + return socket; + } + + let horizontal_len = horizontal_sq.sqrt(); + let level_bore = vec3(bore.x / horizontal_len, 0.0, bore.z / horizontal_len); + let pitch_axis = Vec3::Y.cross(level_bore).normalize(); + let pitch = bore.y.clamp(-1.0, 1.0).asin().clamp(-0.6, 0.6); + + let bore_yaw = bore.x.atan2(bore.z); + let mut yaw = held.resting_yaw_rad - bore_yaw; + while yaw > core::f32::consts::PI { + yaw -= core::f32::consts::PI * 2.0; + } + while yaw < -core::f32::consts::PI { + yaw += core::f32::consts::PI * 2.0; + } + yaw = yaw.clamp(-0.45, 0.45); + if pitch.abs() < 1.0e-5 && yaw.abs() < 1.0e-5 { + return socket; + } + + let correction = Mat4::from_trs(Vec3::ZERO, Quat::from_axis_angle(Vec3::Y, yaw), Vec3::ONE) + .mul(Mat4::from_trs( + Vec3::ZERO, + Quat::from_axis_angle(pitch_axis, pitch), + Vec3::ONE, + )); + Mat4::from_trs(grip, Quat::IDENTITY, Vec3::ONE) + .mul(correction) + .mul(Mat4::from_trs(grip.scale(-1.0), Quat::IDENTITY, Vec3::ONE)) + .mul(socket) +} + +fn interpolate_mount(from: Mat4, to: Mat4, t: f32) -> Mat4 { + let t = t.clamp(0.0, 1.0); + let (from_pos, from_rot, from_scale) = from.to_trs(); + let (to_pos, to_rot, to_scale) = to.to_trs(); + Mat4::from_trs( + vec3( + from_pos.x + (to_pos.x - from_pos.x) * t, + from_pos.y + (to_pos.y - from_pos.y) * t, + from_pos.z + (to_pos.z - from_pos.z) * t, + ), + quat_slerp(from_rot, to_rot, t), + vec3( + from_scale.x + (to_scale.x - from_scale.x) * t, + from_scale.y + (to_scale.y - from_scale.y) * t, + from_scale.z + (to_scale.z - from_scale.z) * t, + ), + ) +} + +fn quat_slerp(from: Quat, mut to: Quat, t: f32) -> Quat { + let mut dot = from.x * to.x + from.y * to.y + from.z * to.z + from.w * to.w; + if dot < 0.0 { + dot = -dot; + to = Quat { + x: -to.x, + y: -to.y, + z: -to.z, + w: -to.w, + }; + } + if dot > 0.9995 { + return Quat { + x: from.x + (to.x - from.x) * t, + y: from.y + (to.y - from.y) * t, + z: from.z + (to.z - from.z) * t, + w: from.w + (to.w - from.w) * t, + } + .normalize(); + } + let theta = dot.clamp(-1.0, 1.0).acos(); + let sin_theta = theta.sin(); + let from_weight = ((1.0 - t) * theta).sin() / sin_theta; + let to_weight = (t * theta).sin() / sin_theta; + Quat { + x: from.x * from_weight + to.x * to_weight, + y: from.y * from_weight + to.y * to_weight, + z: from.z * from_weight + to.z * to_weight, + w: from.w * from_weight + to.w * to_weight, + } + .normalize() +} + +/// Faction id → a tint bias rgb (best-effort; unknown factions untinted). +fn faction_rgb(faction: &str) -> [f32; 3] { + match faction { + f if f.contains("red") || f.contains("raider") => [0.8, 0.25, 0.2], + f if f.contains("blue") || f.contains("law") => [0.3, 0.4, 0.8], + f if f.contains("green") => [0.3, 0.6, 0.3], + _ => [0.5, 0.5, 0.5], + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn converge_speed(hz: u32) -> f32 { + let dt = 1.0 / hz as f32; + let mut speed = 0.0; + for _ in 0..hz { + speed = filtered_gait_speed(speed, 4.0 * dt, dt); + } + speed + } + + #[test] + fn gait_speed_filter_is_frame_rate_independent_and_fail_closed() { + let at_30 = converge_speed(30); + assert!((at_30 - converge_speed(60)).abs() < 1.0e-4); + assert!((at_30 - converge_speed(120)).abs() < 1.0e-4); + assert_eq!(filtered_gait_speed(2.5, 1.0, 0.0), 2.5); + assert_eq!(filtered_gait_speed(2.5, 2.01, 1.0 / 60.0), 0.0); + } + + #[test] + fn ground_height_hysteresis_smooths_floor_transitions_and_snaps_teleports() { + assert_eq!(smooth_ground_height(1.0, 1.02, 1.0 / 60.0), 1.0); + let first = smooth_ground_height(1.0, 1.5, 1.0 / 60.0); + assert!((first - 1.1).abs() < 1.0e-6, "first={first}"); + let mut height = 1.0; + for _ in 0..120 { + height = smooth_ground_height(height, 1.5, 1.0 / 60.0); + } + assert!((height - 1.475).abs() < 1.0e-5, "height={height}"); + assert_eq!(smooth_ground_height(1.0, 4.0, 1.0 / 60.0), 4.0); + } + + #[test] + fn follow_camera_is_locked_north_up_sixty_degree_ortho() { + let ground = vec3(10.0, 2.0, 20.0); + let focus = follow_focus(ground); + let eye = follow_eye(ground); + assert!((focus.y - 2.9).abs() < 1.0e-6); + assert!((eye.sub(focus).length() - 96.0).abs() < 1.0e-4); + assert!((eye.x - focus.x).abs() < 1.0e-6); + assert!(eye.y > focus.y && eye.z > focus.z); + } + + #[test] + fn weapon_mount_interpolation_reaches_held_and_stowed_endpoints() { + let held = Mat4::from_trs(vec3(0.0, 0.0, 0.0), Quat::IDENTITY, vec3(1.0, 1.0, 1.0)); + let stowed = Mat4::from_trs( + vec3(0.16, 0.0, -0.14), + Quat::from_axis_angle(Vec3::Y, core::f32::consts::FRAC_PI_2), + vec3(1.0, 1.0, 1.0), + ); + assert_eq!(interpolate_mount(held, stowed, 0.0), held); + let (end, _, _) = interpolate_mount(held, stowed, 1.0).to_trs(); + assert!((end.x - 0.16).abs() < 1.0e-5); + assert!((end.z + 0.14).abs() < 1.0e-5); + let (mid, mid_rotation, _) = interpolate_mount(held, stowed, 0.5).to_trs(); + assert!((mid.x - 0.08).abs() < 1.0e-5); + let norm = mid_rotation.x * mid_rotation.x + + mid_rotation.y * mid_rotation.y + + mid_rotation.z * mid_rotation.z + + mid_rotation.w * mid_rotation.w; + assert!((norm - 1.0).abs() < 1.0e-5); + } + + #[test] + fn bore_correction_levels_pitch_and_rotates_yaw_around_the_grip() { + let socket = Mat4::from_trs( + vec3(0.4, 1.2, -0.3), + Quat::from_axis_angle(vec3(1.0, 0.0, 0.0), -0.24), + Vec3::ONE, + ); + let held = HeldWeaponRig { + mount: Mat4::IDENTITY, + grip: vec3(0.02, 0.01, 0.1), + foregrip: vec3(0.0, 0.0, 0.4), + muzzle: vec3(0.02, 0.01, 0.9), + foregrip_contact: Vec3::ZERO, + resting_yaw_rad: 0.12, + support_arm: None, + + support_hand: true, + }; + let grip_before = socket.transform_point(held.grip); + let corrected = apply_weapon_bore_correction(socket, held); + let grip_after = corrected.transform_point(held.grip); + assert!(grip_after.sub(grip_before).length() < 1.0e-5); + let bore = corrected.transform_point(held.muzzle).sub(grip_after); + assert!(bore.normalize().y.abs() < 1.0e-5); + assert!((bore.x.atan2(bore.z) - held.resting_yaw_rad).abs() < 1.0e-5); + } + + #[test] + fn presentation_identity_detects_live_loadout_changes() { + let mut actor: GameActorSnapshot = serde_json::from_str( + r##"{ + "id":"dev-1", + "appearance":{"skin":"#cc9978","hair":"hair_short","hair_mat":"hair_raven"}, + "factionId":"desert-law", + "sprite":"wanderer-female", + "role":"player", + "worn":[{"item":"under_tank","colors":["#89cff0"]}], + "weapon":{"weaponId":"wpn-smg","weaponItemId":3111} + }"##, + ) + .expect("actor snapshot"); + let presentation = PawnPresentation { + skin: Some("#cc9978".into()), + faction: Some("desert-law".into()), + sprite: Some("wanderer-female".into()), + role: Some("player".into()), + hair: Some("hair_short".into()), + hair_material: Some("hair_raven".into()), + worn: vec![WornPresentation { + item_id: "under_tank".into(), + colors: vec!["#89cff0".into()], + }], + weapon: Some("wpn-smg".into()), + weapon_item_id: Some(3111), + }; + assert!(presentation.matches(&actor)); + + actor.weapon.as_mut().unwrap().weapon_item_id = Some(3112); + assert!( + !presentation.matches(&actor), + "weapon model change respawns" + ); + actor.weapon.as_mut().unwrap().weapon_item_id = Some(3111); + actor.worn[0].item_id = Some("under_bodysuit".into()); + assert!(!presentation.matches(&actor), "wardrobe change respawns"); + actor.worn[0].item_id = Some("under_tank".into()); + actor.worn[0].colors[0] = "#303030".into(); + assert!( + !presentation.matches(&actor), + "wardrobe color change respawns" + ); + } + #[test] + fn window_layout_round_trips_known_finite_bounds_only() { + let mut manager = successor_engine_render::window::WindowManager::new(); + manager.register( + "inventory", + "INVENTORY", + None, + [10.0, 20.0, 500.0, 400.0], + 320.0, + 240.0, + ); + restore_window_layout( + &mut manager, + Some(&serde_json::json!({ + "schema": WINDOW_LAYOUT_SCHEMA, + "viewport": [1280.0, 720.0], + "windows": [ + {"id": "inventory", "bounds": [33.0, 44.0, 660.0, 521.0]}, + {"id": "missing", "bounds": [1.0, 2.0, 3.0, 4.0]}, + {"id": "inventory", "bounds": ["bad", 2.0, 3.0, 4.0]} + ] + })), + ); + assert_eq!(manager.rect("inventory"), Some([33.0, 44.0, 660.0, 521.0])); + + let saved = save_window_layout(&manager, (1280.0, 720.0)); + assert_eq!( + saved.get("schema").and_then(serde_json::Value::as_str), + Some(WINDOW_LAYOUT_SCHEMA) + ); + assert_eq!( + saved["windows"][0]["bounds"], + serde_json::json!([33.0, 44.0, 660.0, 521.0]) + ); + } + + #[test] + fn persisted_hud_workspace_keeps_bounds_visibility_order_and_lock() { + let icons = crate::hud::Icons::load(); + let viewport = (1280.0, 1024.0); + let mut source = successor_engine_render::window::WindowManager::new(); + crate::hud::register_hud_surfaces_at(&mut source, &icons, viewport); + assert!(crate::hud::set_hud_surface_locked( + &mut source, + crate::hud::PLAYER_STATUS_ID, + false + )); + assert!(source.set_rect(crate::hud::PLAYER_STATUS_ID, [90.0, 70.0, 360.0, 220.0])); + source.close(crate::hud::TARGET_STATUS_ID); + source.open(crate::hud::COMMAND_BAR_ID); + let saved = save_window_layout(&source, viewport); + + let mut restored = successor_engine_render::window::WindowManager::new(); + crate::hud::register_hud_surfaces_at(&mut restored, &icons, viewport); + let missing = restore_window_layout(&mut restored, Some(&saved)); + + assert_eq!(missing, [false; crate::hud::HUD_SURFACE_COUNT]); + assert_eq!( + restored.rect(crate::hud::PLAYER_STATUS_ID), + Some([90.0, 70.0, 360.0, 220.0]) + ); + assert!(restored.is_interactive(crate::hud::PLAYER_STATUS_ID)); + assert!(!restored.is_open(crate::hud::TARGET_STATUS_ID)); + let order = restored.z_order(); + assert_eq!( + restored.window_id(*order.last().expect("command bar is open")), + crate::hud::COMMAND_BAR_ID + ); + } + + #[test] + fn hud_surfaces_re_anchor_when_the_framebuffer_changes() { + let icons = crate::hud::Icons::load(); + let small = (1280.0, 720.0); + let large = (1728.0, 1052.0); + + let mut source = successor_engine_render::window::WindowManager::new(); + crate::hud::register_hud_surfaces_at(&mut source, &icons, small); + let saved = save_window_layout(&source, small); + + // The stamp decides, not the restore. A scene is constructed before its + // first framebuffer exists, so matching a document at restore time + // matches it against a placeholder and strands the band at 720p. + assert_eq!(layout_viewport(Some(&saved)), Some(small)); + assert!(!hud_anchor_is_stale(Some(small), small)); + assert!(hud_anchor_is_stale(Some(small), large)); + assert!( + hud_anchor_is_stale(None, small), + "a document with no viewport stamp is foreign and re-anchors" + ); + + // Restoring 720p rects onto a larger viewport really does strand the + // bottom band mid-screen: that is the regression this guards. + let mut grown = successor_engine_render::window::WindowManager::new(); + crate::hud::register_hud_surfaces_at(&mut grown, &icons, large); + restore_window_layout(&mut grown, Some(&saved)); + let stranded = grown.rect(crate::hud::CHAT_CONSOLE_ID).expect("chat rect"); + assert!( + stranded[1] + stranded[3] < large.1 - 64.0, + "the restored 720p rect sits mid-screen, got {stranded:?}" + ); + + // Reconciling against the live framebuffer pulls it back to the edge. + crate::hud::apply_missing_hud_surface_defaults( + &mut grown, + large, + &[true; crate::hud::HUD_SURFACE_COUNT], + ); + let chat = grown.rect(crate::hud::CHAT_CONSOLE_ID).expect("chat rect"); + assert!( + chat[1] + chat[3] > large.1 - 64.0, + "chat console stays anchored to the bottom edge, got {chat:?}" + ); + } + + #[test] + fn one_pawn_is_visible_in_the_world_and_every_viewer_showing_it() { + let player = "player-1"; + let npc = "npc-7"; + + // Inventory + character sheet both open on the player, examine on an + // NPC. The player must reach the world and BOTH of its viewers. + let mut subjects: [Option<&str>; DOLL_WINDOWS.len()] = [None; DOLL_WINDOWS.len()]; + subjects[0] = Some(player); + subjects[1] = Some(player); + subjects[2] = Some(npc); + + let player_mask = doll_viewport_mask(&subjects, player); + assert!(player_mask & 1 != 0, "the world view is never dropped"); + assert!(player_mask & (1 << doll_viewport(0)) != 0, "inventory doll"); + assert!(player_mask & (1 << doll_viewport(1)) != 0, "character doll"); + assert_eq!( + player_mask & (1 << doll_viewport(2)), + 0, + "the player does not render into someone else's examine viewer" + ); + + let npc_mask = doll_viewport_mask(&subjects, npc); + assert_eq!(npc_mask, 1 | (1 << doll_viewport(2))); + + // A pawn nobody is viewing stays world-only. + assert_eq!(doll_viewport_mask(&subjects, "bystander"), 1); + } + + #[test] + fn viewer_bands_never_collide_and_follow_window_order() { + // Item lanes ride inside their window's band, so the last lane of one + // window must still sort below the next window's doll. + let lanes = crate::item_preview::INVENTORY_LANES as i16; + assert!( + lanes + 1 < DOLL_BAND, + "a window band holds its doll plus every item lane" + ); + for rank in 0..8i16 { + let base = rank * DOLL_BAND; + let last_lane = base + 1 + lanes; + assert!( + last_lane < (rank + 1) * DOLL_BAND, + "band {rank} overflows into the window above it" + ); + } + } + + #[test] + fn every_viewer_and_item_lane_fits_the_viewport_mask() { + // 32 mask bits total: world, one per viewer, then the icon lanes. + let lanes = crate::item_preview::INVENTORY_LANES + 1; + let highest = 5 + lanes - 1; + assert_eq!(doll_viewport(0), 1); + assert_eq!(doll_viewport(DOLL_WINDOWS.len() - 1), 4); + assert!( + highest < 32, + "viewport ids must stay inside the u32 mask, got {highest}" + ); + } + + #[test] + fn permanent_key_registry_uses_every_advertised_badge() { + let expected = [ + (Key::C, "character"), + (Key::I, "inventory"), + (Key::P, "datapad"), + (Key::K, "skills"), + (Key::B, "actions"), + (Key::M, "macros"), + (Key::O, "options"), + (Key::G, "pa"), + ]; + for (key, window) in expected { + assert_eq!(ConnectedScene::permanent_window_for_key(key), Some(window)); + assert!(CONNECTED_INPUT_KEYS.contains(&key)); + } + } + + #[test] + fn shift_c_is_reserved_for_collision_debug_while_plain_c_remains_character() { + assert!(ConnectedScene::collision_debug_chord(Key::C, true)); + assert!(!ConnectedScene::collision_debug_chord(Key::C, false)); + assert!(!ConnectedScene::collision_debug_chord(Key::I, true)); + assert_eq!( + ConnectedScene::permanent_window_for_key(Key::C), + Some("character") + ); + } + + #[test] + fn context_routes_require_authoritative_terminal_or_corpse_state() { + assert_eq!( + ConnectedScene::interaction_window_for_kind("trade-terminal"), + Some("trade") + ); + assert_eq!( + ConnectedScene::interaction_window_for_kind("bank-terminal"), + Some("bank") + ); + assert_eq!( + ConnectedScene::interaction_window_for_kind("decorative-prop"), + None + ); + let corpses = vec![ + serde_json::json!({"id":"far","areaId":"a","x":13.0,"y":10.0}), + serde_json::json!({"id":"near","areaId":"a","cellX":11,"cellY":10}), + serde_json::json!({"id":"other-area","areaId":"b","x":10.0,"y":10.0}), + ]; + assert_eq!( + nearest_loot_corpse_id(&corpses, "a", (10.0, 10.0)), + Some("near") + ); + } + + #[test] + fn escape_dismisses_context_then_modal_then_focused_frame() { + let mut manager = successor_engine_render::window::WindowManager::new(); + manager.register( + "inventory", + "INVENTORY", + None, + [10.0, 20.0, 500.0, 400.0], + 320.0, + 240.0, + ); + manager.open("inventory"); + let mut menu = Some(ContextMenu::Actor { x: 10.0, y: 10.0 }); + let mut tuner = crate::graphics_tuning::GraphicsTuner::new(); + tuner.handle_toggle(true); + + assert!(dismiss_context_or_focused( + &mut menu, + &mut tuner, + &mut manager + )); + assert!(menu.is_none()); + assert!(tuner.is_open()); + assert!(manager.is_open("inventory")); + + assert!(dismiss_context_or_focused( + &mut menu, + &mut tuner, + &mut manager + )); + assert!(!tuner.is_open()); + assert!(manager.is_open("inventory")); + + assert!(dismiss_context_or_focused( + &mut menu, + &mut tuner, + &mut manager + )); + assert!(!manager.is_open("inventory")); + } + + #[test] + fn equipped_inventory_radial_routes_to_typed_unequip() { + let row = crate::windows::InventoryRow { + container: "player:pack".into(), + stack_id: "vest-1".into(), + item: "FIELD JACKET".into(), + item_id: 9001, + variant_id: 4, + available: 2, + equipped: true, + ..Default::default() + }; + let mut actions = [InventoryRadialAction::Examine; 6]; + let count = inventory_radial_actions(&row, false, &mut actions); + assert_eq!( + &actions[..count], + &[ + InventoryRadialAction::Unequip, + InventoryRadialAction::Examine, + InventoryRadialAction::Drop, + InventoryRadialAction::Split, + ] + ); + assert!(matches!( + inventory_radial_window_action(&row, InventoryRadialAction::Unequip), + Some(crate::windows::WindowAction::Command( + ClientCommand::SetEquippedClothing { + equipped: false, + .. + } + )) + )); + assert_eq!( + inventory_radial_window_action(&row, InventoryRadialAction::Splice), + Some(crate::windows::WindowAction::OpenWindow("splice".into())) + ); + } + + #[test] + fn only_identified_local_chat_becomes_spatial_prose() { + let local = ChatMessage { + channel: ChatChannel::Local, + sender_id: "actor-7".into(), + sender: "Rook".into(), + text: "Meet me by the terminal.".into(), + whisper_to: None, + }; + assert_eq!( + spatial_chat_payload(&local), + Some(("actor-7", "Meet me by the terminal.")) + ); + let mut global = local.clone(); + global.channel = ChatChannel::Global; + assert!(spatial_chat_payload(&global).is_none()); + let mut anonymous = local; + anonymous.sender_id.clear(); + assert!(spatial_chat_payload(&anonymous).is_none()); + } +} diff --git a/client-rust/source/app/src/game/interp.rs b/client-rust/source/app/src/game/interp.rs new file mode 100644 index 00000000..a787dcfc --- /dev/null +++ b/client-rust/source/app/src/game/interp.rs @@ -0,0 +1,166 @@ +//! Remote-actor position interpolation — port of `actorInterpolationSamples` / +//! `shouldResetRemoteActorInterpolation` from `gameAuthoritySystem.ts`. Each +//! remote actor keeps a short ring of timestamped authority samples; the render +//! position is the buffered interpolation at `now - delay`, smoothing the +//! 10–20 Hz authority stream into per-frame motion. A large jump (teleport / +//! lifecycle change) resets the buffer so we snap instead of sliding across the +//! map. + +use std::collections::VecDeque; + +const SAMPLE_LIMIT: usize = 12; +const RESET_CELLS: f32 = 12.0; + +#[derive(Clone, Copy)] +struct Sample { + t: f32, + x: f32, + y: f32, +} + +#[derive(Default)] +pub struct ActorInterp { + samples: VecDeque, + last_seq: i64, + interval_ema: f32, +} + +impl ActorInterp { + pub fn new() -> Self { + Self::default() + } + + /// Record an authority sample at wall-time `t`. A position jump beyond + /// `RESET_CELLS`, or a `lifecycle_seq` change, clears the buffer (snap). + pub fn push(&mut self, t: f32, x: f32, y: f32, lifecycle_seq: i64) { + let reset = lifecycle_seq != self.last_seq + || self + .samples + .back() + .map(|s| ((x - s.x).powi(2) + (y - s.y).powi(2)).sqrt() > RESET_CELLS) + .unwrap_or(false); + if let Some(previous) = self.samples.back() { + let interval = t - previous.t; + if interval > 0.0 && interval <= 1.0 { + self.interval_ema = if self.interval_ema > 0.0 { + self.interval_ema * 0.8 + interval * 0.2 + } else { + interval + }; + } + } + self.last_seq = lifecycle_seq; + if reset { + self.samples.clear(); + } + self.samples.push_back(Sample { t, x, y }); + while self.samples.len() > SAMPLE_LIMIT { + self.samples.pop_front(); + } + } + + /// Interpolated render position at wall-time `now`. Delay follows observed + /// packet cadence so jitter remains bracketed without adding fixed latency. + pub fn sample(&self, now: f32) -> Option<(f32, f32)> { + let newest = *self.samples.back()?; + let front = *self.samples.front()?; + let delay = (self.interval_ema.max(1.0 / 30.0) * 1.5).clamp(0.05, 0.25); + let target = now - delay; + if target <= front.t { + return Some((front.x, front.y)); + } + if target >= newest.t { + return Some((newest.x, newest.y)); + } + for i in 0..self.samples.len() - 1 { + let a = self.samples[i]; + let b = self.samples[i + 1]; + if a.t <= target && target <= b.t { + return Some(lerp_pair(a, b, target)); + } + } + Some((newest.x, newest.y)) + } + + pub fn len(&self) -> usize { + self.samples.len() + } + + pub fn is_empty(&self) -> bool { + self.samples.is_empty() + } +} + +fn lerp_pair(a: Sample, b: Sample, t: f32) -> (f32, f32) { + let span = b.t - a.t; + let f = if span > 1e-6 { (t - a.t) / span } else { 0.0 }; + (a.x + (b.x - a.x) * f, a.y + (b.y - a.y) * f) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interpolates_between_samples_at_observed_cadence() { + let mut it = ActorInterp::new(); + it.push(0.0, 0.0, 0.0, 1); + it.push(0.1, 10.0, 0.0, 1); + // 100 ms cadence buffers 150 ms. At now=200 ms the target is 50 ms. + let (x, _) = it.sample(0.2).unwrap(); + assert!((x - 5.0).abs() < 1e-3, "got {x}"); + } + + #[test] + fn interpolation_delay_adapts_to_packet_cadence() { + let mut fast = ActorInterp::new(); + fast.push(0.0, 0.0, 0.0, 1); + fast.push(0.05, 1.0, 0.0, 1); + let mut slow = ActorInterp::new(); + slow.push(0.0, 0.0, 0.0, 1); + slow.push(0.2, 1.0, 0.0, 1); + + // At the same wall-clock offset after the newest packet, the fast + // stream has advanced farther because it needs less jitter buffering. + let fast_x = fast.sample(0.25).unwrap().0; + let slow_x = slow.sample(0.4).unwrap().0; + assert!(fast_x > slow_x, "fast={fast_x} slow={slow_x}"); + } + + #[test] + fn clamps_to_ends() { + let mut it = ActorInterp::new(); + it.push(0.0, 0.0, 0.0, 1); + it.push(1.0, 10.0, 0.0, 1); + assert_eq!(it.sample(0.0).unwrap().0, 0.0); // before first (delay) + assert_eq!(it.sample(5.0).unwrap().0, 10.0); // past newest + } + + #[test] + fn large_jump_resets_buffer() { + let mut it = ActorInterp::new(); + it.push(0.0, 0.0, 0.0, 1); + it.push(0.1, 1.0, 0.0, 1); + assert_eq!(it.len(), 2); + it.push(0.2, 100.0, 0.0, 1); // > 12 cells → reset + assert_eq!(it.len(), 1); + assert_eq!(it.sample(0.3).unwrap(), (100.0, 0.0)); + } + + #[test] + fn lifecycle_change_resets() { + let mut it = ActorInterp::new(); + it.push(0.0, 0.0, 0.0, 1); + it.push(0.1, 1.0, 0.0, 2); // seq changed → reset + assert_eq!(it.len(), 1); + } + + #[test] + fn ring_capacity_bounded() { + let mut it = ActorInterp::new(); + for i in 0..30 { + it.push(i as f32 * 0.1, i as f32 * 0.1, 0.0, 1); + } + assert!(it.len() <= SAMPLE_LIMIT); + } +} diff --git a/client-rust/source/app/src/game/macro_runtime.rs b/client-rust/source/app/src/game/macro_runtime.rs new file mode 100644 index 00000000..d364d7f0 --- /dev/null +++ b/client-rust/source/app/src/game/macro_runtime.rs @@ -0,0 +1,290 @@ +//! Bounded character-local macro parser/runtime. Statements resolve only to the +//! public gameplay action path; no statement can mutate projected state. + +use serde_json::{json, Value}; + +use super::actions::GameplayAction; + +pub const BODY_BYTES_MAX: usize = 8 * 1024; +pub const MACROS_MAX: usize = 64; +pub const RUN_SLOTS_MAX: usize = 4; +pub const STEPS_PER_TICK_MAX: usize = 256; +pub const RECURSION_MAX: usize = 8; +pub const STORAGE_SCHEMA: &str = "successor.macro-library.v1"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MacroSource { + pub name: String, + pub body: String, +} + +#[derive(Clone, Debug)] +struct Run { + macro_index: usize, + line: usize, + wait_until_tick: u64, + depth: usize, +} + +#[derive(Default)] +pub struct MacroRuntime { + macros: Vec, + runs: Vec, + dirty: bool, + pub last_error: Option, +} + +impl MacroRuntime { + pub fn macros(&self) -> &[MacroSource] { + &self.macros + } + pub fn dirty(&self) -> bool { + self.dirty + } + pub fn mark_saved(&mut self) { + self.dirty = false; + } + + pub fn save_macro(&mut self, name: &str, body: &str) -> Result<(), &'static str> { + let name = name.trim(); + if name.is_empty() { + return Err("macro_name_required"); + } + if body.len() > BODY_BYTES_MAX { + return Err("macro_body_too_large"); + } + parse(body)?; + if let Some(existing) = self + .macros + .iter_mut() + .find(|item| item.name.eq_ignore_ascii_case(name)) + { + existing.name = name.to_string(); + existing.body = body.to_string(); + } else { + if self.macros.len() >= MACROS_MAX { + return Err("macro_cap"); + } + self.macros.push(MacroSource { + name: name.to_string(), + body: body.to_string(), + }); + } + self.dirty = true; + Ok(()) + } + + pub fn delete(&mut self, name: &str) -> bool { + let Some(index) = self + .macros + .iter() + .position(|item| item.name.eq_ignore_ascii_case(name)) + else { + return false; + }; + self.macros.remove(index); + self.runs.retain(|run| run.macro_index != index); + for run in &mut self.runs { + if run.macro_index > index { + run.macro_index -= 1; + } + } + self.dirty = true; + true + } + + pub fn start(&mut self, name: &str) -> Result<(), &'static str> { + if self.runs.len() >= RUN_SLOTS_MAX { + return Err("macro_run_slots_full"); + } + let Some(index) = self + .macros + .iter() + .position(|item| item.name.eq_ignore_ascii_case(name)) + else { + return Err("macro_not_found"); + }; + self.runs.push(Run { + macro_index: index, + line: 0, + wait_until_tick: 0, + depth: 0, + }); + Ok(()) + } + + pub fn stop(&mut self, name: &str) { + self.runs.retain(|run| { + !self + .macros + .get(run.macro_index) + .is_some_and(|item| item.name.eq_ignore_ascii_case(name)) + }); + } + + pub fn tick( + &mut self, + tick: u64, + selected_target: Option<&str>, + out: &mut Vec, + ) { + let mut budget = STEPS_PER_TICK_MAX; + let mut index = 0; + while index < self.runs.len() && budget > 0 { + if self.runs[index].wait_until_tick > tick { + index += 1; + continue; + } + let source = &self.macros[self.runs[index].macro_index]; + let lines: Vec<&str> = source + .body + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .collect(); + if self.runs[index].line >= lines.len() { + self.runs.swap_remove(index); + continue; + } + let line = lines[self.runs[index].line]; + self.runs[index].line += 1; + budget -= 1; + let mut words = line.split_whitespace(); + let verb = words.next().unwrap_or("").to_ascii_lowercase(); + match verb.as_str() { + "wait" => { + let delay = words + .next() + .and_then(|v| v.parse::().ok()) + .unwrap_or(1) + .clamp(1, 1800); + self.runs[index].wait_until_tick = tick.saturating_add(delay); + } + "attack" => { + if let Some(target_actor_id) = selected_target { + out.push(GameplayAction::Attack { + action_id: "basic_shot".into(), + target_actor_id: target_actor_id.to_string(), + }); + } + } + "reload" => out.push(GameplayAction::Reload { + weapon_id: None, + ammo_type: None, + }), + "kneel" | "stand" => out.push(GameplayAction::SetPosture { posture: verb }), + "peace" => out.push(GameplayAction::Peace), + "clone" => out.push(GameplayAction::CloneRespawn { facility_id: None }), + "call" => { + let Some(name) = words.next() else { + self.last_error = Some("macro_call_name_required".into()); + continue; + }; + if self.runs[index].depth >= RECURSION_MAX { + self.last_error = Some("macro_recursion_cap".into()); + continue; + } + if let Some(callee) = self + .macros + .iter() + .position(|item| item.name.eq_ignore_ascii_case(name)) + { + self.runs.push(Run { + macro_index: callee, + line: 0, + wait_until_tick: tick, + depth: self.runs[index].depth + 1, + }); + } else { + self.last_error = Some("macro_not_found".into()); + } + } + _ => self.last_error = Some(format!("macro_verb_unknown:{verb}")), + } + index += 1; + } + } + + pub fn save(&self) -> Value { + json!({"schema": STORAGE_SCHEMA, "macros": self.macros.iter().map(|item| json!({"name": item.name, "body": item.body})).collect::>()}) + } + + pub fn load(value: Option<&Value>) -> Self { + let mut runtime = Self::default(); + let Some(value) = value + .filter(|value| value.get("schema").and_then(Value::as_str) == Some(STORAGE_SCHEMA)) + else { + return runtime; + }; + if let Some(rows) = value.get("macros").and_then(Value::as_array) { + for row in rows.iter().take(MACROS_MAX) { + if let (Some(name), Some(body)) = ( + row.get("name").and_then(Value::as_str), + row.get("body").and_then(Value::as_str), + ) { + let _ = runtime.save_macro(name, body); + } + } + } + runtime.dirty = false; + runtime + } +} + +fn parse(body: &str) -> Result<(), &'static str> { + if body.len() > BODY_BYTES_MAX { + return Err("macro_body_too_large"); + } + for line in body + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + { + let verb = line + .split_whitespace() + .next() + .unwrap_or("") + .to_ascii_lowercase(); + if !matches!( + verb.as_str(), + "wait" | "attack" | "reload" | "kneel" | "stand" | "peace" | "clone" | "call" + ) { + return Err("macro_verb_unknown"); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_unknown_and_oversize_sources() { + let mut rt = MacroRuntime::default(); + assert_eq!( + rt.save_macro("bad", "debug-grant"), + Err("macro_verb_unknown") + ); + assert_eq!( + rt.save_macro("large", &"x".repeat(BODY_BYTES_MAX + 1)), + Err("macro_body_too_large") + ); + } + + #[test] + fn runs_only_public_actions_with_wait_and_round_trips() { + let mut rt = MacroRuntime::default(); + rt.save_macro("combat", "attack\nwait 2\nreload").unwrap(); + rt.start("combat").unwrap(); + let mut out = Vec::new(); + rt.tick(1, Some("target"), &mut out); + assert!(matches!(out[0], GameplayAction::Attack { .. })); + rt.tick(2, Some("target"), &mut out); + rt.tick(3, Some("target"), &mut out); + rt.tick(4, Some("target"), &mut out); + assert!(matches!(out.last(), Some(GameplayAction::Reload { .. }))); + let loaded = MacroRuntime::load(Some(&rt.save())); + assert_eq!(loaded.macros(), rt.macros()); + } +} diff --git a/client-rust/source/app/src/game/mod.rs b/client-rust/source/app/src/game/mod.rs new file mode 100644 index 00000000..5c5d5749 --- /dev/null +++ b/client-rust/source/app/src/game/mod.rs @@ -0,0 +1,17 @@ +//! Playable-slice systems: projecting streamed authority state into the ECS, +//! turning input into movement commands, and the chat overlay. Native-only — +//! the wasm runtime's networking is a later parity wave. + +pub mod actions; +pub mod authority; +pub mod chat; +pub mod chat_net; +pub mod chat_ui; +pub mod combat_fx; +pub mod command_queue; +pub mod connected_scene; +pub mod interp; +pub mod macro_runtime; +pub mod movement; +pub mod prediction; +pub mod projection; diff --git a/client-rust/source/app/src/game/movement.rs b/client-rust/source/app/src/game/movement.rs new file mode 100644 index 00000000..4323b6b0 --- /dev/null +++ b/client-rust/source/app/src/game/movement.rs @@ -0,0 +1,410 @@ +//! Shared connected-movement input state for native and web clients. +//! +//! Input edges are transmitted immediately. Held input uses a wall-clock +//! keepalive, and a released intent is retried through the authority's +//! one-second expiry window. Rendering cadence never controls network cadence. + +use std::collections::{HashMap, VecDeque}; +use successor_engine_core::input::Key; +use successor_net::{CardinalDirection, ClientCommand, ClientCommandEnvelope, PlayerId, SessionId}; + +pub const INTENT_KEEPALIVE_MS: u64 = 500; +pub const STOP_RETRY_MS: u64 = 250; +pub const STOP_RETRY_WINDOW_MS: u64 = 1_000; + +#[derive(Clone, Copy, Debug)] +pub struct MovementController { + desired: IntentState, + last_sent: Option, + next_send_ms: u64, + stop_retry_until_ms: u64, + last_change_ms: u64, + last_send_ms: u64, +} + +impl Default for MovementController { + fn default() -> Self { + Self { + desired: IntentState::default(), + last_sent: None, + next_send_ms: 0, + stop_retry_until_ms: 0, + last_change_ms: 0, + last_send_ms: 0, + } + } +} + +impl MovementController { + pub fn desired(&self) -> IntentState { + self.desired + } + + pub fn timing(&self) -> MovementTiming { + MovementTiming { + last_change_ms: self.last_change_ms, + last_send_ms: self.last_send_ms, + next_send_ms: self.next_send_ms, + stop_retry_until_ms: self.stop_retry_until_ms, + } + } + + pub fn update(&mut self, desired: IntentState, now_ms: u64) -> Option { + let changed = desired != self.desired; + if changed { + self.desired = desired; + self.next_send_ms = now_ms; + self.last_change_ms = now_ms; + self.stop_retry_until_ms = if desired.stopped() { + now_ms.saturating_add(STOP_RETRY_WINDOW_MS) + } else { + 0 + }; + } + if self.last_sent.is_none() && desired.stopped() && !changed { + return None; + } + if !changed && now_ms < self.next_send_ms { + return None; + } + if !changed && desired.stopped() && now_ms > self.stop_retry_until_ms { + return None; + } + self.last_sent = Some(desired); + self.last_send_ms = now_ms; + self.next_send_ms = now_ms.saturating_add(if desired.stopped() { + STOP_RETRY_MS + } else { + INTENT_KEEPALIVE_MS + }); + Some(desired) + } + + pub fn release(&mut self, now_ms: u64, _reason: StopReason) -> Option { + self.update(IntentState::default(), now_ms) + } + + pub fn reset(&mut self) { + *self = Self::default(); + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct MovementTiming { + pub last_change_ms: u64, + pub last_send_ms: u64, + pub next_send_ms: u64, + pub stop_retry_until_ms: u64, +} + +/// Directional intent from the current key state. Convention: `+dx` = east +/// (D/Right), `+dy` = south (S/Down); the authority interprets the axes. +pub fn intent_from_keys(down: impl Fn(Key) -> bool) -> (i32, i32, bool) { + let mut dx = 0; + let mut dy = 0; + if down(Key::A) || down(Key::Left) { + dx -= 1; + } + if down(Key::D) || down(Key::Right) { + dx += 1; + } + if down(Key::W) || down(Key::Up) { + dy -= 1; + } + if down(Key::S) || down(Key::Down) { + dy += 1; + } + let sprint = down(Key::LeftShift); + (dx, dy, sprint) +} + +/// Convert the current directional intent to the protocol's four-way facing. +/// Diagonal movement keeps its dominant axis; a tie is resolved horizontally +/// for deterministic replay. +pub fn facing_from_intent(dx: i32, dy: i32) -> Option { + match (dx, dy) { + (0, y) if y < 0 => Some(CardinalDirection::Front), + (x, 0) if x > 0 => Some(CardinalDirection::Right), + (0, y) if y > 0 => Some(CardinalDirection::Back), + (x, 0) if x < 0 => Some(CardinalDirection::Left), + (x, y) if x.abs() >= y.abs() && x > 0 => Some(CardinalDirection::Right), + (x, y) if x.abs() >= y.abs() && x < 0 => Some(CardinalDirection::Left), + (_, y) if y < 0 => Some(CardinalDirection::Front), + (_, y) if y > 0 => Some(CardinalDirection::Back), + _ => None, + } +} + +/// An input edge that must stop movement. This is intentionally independent +/// of rendering/window state so every platform shell can apply it identically. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StopReason { + FocusLost, + ModalInput, + Disconnected, + Transition, + Dead, + ControlReleased, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct IntentState { + pub dx: i32, + pub dy: i32, + pub sprint: bool, +} + +impl IntentState { + pub fn stopped(self) -> bool { + self.dx == 0 && self.dy == 0 && !self.sprint + } + pub fn release(&mut self, _reason: StopReason) { + *self = Self::default(); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PointerTarget { + Actor, + Prop, + Empty, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PointerGesture { + LeftClick, + RightClick, + DoubleLeft, + RightHold, +} + +/// Normalize pointer grammar before verb lookup. Empty clicks never teleport: +/// the caller turns `MoveTo` into successive authority intents. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PointerAction { + Select, + OpenRadial, + DefaultAction, + MoveTo, + FaceOrStrafe, + ClearSelection, +} + +pub fn pointer_action(target: PointerTarget, gesture: PointerGesture) -> PointerAction { + match (target, gesture) { + (PointerTarget::Actor, PointerGesture::LeftClick) => PointerAction::Select, + (PointerTarget::Actor, PointerGesture::RightClick) => PointerAction::OpenRadial, + (PointerTarget::Actor, PointerGesture::RightHold) => PointerAction::FaceOrStrafe, + (PointerTarget::Actor, PointerGesture::DoubleLeft) => PointerAction::DefaultAction, + (PointerTarget::Empty, PointerGesture::LeftClick) => PointerAction::MoveTo, + (PointerTarget::Empty, PointerGesture::RightHold) => PointerAction::FaceOrStrafe, + (PointerTarget::Empty, _) => PointerAction::ClearSelection, + (PointerTarget::Prop, PointerGesture::RightClick) => PointerAction::OpenRadial, + (PointerTarget::Prop, PointerGesture::DoubleLeft) => PointerAction::DefaultAction, + (PointerTarget::Prop, _) => PointerAction::Select, + } +} +/// Deterministic eight-neighbor route. Search is bounded around the endpoints; +/// diagonal steps require both adjacent cardinal cells to be clear. +pub fn route_grid( + start: (i32, i32), + goal: (i32, i32), + mut blocked: impl FnMut(i32, i32) -> bool, +) -> Option> { + const NEIGHBORS: [(i32, i32); 8] = [ + (0, -1), + (1, 0), + (0, 1), + (-1, 0), + (1, -1), + (1, 1), + (-1, 1), + (-1, -1), + ]; + let margin = 32; + let min_x = start.0.min(goal.0) - margin; + let max_x = start.0.max(goal.0) + margin; + let min_y = start.1.min(goal.1) - margin; + let max_y = start.1.max(goal.1) + margin; + let mut queue = VecDeque::from([start]); + let mut previous = HashMap::from([(start, start)]); + while let Some(cell) = queue.pop_front() { + if cell == goal { + let mut route = Vec::new(); + let mut cursor = goal; + while cursor != start { + route.push(cursor); + cursor = previous[&cursor]; + } + route.reverse(); + return Some(route); + } + for (dx, dy) in NEIGHBORS { + let next = (cell.0 + dx, cell.1 + dy); + if next.0 < min_x + || next.0 > max_x + || next.1 < min_y + || next.1 > max_y + || previous.contains_key(&next) + || (next != goal && blocked(next.0, next.1)) + || (dx != 0 + && dy != 0 + && (blocked(cell.0 + dx, cell.1) || blocked(cell.0, cell.1 + dy))) + { + continue; + } + previous.insert(next, cell); + queue.push_back(next); + } + } + None +} + +/// Build a `SetMoveIntent` envelope reusing the shared `successor-net` vocabulary. +pub fn move_envelope( + session: u64, + player: u32, + command_id: u64, + tick: u64, + dx: i32, + dy: i32, + sprint: bool, +) -> ClientCommandEnvelope { + ClientCommandEnvelope { + session: SessionId(session), + player: PlayerId(player), + command_id, + issued_at_tick: tick, + command: ClientCommand::SetMoveIntent { + dx, + dy, + facing: facing_from_intent(dx, dy), + sprint, + }, + } +} + +/// A stopped intent is still sent through the queue so the authority observes +/// key-up/focus-loss, rather than the client merely hiding local movement. +pub fn stop_envelope( + session: u64, + player: u32, + command_id: u64, + tick: u64, +) -> ClientCommandEnvelope { + move_envelope(session, player, command_id, tick, 0, 0, false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wd_gives_east_forward() { + let down = |k: Key| matches!(k, Key::W | Key::D); + assert_eq!(intent_from_keys(down), (1, -1, false)); + } + + #[test] + fn shift_sprints_and_idle_is_zero() { + assert_eq!(intent_from_keys(|k| k == Key::LeftShift), (0, 0, true)); + assert_eq!(intent_from_keys(|_| false), (0, 0, false)); + } + + #[test] + fn envelope_carries_set_move_intent() { + let e = move_envelope(7, 3, 1, 42, 1, 0, true); + assert!(matches!( + e.command, + ClientCommand::SetMoveIntent { + dx: 1, + dy: 0, + + sprint: true, + .. + } + )); + assert_eq!( + (e.session.0, e.player.0, e.command_id, e.issued_at_tick), + (7, 3, 1, 42) + ); + } + #[test] + fn facing_is_cardinal_and_release_is_authoritative() { + assert_eq!(facing_from_intent(1, -1), Some(CardinalDirection::Right)); + let mut intent = IntentState { + dx: 1, + dy: 0, + sprint: true, + }; + intent.release(StopReason::FocusLost); + assert!(intent.stopped()); + let stop = stop_envelope(1, 2, 3, 4); + assert!(matches!( + stop.command, + ClientCommand::SetMoveIntent { + dx: 0, + dy: 0, + sprint: false, + .. + } + )); + } + #[test] + fn pointer_grammar_is_deterministic() { + assert_eq!( + pointer_action(PointerTarget::Actor, PointerGesture::RightClick), + PointerAction::OpenRadial + ); + assert_eq!( + pointer_action(PointerTarget::Empty, PointerGesture::LeftClick), + PointerAction::MoveTo + ); + assert_eq!( + pointer_action(PointerTarget::Empty, PointerGesture::RightHold), + PointerAction::FaceOrStrafe + ); + } + #[test] + fn route_detours_around_blocked_cells() { + let blocked = [(1, 0), (1, 1), (1, 2)]; + let route = route_grid((0, 1), (2, 1), |x, y| blocked.contains(&(x, y))) + .expect("route around wall"); + assert_eq!(route.last(), Some(&(2, 1))); + assert!(route.iter().all(|cell| !blocked.contains(cell))); + } + + #[test] + fn route_rejects_diagonal_corner_cutting() { + let blocked = [(1, 0), (0, 1)]; + let route = route_grid((0, 0), (1, 1), |x, y| blocked.contains(&(x, y))); + assert!(route.is_some()); + assert_ne!(route.unwrap().first(), Some(&(1, 1))); + } + + #[test] + fn cadence_uses_wall_time_and_retries_release() { + let mut controller = MovementController::default(); + let walking = IntentState { + dx: 1, + dy: 0, + sprint: false, + }; + assert_eq!(controller.update(walking, 10), Some(walking)); + assert_eq!(controller.update(walking, 509), None); + assert_eq!(controller.update(walking, 510), Some(walking)); + + let stopped = IntentState::default(); + assert_eq!(controller.update(stopped, 600), Some(stopped)); + assert_eq!(controller.update(stopped, 849), None); + assert_eq!(controller.update(stopped, 850), Some(stopped)); + assert_eq!(controller.update(stopped, 1_600), Some(stopped)); + assert_eq!(controller.update(stopped, 1_851), None); + } + + #[test] + fn idle_controller_sends_nothing() { + let mut controller = MovementController::default(); + assert_eq!(controller.update(IntentState::default(), 1_000), None); + } +} diff --git a/client-rust/source/app/src/game/prediction.rs b/client-rust/source/app/src/game/prediction.rs new file mode 100644 index 00000000..abb14dfb --- /dev/null +++ b/client-rust/source/app/src/game/prediction.rs @@ -0,0 +1,191 @@ +//! Local-player movement prediction — port of the client-prediction feel from +//! `authorityMovementSystem.ts` + `gameAuthoritySystem.ts`. The predicted +//! position advances with held intent but is capped a small "lead" ahead of the +//! authoritative position so the local player feels responsive without running +//! away from the server; acks reconcile the authoritative point and clamp the +//! predicted point back within a tighter correction lead. Remote actors use the +//! interpolation buffer in `interp.rs`, not this. +//! +//! Constants: base/sprint speed from `tuning.v1.json` +//! (`playerSpeedCellsPerSecond` 1.357, `sprintSpeedMultiplier` 4.809); lead caps +//! from `gameAuthoritySystem.ts` (walk 0.82 / sprint 0.9 prediction, 0.58 / 0.65 +//! correction). + +pub const BASE_SPEED_CELLS: f32 = 1.357; +pub const SPRINT_MULTIPLIER: f32 = 4.809; +const WALK_LEAD: f32 = 0.82; +const SPRINT_LEAD: f32 = 0.9; +const WALK_CORRECTION_LEAD: f32 = 0.58; +const SPRINT_CORRECTION_LEAD: f32 = 0.65; +const FIXED_STEP_SECONDS: f32 = 1.0 / 30.0; +const MAX_STEPS_PER_FRAME: usize = 4; + +/// Ground speed in cells/second for the local player. `role_multiplier` folds in +/// role/profession/strain effects the caller resolves (1.0 for a plain player). +pub fn speed_cells_per_second(sprint: bool, role_multiplier: f32) -> f32 { + BASE_SPEED_CELLS * role_multiplier * if sprint { SPRINT_MULTIPLIER } else { 1.0 } +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct MovePredictor { + auth_x: f32, + auth_y: f32, + pred_x: f32, + pred_y: f32, + accumulator: f32, +} + +impl MovePredictor { + pub fn new(x: f32, y: f32) -> Self { + MovePredictor { + auth_x: x, + auth_y: y, + pred_x: x, + pred_y: y, + accumulator: 0.0, + } + } + + pub fn render_pos(&self) -> (f32, f32) { + (self.pred_x, self.pred_y) + } + + pub fn authoritative(&self) -> (f32, f32) { + (self.auth_x, self.auth_y) + } + + /// Advance on the authority's fixed tick cadence and resolve every replay + /// step through the same swept-circle solver used by the Rust authority. + /// Returns input-driven distance only; reconciliation never feeds gait. + pub fn predict( + &mut self, + collision: &crate::world::movement_collision::MovementCollisionWorld, + dx: f32, + dy: f32, + sprint: bool, + speed_cells_per_second: f32, + dt: f32, + ) -> f32 { + self.accumulator = (self.accumulator + dt.clamp(0.0, 0.1)) + .min(FIXED_STEP_SECONDS * MAX_STEPS_PER_FRAME as f32); + let len = (dx * dx + dy * dy).sqrt(); + let mut moved = 0.0; + let mut steps = 0; + while self.accumulator >= FIXED_STEP_SECONDS && steps < MAX_STEPS_PER_FRAME { + self.accumulator -= FIXED_STEP_SECONDS; + steps += 1; + if len > 1e-4 { + let distance = speed_cells_per_second.max(0.0) * FIXED_STEP_SECONDS; + let before = (self.pred_x, self.pred_y); + let resolved = collision.resolve_anchor_move( + self.pred_x, + self.pred_y, + dx / len * distance, + dy / len * distance, + ); + self.pred_x = resolved.0; + self.pred_y = resolved.1; + moved += ((resolved.0 - before.0).powi(2) + (resolved.1 - before.1).powi(2)).sqrt(); + } + } + let lead = if sprint { SPRINT_LEAD } else { WALK_LEAD }; + self.clamp_to_lead(lead); + moved + } + + /// Apply an authoritative position (from an ack/delta), then clamp the + /// predicted point within the tighter correction lead. When not moving, the + /// predicted point snaps exactly to authoritative. + pub fn reconcile(&mut self, auth_x: f32, auth_y: f32, moving: bool, sprint: bool) { + self.auth_x = auth_x; + self.auth_y = auth_y; + if !moving { + self.pred_x = auth_x; + self.pred_y = auth_y; + return; + } + let lead = if sprint { + SPRINT_CORRECTION_LEAD + } else { + WALK_CORRECTION_LEAD + }; + self.clamp_to_lead(lead); + } + + fn clamp_to_lead(&mut self, lead: f32) { + let dx = self.pred_x - self.auth_x; + let dy = self.pred_y - self.auth_y; + let d = (dx * dx + dy * dy).sqrt(); + if d > lead && d > 1e-6 { + let k = lead / d; + self.pred_x = self.auth_x + dx * k; + self.pred_y = self.auth_y + dy * k; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn speed_sprint_scales() { + assert!((speed_cells_per_second(false, 1.0) - 1.357).abs() < 1e-4); + assert!((speed_cells_per_second(true, 1.0) - 1.357 * 4.809).abs() < 1e-3); + } + + #[test] + fn prediction_capped_at_lead() { + let mut p = MovePredictor::new(0.0, 0.0); + let world = crate::world::movement_collision::MovementCollisionWorld::default(); + // Hold north (−y) for a long time; predicted must not exceed walk lead. + for _ in 0..600 { + p.predict(&world, 0.0, -1.0, false, BASE_SPEED_CELLS, 1.0 / 60.0); + } + let (_, y) = p.render_pos(); + assert!((y + WALK_LEAD).abs() < 1e-3, "capped at walk lead, got {y}"); + } + + #[test] + fn sprint_lead_is_larger() { + let mut p = MovePredictor::new(0.0, 0.0); + let world = crate::world::movement_collision::MovementCollisionWorld::default(); + for _ in 0..600 { + p.predict( + &world, + 1.0, + 0.0, + true, + BASE_SPEED_CELLS * SPRINT_MULTIPLIER, + 1.0 / 60.0, + ); + } + let (x, _) = p.render_pos(); + assert!((x - SPRINT_LEAD).abs() < 1e-3); + } + + #[test] + fn reconcile_snaps_when_stopped() { + let mut p = MovePredictor::new(0.0, 0.0); + let world = crate::world::movement_collision::MovementCollisionWorld::default(); + p.predict(&world, 1.0, 0.0, false, BASE_SPEED_CELLS, 0.5); // predicted moved ahead + p.reconcile(5.0, 0.0, false, false); + assert_eq!(p.render_pos(), (5.0, 0.0)); + } + + #[test] + fn reconcile_clamps_correction_lead() { + let mut p = MovePredictor::new(0.0, 0.0); + let world = crate::world::movement_collision::MovementCollisionWorld::default(); + // Predict far ahead, then authority lags well behind. + for _ in 0..600 { + p.predict(&world, 1.0, 0.0, false, BASE_SPEED_CELLS, 1.0 / 60.0); + } + p.reconcile(0.0, 0.0, true, false); + let (x, _) = p.render_pos(); + assert!( + x <= WALK_CORRECTION_LEAD + 1e-3, + "clamped to correction lead, got {x}" + ); + } +} diff --git a/client-rust/source/app/src/game/projection.rs b/client-rust/source/app/src/game/projection.rs new file mode 100644 index 00000000..38db0e65 --- /dev/null +++ b/client-rust/source/app/src/game/projection.rs @@ -0,0 +1,242 @@ +//! Project streamed authority state (`game.hello` / `game.snapshot` / +//! `game.delta` / `game.acks`) into ECS entities: one capsule per actor on a +//! flat ground plane. Map-bundle world geometry and GLB pawns are later parity +//! waves; this is the barebones "see actors, watch them move" slice. +//! +//! Authority `(x, y)` are planar metre/cell coordinates; capsules use the same +//! world-unit contract as terrain, GLB props, and connected-mode pawns. + +use std::collections::BTreeMap; + +use successor_client_proto::packets::{GameHello, GameShardDelta, GameShardSnapshot}; +use successor_engine_core::ecs::{Entity, WorldOps}; +use successor_engine_core::math::{vec3, Quat, Vec3}; +use successor_engine_render::components::{MaterialId, MeshId, MeshRenderer, Transform}; + +use crate::world::{ADULT_PAWN_HEIGHT_METERS, WORLD_UNITS_PER_CELL}; +use crate::GameWorld; + +const HERO_Y: f32 = ADULT_PAWN_HEIGHT_METERS * 0.5; +/// All actors are visible in the main (0) and minimap (1) viewports. +const ACTOR_MASK: u32 = 0b011; + +pub struct WorldActors { + capsule: MeshId, + mat_player: MaterialId, + mat_other: MaterialId, + entities: BTreeMap, + player_actor_id: Option, + player_pos: Vec3, +} + +impl WorldActors { + pub fn new(capsule: MeshId, mat_player: MaterialId, mat_other: MaterialId) -> Self { + Self { + capsule, + mat_player, + mat_other, + entities: BTreeMap::new(), + player_actor_id: None, + player_pos: vec3(0.0, HERO_Y, 0.0), + } + } + + pub fn player_actor_id(&self) -> Option<&str> { + self.player_actor_id.as_deref() + } + + /// Current player world position (follow-camera focus). + pub fn player_pos(&self) -> Vec3 { + self.player_pos + } + + pub fn actor_count(&self) -> usize { + self.entities.len() + } + + pub fn apply_hello(&mut self, world: &mut GameWorld, hello: &GameHello) { + self.player_actor_id = Some(hello.player_actor_id.clone()); + self.apply_snapshot(world, &hello.snapshot); + } + + pub fn apply_snapshot(&mut self, world: &mut GameWorld, snap: &GameShardSnapshot) { + if self.player_actor_id.is_none() && !snap.player_actor_id.is_empty() { + self.player_actor_id = Some(snap.player_actor_id.clone()); + } + for (id, actor) in &snap.actors { + self.upsert(world, id, actor.x, actor.y, &actor.direction); + } + } + + pub fn apply_delta(&mut self, world: &mut GameWorld, delta: &GameShardDelta) { + // Full actor snapshots included in the delta. + for (id, actor) in &delta.actors { + self.upsert(world, id, actor.x, actor.y, &actor.direction); + } + // Field-level patches. + for (id, patch) in &delta.actor_patches { + let (x, y) = (patch.x, patch.y); + if let (Some(x), Some(y)) = (x, y) { + let dir = patch.direction.clone().unwrap_or_default(); + self.upsert(world, id, x, y, &dir); + } + } + // Removals. + for id in &delta.actor_removals { + if let Some(e) = self.entities.remove(id) { + world.destroy(e); + } + } + world.flush(); + } + + /// Apply a `game.acks` player-actor position correction. + pub fn apply_player_position(&mut self, world: &mut GameWorld, x: f32, y: f32) { + if let Some(id) = self.player_actor_id.clone() { + self.upsert(world, &id, x, y, ""); + } + } + + fn upsert(&mut self, world: &mut GameWorld, id: &str, x: f32, y: f32, direction: &str) { + let pos = vec3(x * WORLD_UNITS_PER_CELL, HERO_Y, y * WORLD_UNITS_PER_CELL); + let rot = yaw_for_direction(direction); + let is_player = self.player_actor_id.as_deref() == Some(id); + if is_player { + self.player_pos = pos; + } + if let Some(&e) = self.entities.get(id) { + if let Some(tr) = world.get_component::(e) { + tr.pos = pos; + tr.rot = rot; + } + return; + } + let e = world.spawn(); + world.set_component( + e, + Transform { + pos, + rot, + scale: Vec3::ONE, + }, + ); + world.set_component( + e, + MeshRenderer { + mesh: self.capsule, + material: if is_player { + self.mat_player + } else { + self.mat_other + }, + viewport_mask: ACTOR_MASK, + ..Default::default() + }, + ); + self.entities.insert(id.to_string(), e); + } +} + +fn yaw_for_direction(direction: &str) -> Quat { + let d = direction.to_ascii_lowercase(); + let yaw = if d.starts_with('n') { + 0.0 + } else if d.starts_with('e') { + core::f32::consts::FRAC_PI_2 + } else if d.starts_with('s') { + core::f32::consts::PI + } else if d.starts_with('w') { + -core::f32::consts::FRAC_PI_2 + } else { + 0.0 + }; + Quat::from_yaw(yaw) +} + +#[cfg(test)] +mod tests { + use super::*; + use successor_client_proto::packets::{GameActorSnapshot, GameActorVitals}; + use successor_engine_render::gpu::NullGpu; + use successor_engine_render::primitives; + use successor_engine_render::renderer::{Renderer, RendererLimits}; + + fn actor(id: &str, x: f32, y: f32) -> GameActorSnapshot { + GameActorSnapshot { + id: id.into(), + label: "npc".into(), + display_name: id.into(), + area_id: "open-desert".into(), + x, + y, + direction: "north".into(), + vitals: GameActorVitals { + health: 100.0, + action: 100.0, + spirit: 100.0, + }, + life_state: "alive".into(), + ..Default::default() + } + } + + #[test] + fn hello_then_delta_spawns_moves_and_removes() { + let mut gpu = NullGpu::default(); + let mut r = Renderer::new(&mut gpu, RendererLimits::default()) + .expect("renderer initialization failed"); + let (v, i) = primitives::capsule(0.4, 1.8, 8, 4); + let capsule = r.upload_mesh(&mut gpu, &v, &i); + let mp = r.add_material_desc(successor_engine_render::renderer::MaterialDesc { + base_color: [0.9, 0.8, 0.2, 1.0], + blend: ([0.9, 0.8, 0.2, 1.0])[3] < 1.0, + ..successor_engine_render::renderer::MaterialDesc::default() + }); + let mo = r.add_material_desc(successor_engine_render::renderer::MaterialDesc { + base_color: [0.5, 0.6, 0.7, 1.0], + blend: ([0.5, 0.6, 0.7, 1.0])[3] < 1.0, + ..successor_engine_render::renderer::MaterialDesc::default() + }); + let mut wa = WorldActors::new(capsule, mp, mo); + let mut world = GameWorld::new(); + + // Hello: player + one other actor. + let mut snap = GameShardSnapshot { + player_actor_id: "me".into(), + ..Default::default() + }; + snap.actors.insert("me".into(), actor("me", 10.0, 20.0)); + snap.actors.insert("bob".into(), actor("bob", 5.0, 5.0)); + let hello = GameHello { + session_id: "s".into(), + player_actor_id: "me".into(), + snapshot: snap, + server_time: "t".into(), + }; + wa.apply_hello(&mut world, &hello); + assert_eq!(wa.actor_count(), 2); + assert_eq!(wa.player_actor_id(), Some("me")); + assert_eq!(wa.player_pos(), vec3(10.0, HERO_Y, 20.0)); + + // Delta: move player, remove bob. + let mut delta = GameShardDelta::default(); + delta.actor_patches.insert( + "me".into(), + successor_client_proto::packets::GameActorPatch { + id: "me".into(), + x: Some(12.0), + y: Some(22.0), + direction: Some("east".into()), + ..Default::default() + }, + ); + delta.actor_removals.push("bob".into()); + wa.apply_delta(&mut world, &delta); + assert_eq!(wa.actor_count(), 1, "bob removed"); + assert_eq!( + wa.player_pos(), + vec3(12.0, HERO_Y, 22.0), + "player moved by authority" + ); + } +} diff --git a/client-rust/source/app/src/glb_scene.rs b/client-rust/source/app/src/glb_scene.rs new file mode 100644 index 00000000..ae795ae3 --- /dev/null +++ b/client-rust/source/app/src/glb_scene.rs @@ -0,0 +1,215 @@ +//! `--demo glb-view`: load a repo `.glb` and orbit it, exercising the whole +//! asset path — GLB parse, mesh/material upload, static baking, and (for rigged +//! bodies) skeletal animation via the skinning pipeline. Native visual QA for +//! Wave 1; the pawn/prop game wiring builds on this in later waves. + +use successor_engine_core::anim::{apply_animation, JointTransform, Skeleton}; +use successor_engine_core::ecs::{Entity, WorldOps}; +use successor_engine_core::glb::{self, GlbAnimation}; +use successor_engine_core::math::{vec3, Vec3}; +use successor_engine_render::components::{ + CamTarget, Camera, DirectionalLight, MeshRenderer, Projection, SkinRef, Transform, +}; +use successor_engine_render::gpu::Gpu; +use successor_engine_render::renderer::Renderer; + +use crate::GameWorld; + +pub struct GlbScene { + pub world: GameWorld, + pub renderer: Renderer, + skeleton: Option, + anim: Option, + pose: Vec, + palette: Vec<[f32; 16]>, + skinned_entities: Vec, + camera: Entity, + center: Vec3, + orbit_radius: f32, +} + +impl GlbScene { + /// Parse `bytes` and build a scene. `clip` names the animation to play + /// (falls back to the first animation, or none for static meshes). + pub fn build( + gpu: &mut G, + bytes: &[u8], + clip: Option<&str>, + ) -> Result { + let doc = glb::parse(bytes)?; + let mut renderer = crate::configured_renderer(gpu).expect("renderer initialization failed"); + renderer.set_ambient(0.35); + let mut world = GameWorld::new(); + + let globals = doc.node_globals(); + let skinned = !doc.skins.is_empty(); + let skeleton = if skinned { + Skeleton::from_document(&doc, 0) + } else { + None + }; + + let uploaded = successor_engine_render::model::upload_glb(&mut renderer, gpu, &doc) + .map_err(|error| { + eprintln!("GLB model upload failed: {error:?}"); + glb::GlbError::Unsupported("model upload") + })?; + + let mut aabb_min = vec3(f32::MAX, f32::MAX, f32::MAX); + let mut aabb_max = vec3(f32::MIN, f32::MIN, f32::MIN); + let mut skinned_entities = Vec::new(); + + for (node_idx, node) in doc.nodes.iter().enumerate() { + let Some(mesh_idx) = node.mesh else { continue }; + let Some(mesh) = doc.meshes.get(mesh_idx) else { + continue; + }; + let g = globals[node_idx]; + for (primitive_idx, prim) in mesh.primitives.iter().enumerate() { + if prim.positions.is_empty() { + continue; + } + let is_skinned = skinned && !prim.joints.is_empty() && !prim.weights.is_empty(); + let uploaded_primitive = uploaded + .primitives + .iter() + .find(|item| { + item.source_mesh == mesh_idx && item.source_primitive == primitive_idx + }) + .ok_or(glb::GlbError::Unsupported("missing uploaded primitive"))?; + for position in &prim.positions { + let p = if is_skinned { + vec3(position[0], position[1], position[2]) + } else { + g.transform_point(vec3(position[0], position[1], position[2])) + }; + aabb_min = min3(aabb_min, p); + aabb_max = max3(aabb_max, p); + } + let e = world.spawn(); + let (pos, rot, scale) = if is_skinned { + ( + Vec3::ZERO, + successor_engine_core::math::Quat::IDENTITY, + Vec3::ONE, + ) + } else { + g.to_trs() + }; + world.set_component(e, Transform { pos, rot, scale }); + world.set_component( + e, + MeshRenderer { + mesh: uploaded_primitive.mesh, + material: uploaded_primitive.material, + viewport_mask: 0b1, + skin: SkinRef::NONE, + }, + ); + if is_skinned { + skinned_entities.push(e); + } + } + } + + if aabb_min.x > aabb_max.x { + aabb_min = vec3(-1.0, -1.0, -1.0); + aabb_max = vec3(1.0, 1.0, 1.0); + } + let center = aabb_min.add(aabb_max).scale(0.5); + renderer.gi_set_focus([center.x, center.y, center.z]); + let extent = aabb_max.sub(aabb_min); + let orbit_radius = (extent.length() * 0.5).max(0.5) * 2.4; + + // Sun. + let sun = world.spawn(); + world.set_component( + sun, + DirectionalLight { + dir: vec3(-0.4, -1.0, -0.3).normalize(), + color: [1.0, 0.98, 0.92], + cast_shadows: true, + }, + ); + + // Orbiting perspective camera. + let camera = world.spawn(); + world.set_component( + camera, + Camera { + viewport_id: 0, + order: 0, + projection: Projection::Perspective { + fovy: 45.0_f32.to_radians(), + near: 0.05, + far: 500.0, + }, + target: CamTarget::Screen(successor_engine_render::components::RectNorm::FULL), + clear: successor_engine_render::gpu::ClearSpec { + color: Some([0.08, 0.09, 0.11, 1.0]), + depth: Some(1.0), + }, + eye: center.add(vec3(orbit_radius, orbit_radius * 0.6, orbit_radius)), + look_at: center, + up: Vec3::Y, + }, + ); + + let anim = clip + .and_then(|c| doc.animation_by_name(c).cloned()) + .or_else(|| doc.animations.first().cloned()); + let pose = skeleton.as_ref().map(|s| s.rest_pose()).unwrap_or_default(); + + Ok(GlbScene { + world, + renderer, + skeleton, + anim, + pose, + palette: Vec::new(), + skinned_entities, + camera, + center, + orbit_radius, + }) + } + + pub fn animate(&mut self, frame: u64) { + // Orbit the camera. + let angle = frame as f32 * 0.012; + let eye = self.center.add(vec3( + angle.cos() * self.orbit_radius, + self.orbit_radius * 0.55, + angle.sin() * self.orbit_radius, + )); + if let Some(cam) = self.world.get_component::(self.camera) { + cam.eye = eye; + } + + // Skinned animation. + if let Some(sk) = self.skeleton.as_mut() { + self.pose.copy_from_slice(&sk.rest); + if let Some(anim) = self.anim.as_ref() { + let duration = anim.duration.max(0.001); + let t = (frame as f32 / 60.0) % duration; + apply_animation(anim, t, &mut self.pose); + } + sk.compute_palette(&self.pose, &mut self.palette); + self.renderer.begin_skin_frame(); + let offset = self.renderer.push_skin_palette(&self.palette); + let count = self.palette.len() as u32; + for &e in &self.skinned_entities { + if let Some(mr) = self.world.get_component::(e) { + mr.skin = SkinRef { offset, count }; + } + } + } + } +} + +fn min3(a: Vec3, b: Vec3) -> Vec3 { + vec3(a.x.min(b.x), a.y.min(b.y), a.z.min(b.z)) +} +fn max3(a: Vec3, b: Vec3) -> Vec3 { + vec3(a.x.max(b.x), a.y.max(b.y), a.z.max(b.z)) +} diff --git a/client-rust/source/app/src/graphics_tuning.rs b/client-rust/source/app/src/graphics_tuning.rs new file mode 100644 index 00000000..0416606e --- /dev/null +++ b/client-rust/source/app/src/graphics_tuning.rs @@ -0,0 +1,620 @@ +//! Developer graphics mastering overlay, toggled by Backquote. + +use crate::render_settings::{self, QualityPreset, RenderSettingsDocument}; +use successor_engine_render::gpu::Gpu; +use successor_engine_render::renderer::Renderer; +use successor_engine_render::ui::{ButtonStyle, UiBuilder}; + +const PANEL_W: f32 = 574.0; +const PANEL_H: f32 = 688.0; +const ROW_H: f32 = 25.0; +const TEXT: [u8; 4] = [218, 228, 238, 255]; +const MUTED: [u8; 4] = [145, 162, 180, 255]; +const ACCENT: [u8; 4] = [240, 196, 96, 255]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Page { + Lighting, + Post, + Color, +} + +pub struct GraphicsTuner { + open: bool, + previous_toggle: bool, + page: Page, + document: RenderSettingsDocument, + status: String, +} + +impl GraphicsTuner { + pub fn new() -> Self { + Self { + open: false, + previous_toggle: false, + page: Page::Lighting, + document: render_settings::document(), + status: "BACKQUOTE CLOSES THIS OVERLAY".to_string(), + } + } + + pub fn is_open(&self) -> bool { + self.open + } + + /// Dismiss the overlay without changing the backquote edge state. + pub fn dismiss(&mut self) -> bool { + if !self.open { + return false; + } + self.open = false; + self.status = "TUNING CLOSED".to_string(); + true + } + + pub fn handle_toggle(&mut self, down: bool) -> bool { + let changed = down && !self.previous_toggle; + self.previous_toggle = down; + if changed { + self.open = !self.open; + self.status = if self.open { + "LIVE TUNING ACTIVE".to_string() + } else { + "TUNING CLOSED".to_string() + }; + } + changed + } + + pub fn draw( + &mut self, + ui: &mut UiBuilder, + renderer: &mut Renderer, + gpu: &mut G, + screen_w: u32, + screen_h: u32, + ) { + if !self.open { + return; + } + let x = (screen_w as f32 - PANEL_W - 14.0).max(8.0); + let y = ((screen_h as f32 - PANEL_H) * 0.5).max(8.0); + let h = PANEL_H.min(screen_h as f32 - y - 8.0); + ui.panel(x, y, PANEL_W, h, [10, 15, 23, 248], [118, 142, 168, 255]); + ui.rect(x + 1.0, y + 1.0, PANEL_W - 2.0, 38.0, [26, 36, 50, 255]); + ui.text("GRAPHICS MASTERING", x + 16.0, y + 12.0, 2.0, ACCENT); + ui.text("`", x + PANEL_W - 30.0, y + 12.0, 2.0, MUTED); + + let mut changed = self.draw_preset_row(ui, x, y + 48.0); + self.draw_page_tabs(ui, x, y + 86.0); + ui.rect(x + 12.0, y + 122.0, PANEL_W - 24.0, 1.0, [65, 82, 102, 255]); + + let content_y = y + 132.0; + changed |= match self.page { + Page::Lighting => self.draw_lighting(ui, x, content_y), + Page::Post => self.draw_post(ui, x, content_y), + Page::Color => self.draw_color(ui, x, content_y), + }; + + if changed { + self.apply(renderer, gpu); + } + + let actions_y = y + h - 42.0; + let style = crate::hud::button_style(); + if ui.button(x + 14.0, actions_y, 104.0, 28.0, "SAVE", style) { + match render_settings::save(&self.document) { + Ok(()) => self.status = "SAVED ASSETS/RENDER/SETTINGS.JSON".to_string(), + Err(error) => self.status = format!("SAVE FAILED: {error}"), + } + } + if ui.button(x + 126.0, actions_y, 104.0, 28.0, "RELOAD", style) { + match render_settings::reload() { + Ok(document) => { + self.document = document; + crate::set_render_quality(self.document.selected_preset.render_quality()); + self.apply(renderer, gpu); + self.status = "RELOADED DISK SETTINGS".to_string(); + } + Err(error) => self.status = format!("RELOAD FAILED: {error}"), + } + } + if ui.button(x + 238.0, actions_y, 144.0, 28.0, "RESET PRESET", style) { + self.document.reset_selected(); + self.apply(renderer, gpu); + self.status = "RESET ACTIVE PRESET".to_string(); + } + ui.text( + &truncate_status(&self.status), + x + 392.0, + actions_y + 9.0, + 1.0, + MUTED, + ); + } + + fn draw_preset_row(&mut self, ui: &mut UiBuilder, x: f32, y: f32) -> bool { + ui.text("QUALITY", x + 16.0, y + 9.0, 1.4, TEXT); + let mut changed = false; + for (index, preset) in QualityPreset::ALL.into_iter().enumerate() { + let bx = x + 124.0 + index as f32 * 112.0; + let selected = self.document.selected_preset == preset; + if ui.button(bx, y, 104.0, 28.0, preset.label(), selected_style(selected)) { + self.document.select(preset); + crate::set_render_quality(preset.render_quality()); + self.status = "PRESET APPLIED; SHADER TIER ON RESTART".to_string(); + changed = true; + } + } + changed + } + + fn draw_page_tabs(&mut self, ui: &mut UiBuilder, x: f32, y: f32) { + for (index, (page, label)) in [ + (Page::Lighting, "LIGHTING"), + (Page::Post, "POST / AA"), + (Page::Color, "COLOR / PALETTE"), + ] + .into_iter() + .enumerate() + { + if ui.button( + x + 14.0 + index as f32 * 180.0, + y, + 170.0, + 28.0, + label, + selected_style(self.page == page), + ) { + self.page = page; + } + } + } + + fn draw_lighting(&mut self, ui: &mut UiBuilder, x: f32, mut y: f32) -> bool { + let preset = self.document.selected_mut(); + let mut changed = false; + changed |= slider_row( + ui, + x, + y, + "AMBIENT", + &mut preset.ambient_intensity, + 0.0, + 2.0, + 2, + ); + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + "EMISSIVE", + &mut preset.emissive_scalar, + 0.0, + 8.0, + 2, + ); + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + "AUTO POINT LIGHT SCALE", + &mut preset.auto_point_light_scale, + 0.0, + 8.0, + 2, + ); + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + "AO INTENSITY", + &mut preset.ao.intensity, + 0.0, + 4.0, + 2, + ); + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + "SUN AZIMUTH", + &mut preset.sun.azimuth_degrees, + -180.0, + 180.0, + 1, + ); + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + "SUN ELEVATION", + &mut preset.sun.elevation_degrees, + 5.0, + 89.0, + 1, + ); + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + "SUN INTENSITY", + &mut preset.sun.intensity, + 0.0, + 8.0, + 2, + ); + for channel in 0..3 { + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + ["SUN RED", "SUN GREEN", "SUN BLUE"][channel], + &mut preset.sun.color[channel], + 0.0, + 4.0, + 2, + ); + } + y += ROW_H; + changed |= option_row( + ui, + x, + y, + "SHADOW SIZE", + &mut preset.shadows.map_size, + &[512, 1024, 2048, 4096], + ); + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + "SHADOW RANGE", + &mut preset.shadows.world_radius, + 8.0, + 256.0, + 1, + ); + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + "DEPTH BIAS", + &mut preset.shadows.depth_bias, + 0.0, + 0.05, + 4, + ); + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + "NORMAL BIAS", + &mut preset.shadows.normal_bias, + 0.0, + 8.0, + 2, + ); + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + "PENUMBRA", + &mut preset.shadows.penumbra, + 0.0, + 100.0, + 1, + ); + changed + } + + fn draw_post(&mut self, ui: &mut UiBuilder, x: f32, mut y: f32) -> bool { + let preset = self.document.selected_mut(); + let mut changed = false; + changed |= slider_row(ui, x, y, "EXPOSURE", &mut preset.exposure, 0.1, 4.0, 2); + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + "BLOOM THRESHOLD", + &mut preset.bloom.threshold, + 0.0, + 8.0, + 2, + ); + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + "BLOOM INTENSITY", + &mut preset.bloom.intensity, + 0.0, + 4.0, + 2, + ); + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + "BLOOM SIZE", + &mut preset.bloom.radius, + 0.25, + 4.0, + 2, + ); + y += ROW_H + 5.0; + changed |= ui.checkbox( + x + 18.0, + y, + 18.0, + "FXAA ENABLED", + &mut preset.aa.enabled, + crate::hud::button_style(), + ); + y += ROW_H + 5.0; + changed |= slider_row( + ui, + x, + y, + "AA MIN EDGE", + &mut preset.aa.edge_threshold_min, + 0.001, + 0.5, + 3, + ); + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + "AA EDGE", + &mut preset.aa.edge_threshold, + 0.01, + 0.5, + 3, + ); + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + "AA SUBPIXEL", + &mut preset.aa.subpixel_blend, + 0.0, + 1.0, + 2, + ); + changed + } + + fn draw_color(&mut self, ui: &mut UiBuilder, x: f32, mut y: f32) -> bool { + let preset = self.document.selected_mut(); + let grade = &mut preset.color_grading; + let mut changed = false; + changed |= slider_row(ui, x, y, "SATURATION", &mut grade.saturation, 0.0, 2.0, 2); + y += ROW_H; + changed |= slider_row(ui, x, y, "CONTRAST", &mut grade.contrast, 0.0, 2.0, 2); + y += ROW_H; + changed |= slider_row(ui, x, y, "DISPLAY GAMMA", &mut grade.gamma, 0.5, 2.5, 2); + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + "TEMPERATURE", + &mut grade.temperature, + -1.0, + 1.0, + 2, + ); + y += ROW_H; + changed |= slider_row(ui, x, y, "TINT", &mut grade.tint, -1.0, 1.0, 2); + for channel in 0..3 { + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + ["LIFT R", "LIFT G", "LIFT B"][channel], + &mut grade.lift[channel], + -1.0, + 1.0, + 2, + ); + } + for channel in 0..3 { + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + ["COLOR GAMMA R", "COLOR GAMMA G", "COLOR GAMMA B"][channel], + &mut grade.color_gamma[channel], + 0.1, + 4.0, + 2, + ); + } + for channel in 0..3 { + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + ["GAIN R", "GAIN G", "GAIN B"][channel], + &mut grade.gain[channel], + 0.0, + 4.0, + 2, + ); + } + y += ROW_H + 3.0; + changed |= ui.checkbox( + x + 18.0, + y, + 18.0, + "PALETTE QUANTIZATION", + &mut preset.palette.enabled, + crate::hud::button_style(), + ); + y += ROW_H + 3.0; + changed |= option_row( + ui, + x, + y, + "PALETTE LEVELS", + &mut preset.palette.levels, + &[2, 4, 8, 16, 32, 64], + ); + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + "PALETTE STRENGTH", + &mut preset.palette.strength, + 0.0, + 1.0, + 2, + ); + y += ROW_H; + changed |= slider_row( + ui, + x, + y, + "PALETTE DITHER", + &mut preset.palette.dither, + 0.0, + 1.0, + 2, + ); + changed + } + + fn apply(&mut self, renderer: &mut Renderer, gpu: &mut G) { + match self.document.selected().validate("active") { + Ok(()) => { + match renderer.apply_settings(gpu, self.document.selected().renderer_settings()) { + Ok(()) => { + if let Err(error) = render_settings::replace(self.document.clone()) { + self.status = format!("SETTINGS STATE FAILED: {error}"); + } else if !self.status.starts_with("PRESET") { + self.status = "LIVE SETTINGS APPLIED".to_string(); + } + } + Err(error) => self.status = format!("GPU APPLY FAILED: {error:?}"), + } + } + Err(error) => self.status = format!("INVALID SETTINGS: {error}"), + } + } +} + +impl Default for GraphicsTuner { + fn default() -> Self { + Self::new() + } +} + +fn selected_style(selected: bool) -> ButtonStyle { + if selected { + ButtonStyle { + fill: [104, 77, 32, 245], + hover: [126, 94, 38, 255], + active: [150, 112, 44, 255], + edge: ACCENT, + text: [255, 232, 174, 255], + } + } else { + crate::hud::button_style() + } +} + +#[allow(clippy::too_many_arguments)] +fn slider_row( + ui: &mut UiBuilder, + x: f32, + y: f32, + label: &str, + value: &mut f32, + min: f32, + max: f32, + precision: usize, +) -> bool { + ui.text(label, x + 18.0, y + 7.0, 1.25, TEXT); + let display = format!("{value:.precision$}"); + let display_w = ui.measure_text(&display, 1.2); + ui.text(&display, x + 208.0 - display_w, y + 7.0, 1.2, MUTED); + ui.slider( + x + 220.0, + y + 2.0, + 330.0, + 20.0, + value, + min, + max, + crate::hud::button_style(), + ) +} + +fn option_row( + ui: &mut UiBuilder, + x: f32, + y: f32, + label: &str, + value: &mut u32, + options: &[u32], +) -> bool { + ui.text(label, x + 18.0, y + 7.0, 1.25, TEXT); + let button_w = 326.0 / options.len() as f32; + let mut changed = false; + for (index, option) in options.iter().copied().enumerate() { + let label = option.to_string(); + if ui.button( + x + 220.0 + index as f32 * button_w, + y, + button_w - 3.0, + 22.0, + &label, + selected_style(*value == option), + ) { + *value = option; + changed = true; + } + } + changed +} + +fn truncate_status(status: &str) -> String { + status.chars().take(27).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backquote_toggle_is_edge_triggered() { + let mut tuner = GraphicsTuner::new(); + assert!(tuner.handle_toggle(true)); + assert!(tuner.is_open()); + assert!(!tuner.handle_toggle(true)); + assert!(!tuner.handle_toggle(false)); + assert!(tuner.handle_toggle(true)); + assert!(!tuner.is_open()); + } +} diff --git a/client-rust/source/app/src/hosted_creator.rs b/client-rust/source/app/src/hosted_creator.rs new file mode 100644 index 00000000..b58a9f0e --- /dev/null +++ b/client-rust/source/app/src/hosted_creator.rs @@ -0,0 +1,662 @@ +//! Hosted character-workshop protocol and state fence. +//! +//! The browser parent remains the only account, roster, CSRF, create, and +//! ticket authority. This module accepts only the parent’s deliberately small +//! projection, turns screen intents into the three creator messages, and never +//! contains a credential, URL, or HTTP path. + +use crate::screens::RosterEntry; +use serde_json::{json, Map, Value}; + +pub const CREATOR_READY_TYPE: &str = "successor.creator.ready.v1"; +pub const CREATOR_CREATE_TYPE: &str = "successor.creator.create.v1"; +pub const CREATOR_SELECT_TYPE: &str = "successor.creator.select.v1"; +pub const CREATOR_STATE_TYPE: &str = "successor.creator.state.v1"; +pub const CREATOR_CREATE_RESULT_TYPE: &str = "successor.creator.create-result.v1"; + +const MAX_ROSTER_ENTRIES: usize = 10; +const MAX_CHARACTER_ID: usize = 128; +const MAX_CHARACTER_NAME: usize = 16; +const MAX_ERROR_TEXT: usize = 128; +const CREATE_TIMEOUT_MS: u64 = 12_000; +const APPEARANCE_KEYS: &[&str] = &["body", "skinTone", "hair", "hairMat", "face"]; +const FACE_KEYS: &[&str] = &[ + "eyes", + "brows", + "nose", + "mouth", + "eyeColor", + "browColor", + "lipColor", +]; + +/// The only body values accepted by the parent character store. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CreatorBody { + Male, + Female, +} + +impl CreatorBody { + pub const fn from_female(female: bool) -> Self { + if female { + Self::Female + } else { + Self::Male + } + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::Male => "male", + Self::Female => "female", + } + } + + fn parse(value: &str) -> Option { + match value { + "male" => Some(Self::Male), + "female" => Some(Self::Female), + _ => None, + } + } +} + +/// Minimal safe roster record retained by the Rust child. Account references, +/// tickets, and all unknown fields are discarded by the platform bridge before +/// this parser sees a message. `body` remains so the roster never silently +/// presents every parent-owned character as male. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreatorCharacter { + pub id: String, + pub name: String, + pub initial_profession_id: Option, + pub world_entry_claimed: bool, + pub body: CreatorBody, +} + +impl CreatorCharacter { + fn roster_entry(&self) -> RosterEntry { + RosterEntry { + id: self.id.clone(), + name: self.name.clone(), + female: self.body == CreatorBody::Female, + lineage: String::new(), + vocation: display_profession(self.initial_profession_id.as_deref()), + location: String::new(), + played: String::new(), + } + } +} + +/// Bounded parent projection for the live workshop. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreatorState { + pub characters: Vec, + pub selected_character_id: Option, +} + +/// A validated parent message. The platform only queues these two types. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CreatorInbound { + State(CreatorState), + CreateResult(CreatorCreateResult), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreatorCreateResult { + pub request_id: String, + pub ok: bool, + pub error: Option, +} + +/// The canonical appearance expressible by the Rust pregame screen. It exactly +/// matches the hosted parent’s `CharacterAppearance` contract: the visible +/// presentation maps body precisely, while fixed canonical values provide the +/// current skin/hair/face defaults without inventing another authority path. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CanonicalAppearance { + pub body: CreatorBody, + pub skin_tone: &'static str, + pub hair: Option<&'static str>, + pub hair_mat: &'static str, +} + +impl CanonicalAppearance { + pub const fn creator_default(female: bool) -> Self { + Self { + body: CreatorBody::from_female(female), + skin_tone: "#c78f62", + hair: Some("hair_mop"), + hair_mat: "hair_raven", + } + } +} + +/// Exact child-to-parent create payload. It is serialized only immediately +/// before the platform posts it to the exact configured parent origin. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreatorCreateRequest { + pub request_id: String, + pub name: String, + pub initial_profession_id: &'static str, + pub appearance: CanonicalAppearance, +} + +impl CreatorCreateRequest { + pub fn to_json(&self) -> String { + serde_json::to_string(&json!({ + "type": CREATOR_CREATE_TYPE, + "requestId": self.request_id.as_str(), + "character": { + "name": self.name.as_str(), + "initialProfessionId": self.initial_profession_id, + "appearance": { + "body": self.appearance.body.as_str(), + "skinTone": self.appearance.skin_tone, + "hair": self.appearance.hair, + "hairMat": self.appearance.hair_mat, + "face": Value::Null, + }, + }, + })) + .expect("fixed hosted creator payload serializes") + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CreateStartError { + Pending, + InvalidName, + UnsupportedProfession, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CreateEffect { + Ignored, + WaitingForRoster, + Rejected(String), + Created(String), + TimedOut, +} + +/// Result of replacing the server-owned roster projection. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct StateEffect { + pub created_character_id: Option, + pub selected_character_id: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PendingPhase { + WaitingForResult, + WaitingForRoster, +} + +#[derive(Clone, Debug)] +struct PendingCreate { + request_id: String, + expected_name: String, + before_ids: Vec, + started_ms: u64, + phase: PendingPhase, +} + +/// Small state machine for one hosted workshop frame. A create only completes +/// after its matching result *and* a refreshed state prove that exactly one new +/// stable id has appeared for the requested canonical name. +#[derive(Debug, Default)] +pub struct HostedCreatorFlow { + roster: Vec, + pending: Option, + next_request: u64, +} + +impl HostedCreatorFlow { + pub fn roster_entries(&self) -> Vec { + self.roster + .iter() + .map(CreatorCharacter::roster_entry) + .collect() + } + + pub fn apply_state(&mut self, state: CreatorState) -> StateEffect { + self.roster = state.characters; + let created_character_id = self + .pending + .as_ref() + .filter(|pending| pending.phase == PendingPhase::WaitingForRoster) + .and_then(|pending| self.created_character_id(pending)); + if created_character_id.is_some() { + self.pending = None; + } + StateEffect { + created_character_id, + selected_character_id: state.selected_character_id, + } + } + + pub fn begin_create( + &mut self, + display_name: &str, + screen_vocation: &str, + female: bool, + now_ms: u64, + ) -> Result { + if self.pending.is_some() { + return Err(CreateStartError::Pending); + } + let name = canonical_creator_name(display_name).ok_or(CreateStartError::InvalidName)?; + let initial_profession_id = + hosted_profession(screen_vocation).ok_or(CreateStartError::UnsupportedProfession)?; + self.next_request = self.next_request.wrapping_add(1).max(1); + let request_id = format!("r{}-{}", self.next_request, now_ms.max(1)); + self.pending = Some(PendingCreate { + request_id: request_id.clone(), + expected_name: name.clone(), + before_ids: self + .roster + .iter() + .map(|character| character.id.clone()) + .collect(), + started_ms: now_ms, + phase: PendingPhase::WaitingForResult, + }); + Ok(CreatorCreateRequest { + request_id, + name, + initial_profession_id, + appearance: CanonicalAppearance::creator_default(female), + }) + } + + pub fn handle_create_result(&mut self, result: CreatorCreateResult) -> CreateEffect { + let Some(pending) = self.pending.as_ref() else { + return CreateEffect::Ignored; + }; + if pending.request_id != result.request_id { + return CreateEffect::Ignored; + } + if !result.ok { + self.pending = None; + return CreateEffect::Rejected( + result + .error + .unwrap_or_else(|| "CREATE REQUEST WAS REJECTED".to_string()), + ); + } + if let Some(pending) = self.pending.as_mut() { + pending.phase = PendingPhase::WaitingForRoster; + } + let created_character_id = self + .pending + .as_ref() + .and_then(|pending| self.created_character_id(pending)); + if let Some(character_id) = created_character_id { + self.pending = None; + CreateEffect::Created(character_id) + } else { + CreateEffect::WaitingForRoster + } + } + + pub fn expire(&mut self, now_ms: u64) -> CreateEffect { + let timed_out = self + .pending + .as_ref() + .is_some_and(|pending| now_ms.saturating_sub(pending.started_ms) >= CREATE_TIMEOUT_MS); + if timed_out { + self.pending = None; + CreateEffect::TimedOut + } else { + CreateEffect::Ignored + } + } + + pub fn has_pending_create(&self) -> bool { + self.pending.is_some() + } + + /// Abort only the local wait when the browser bridge could not post the + /// already-validated payload. Nothing has reached parent authority. + pub fn cancel_pending(&mut self) { + self.pending = None; + } + + fn created_character_id(&self, pending: &PendingCreate) -> Option { + let mut matches = self.roster.iter().filter(|character| { + !pending.before_ids.iter().any(|id| id == &character.id) + && character.name == pending.expected_name + }); + let candidate = matches.next()?; + (matches.next().is_none()).then(|| candidate.id.clone()) + } +} + +/// Parse a normalized queued platform message. This is deliberately strict a +/// second time: malformed messages are absent, never partially interpreted. +pub fn parse_inbound_message(bytes: &[u8]) -> Option { + let value = serde_json::from_slice::(bytes).ok()?; + let object = value.as_object()?; + match object.get("type")?.as_str()? { + CREATOR_STATE_TYPE => parse_state(object).map(CreatorInbound::State), + CREATOR_CREATE_RESULT_TYPE => parse_create_result(object).map(CreatorInbound::CreateResult), + _ => None, + } +} + +fn parse_state(object: &Map) -> Option { + if !has_only_keys(object, &["type", "characters", "selectedCharacterId"]) { + return None; + } + let values = object.get("characters")?.as_array()?; + if values.len() > MAX_ROSTER_ENTRIES { + return None; + } + let mut characters = Vec::with_capacity(values.len()); + for value in values { + let character = parse_character(value)?; + if characters + .iter() + .any(|existing: &CreatorCharacter| existing.id == character.id) + { + return None; + } + characters.push(character); + } + let selected_character_id = match object.get("selectedCharacterId") { + None => None, + Some(Value::String(id)) if is_character_id(id) => Some(id.clone()), + Some(_) => return None, + }; + if let Some(id) = selected_character_id.as_deref() { + if !characters.iter().any(|character| character.id == id) { + return None; + } + } + Some(CreatorState { + characters, + selected_character_id, + }) +} + +fn parse_character(value: &Value) -> Option { + let object = value.as_object()?; + if !has_only_keys( + object, + &[ + "id", + "name", + "initialProfessionId", + "worldEntryClaimed", + "appearance", + ], + ) { + return None; + } + let id = object.get("id")?.as_str()?; + let name = object.get("name")?.as_str()?; + let initial_profession_id = match object.get("initialProfessionId")? { + Value::Null => None, + Value::String(value) if is_profession_id(value) => Some(value.clone()), + _ => return None, + }; + let world_entry_claimed = object.get("worldEntryClaimed")?.as_bool()?; + let body = parse_appearance_body(object.get("appearance")?)?; + if !is_character_id(id) || !is_creator_name(name) { + return None; + } + Some(CreatorCharacter { + id: id.to_string(), + name: name.to_string(), + initial_profession_id, + world_entry_claimed, + body, + }) +} + +fn parse_appearance_body(value: &Value) -> Option { + let appearance = value.as_object()?; + if !has_only_keys(appearance, APPEARANCE_KEYS) { + return None; + } + let body = CreatorBody::parse(appearance.get("body")?.as_str()?)?; + if !is_safe_text(appearance.get("skinTone")?.as_str()?, 1, 64) + || !is_safe_text(appearance.get("hairMat")?.as_str()?, 1, 64) + { + return None; + } + match appearance.get("hair")? { + Value::Null => {} + Value::String(hair) if is_safe_text(hair, 1, 64) => {} + _ => return None, + } + match appearance.get("face")? { + Value::Null => {} + Value::Object(face) if has_only_keys(face, FACE_KEYS) => { + for key in FACE_KEYS { + if !is_safe_text(face.get(*key)?.as_str()?, 1, 64) { + return None; + } + } + } + _ => return None, + } + Some(body) +} + +fn parse_create_result(object: &Map) -> Option { + if !has_only_keys(object, &["type", "requestId", "ok", "error"]) { + return None; + } + let request_id = object.get("requestId")?.as_str()?; + let ok = object.get("ok")?.as_bool()?; + let error = match object.get("error") { + None => None, + Some(Value::String(value)) if !ok && is_safe_text(value, 1, MAX_ERROR_TEXT) => { + Some(value.clone()) + } + Some(_) => return None, + }; + if !is_request_id(request_id) { + return None; + } + Some(CreatorCreateResult { + request_id: request_id.to_string(), + ok, + error, + }) +} + +fn has_only_keys(object: &Map, allowed: &[&str]) -> bool { + object.keys().all(|key| allowed.contains(&key.as_str())) +} + +fn is_character_id(value: &str) -> bool { + let bytes = value.as_bytes(); + (1..=MAX_CHARACTER_ID).contains(&bytes.len()) + && bytes[0].is_ascii_alphanumeric() + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) +} + +fn is_request_id(value: &str) -> bool { + let bytes = value.as_bytes(); + (1..=64).contains(&bytes.len()) + && bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) +} + +fn is_profession_id(value: &str) -> bool { + let bytes = value.as_bytes(); + (1..=32).contains(&bytes.len()) + && bytes[0].is_ascii_lowercase() + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_lowercase() || matches!(byte, b'_' | b'-')) +} + +fn is_creator_name(value: &str) -> bool { + let bytes = value.as_bytes(); + if !(3..=MAX_CHARACTER_NAME).contains(&bytes.len()) { + return false; + } + let mut previous_hyphen = false; + for (index, byte) in bytes.iter().copied().enumerate() { + if byte.is_ascii_alphabetic() { + previous_hyphen = false; + } else if byte == b'-' && index > 0 && !previous_hyphen && index + 1 < bytes.len() { + previous_hyphen = true; + } else { + return false; + } + } + !previous_hyphen +} + +fn is_safe_text(value: &str, min: usize, max: usize) -> bool { + (min..=max).contains(&value.len()) && value.bytes().all(|byte| (b' '..=b'~').contains(&byte)) +} + +fn canonical_creator_name(display_name: &str) -> Option { + let mut canonical = String::with_capacity(display_name.len()); + for part in display_name.split_whitespace() { + if !canonical.is_empty() { + canonical.push('-'); + } + canonical.push_str(part); + } + is_creator_name(&canonical).then_some(canonical) +} + +fn hosted_profession(screen_vocation: &str) -> Option<&'static str> { + match screen_vocation { + "TECHNICIAN" => Some("craftsman"), + "SCOUT" => Some("scout"), + "MEDIC" => Some("medic"), + "MARKSMAN" => Some("marksman"), + _ => None, + } +} + +fn display_profession(value: Option<&str>) -> String { + match value { + Some("craftsman") => "TECHNICIAN".to_string(), + Some("scout") => "SCOUT".to_string(), + Some("medic") => "MEDIC".to_string(), + Some("marksman") => "MARKSMAN".to_string(), + Some("brawler") => "BRAWLER".to_string(), + Some(value) => value.to_ascii_uppercase(), + None => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn character(id: &str, name: &str, body: CreatorBody) -> CreatorCharacter { + CreatorCharacter { + id: id.to_string(), + name: name.to_string(), + initial_profession_id: Some("scout".to_string()), + world_entry_claimed: false, + body, + } + } + + #[test] + fn parser_accepts_only_the_safe_roster_projection_with_body() { + let accepted = parse_inbound_message( + br##"{"type":"successor.creator.state.v1","characters":[{"id":"char-1","name":"Mara-Voss","initialProfessionId":"scout","worldEntryClaimed":false,"appearance":{"body":"female","skinTone":"#c78f62","hair":"hair_mop","hairMat":"hair_raven","face":null}}]}"##, + ); + assert!(matches!( + accepted, + Some(CreatorInbound::State(CreatorState { characters, .. })) + if characters[0].body == CreatorBody::Female + )); + + let rejected = parse_inbound_message( + br##"{"type":"successor.creator.state.v1","characters":[{"id":"char-1","name":"Mara-Voss","initialProfessionId":"scout","worldEntryClaimed":false,"appearance":{"body":"female","skinTone":"#c78f62","hair":"hair_mop","hairMat":"hair_raven","face":null},"ticket":"secret"}]}"##, + ); + assert!(rejected.is_none()); + } + + #[test] + fn create_maps_visible_fields_to_canonical_parent_payload() { + let mut flow = HostedCreatorFlow::default(); + let request = flow + .begin_create("Mara Voss", "TECHNICIAN", true, 42) + .unwrap(); + let payload = serde_json::from_str::(&request.to_json()).unwrap(); + assert_eq!(payload["type"], CREATOR_CREATE_TYPE); + assert_eq!(payload["character"]["name"], "Mara-Voss"); + assert_eq!(payload["character"]["initialProfessionId"], "craftsman"); + assert_eq!( + payload["character"]["appearance"], + json!({ + "body": "female", + "skinTone": "#c78f62", + "hair": "hair_mop", + "hairMat": "hair_raven", + "face": null, + }) + ); + } + + #[test] + fn create_waits_for_matching_result_and_refreshed_stable_id() { + let mut flow = HostedCreatorFlow::default(); + flow.apply_state(CreatorState { + characters: vec![character("char-old", "Atlas", CreatorBody::Male)], + selected_character_id: None, + }); + let request = flow + .begin_create("Mara Voss", "TECHNICIAN", true, 42) + .unwrap(); + assert_eq!( + flow.handle_create_result(CreatorCreateResult { + request_id: request.request_id, + ok: true, + error: None, + }), + CreateEffect::WaitingForRoster + ); + assert!(flow.has_pending_create()); + + let update = flow.apply_state(CreatorState { + characters: vec![ + character("char-old", "Atlas", CreatorBody::Male), + character("char-new", "Mara-Voss", CreatorBody::Female), + ], + selected_character_id: None, + }); + assert_eq!(update.created_character_id.as_deref(), Some("char-new")); + assert!(!flow.has_pending_create()); + } + + #[test] + fn create_rejection_is_matched_and_bounded() { + let mut flow = HostedCreatorFlow::default(); + let request = flow.begin_create("Mara", "SCOUT", false, 1).unwrap(); + assert_eq!( + flow.handle_create_result(CreatorCreateResult { + request_id: "other".to_string(), + ok: false, + error: Some("name_taken".to_string()), + }), + CreateEffect::Ignored + ); + assert_eq!( + flow.handle_create_result(CreatorCreateResult { + request_id: request.request_id, + ok: false, + error: Some("name_taken".to_string()), + }), + CreateEffect::Rejected("name_taken".to_string()) + ); + assert!(!flow.has_pending_create()); + } +} diff --git a/client-rust/source/app/src/hud.rs b/client-rust/source/app/src/hud.rs new file mode 100644 index 00000000..10b8048d --- /dev/null +++ b/client-rust/source/app/src/hud.rs @@ -0,0 +1,2097 @@ +//! Live HUD — the connected client's chrome, ported from the `client-3d` +//! reference surfaces (`ui/statusPlate.ts`, `ui/hud/*`, `ui/windows/dock.ts`). +//! +//! Everything here binds to [`HudState`], a plain-data projection built from +//! the authority store when packets apply (never per frame), so the draw path +//! renders prebuilt strings and numbers without steady-state allocation. +//! `HudState::default()` is the honest disconnected state (NO SIGNAL, empty +//! gauges) — there are no sample contacts, sectors, or shield values. +//! +//! Submodules: +//! - [`plate`] — status plate, target plate, group rail, death overlay, +//! interact chip, extraction toast, banners, first steps, ability queue. +//! - [`radar`] — north-up tactical scope (classification + click actions). +//! - [`toolbar`] — 12-slot toolbar (schema-3 doc), dock rail, action registry. +//! - [`overlays`] — world-anchored nameplates, chat bubbles, floating text. +//! - [`waypoints`] — character-scoped waypoint store (port of +//! `ui/waypoints/store.ts`). + +use successor_engine_render::font::{RasterFont, RasterGlyph}; +use successor_engine_render::ui::{AtlasMeta, TextField, UiBuilder}; +pub(crate) struct TextBuffer { + bytes: [u8; N], + len: usize, +} + +impl TextBuffer { + pub(crate) const fn new() -> Self { + Self { + bytes: [0; N], + len: 0, + } + } + + pub(crate) fn as_str(&self) -> &str { + core::str::from_utf8(&self.bytes[..self.len]).expect("formatted UI text is UTF-8") + } + + pub(crate) fn clear(&mut self) { + self.len = 0; + } +} + +impl core::fmt::Write for TextBuffer { + fn write_str(&mut self, value: &str) -> core::fmt::Result { + let available = self.bytes.len().saturating_sub(self.len); + if value.len() > available { + return Err(core::fmt::Error); + } + self.bytes[self.len..self.len + value.len()].copy_from_slice(value.as_bytes()); + self.len += value.len(); + Ok(()) + } +} + +pub mod layout; +pub mod overlays; +pub mod plate; +pub mod radar; +pub mod toolbar; +pub mod waypoints; + +const ICONS_A8: &[u8] = include_bytes!("../assets/ui/icons.a8"); +const ICONS_JSON: &str = include_str!("../assets/ui/icons.json"); +const UI_FONT_TTF: &[u8] = include_bytes!("../assets/ui/PT_Sans-Web-Bold.ttf"); +const LOADING_DESTINATION_PNG: &[u8] = + include_bytes!("../assets/ui/generated/loading-destination-atlas.png"); +const NAV_WEDGE_LEFT_PNG: &[u8] = include_bytes!("../assets/ui/generated/nav-wedge-left.png"); +const NAV_WEDGE_RIGHT_PNG: &[u8] = include_bytes!("../assets/ui/generated/nav-wedge-right.png"); +const ROSTER_CHEVRON_PNG: &[u8] = include_bytes!("../assets/ui/generated/row-chevron.png"); +const RADIAL_TICK_CROWN_PNG: &[u8] = include_bytes!("../assets/ui/generated/radial-tick-crown.png"); +const PROGRESS_ARC_PNG: &[u8] = include_bytes!("../assets/ui/generated/progress-arc.png"); +const UI_ATLAS_W: usize = 1024; +const UI_ATLAS_H: usize = 1024; +const UI_FONT_REGION_W: usize = 512; +const UI_FONT_SOURCE_PX: f32 = 32.0; + +const fn authored_uv(x: usize, y: usize, w: usize, h: usize) -> (f32, f32, f32, f32) { + ( + x as f32 / UI_ATLAS_W as f32, + y as f32 / UI_ATLAS_H as f32, + (x + w) as f32 / UI_ATLAS_W as f32, + (y + h) as f32 / UI_ATLAS_H as f32, + ) +} + +pub const LOADING_DESTINATION_UV: (f32, f32, f32, f32) = authored_uv(512, 0, 512, 512); +pub const NAV_WEDGE_LEFT_UV: (f32, f32, f32, f32) = authored_uv(512, 512, 77, 109); +pub const NAV_WEDGE_RIGHT_UV: (f32, f32, f32, f32) = authored_uv(608, 512, 85, 109); +pub const ROSTER_CHEVRON_UV: (f32, f32, f32, f32) = authored_uv(704, 512, 75, 97); +pub const RADIAL_TICK_CROWN_UV: (f32, f32, f32, f32) = authored_uv(800, 512, 147, 143); +pub const PROGRESS_ARC_UV: (f32, f32, f32, f32) = authored_uv(512, 672, 132, 135); + +fn blit_png(rgba: &mut [u8], bytes: &[u8], x: usize, y: usize, w: usize, h: usize) { + let image = successor_engine_core::image::decode_image("image/png", bytes) + .expect("authored UI image decode"); + assert_eq!((image.width as usize, image.height as usize), (w, h)); + for row in 0..h { + let src = &image.pixels[row * w * 4..(row + 1) * w * 4]; + let start = ((y + row) * UI_ATLAS_W + x) * 4; + rgba[start..start + w * 4].copy_from_slice(src); + } +} + +/// Parsed icon/font atlas: metadata, RGBA8 texture bytes, glyph metrics and +/// the stable icon-id map. Text and icons share one texture and one UI pass. +pub struct Icons { + pub meta: AtlasMeta, + pub rgba: Vec, + pub font: RasterFont, + map: Vec<(String, (u32, u32))>, +} + +impl Icons { + pub fn load() -> Self { + let v: serde_json::Value = serde_json::from_str(ICONS_JSON).expect("icons.json parse"); + let u = |k: &str| v[k].as_u64().unwrap_or(0) as u32; + let icon_w = u("width") as usize; + let icon_h = u("height") as usize; + let meta = AtlasMeta { + cell: u("cell"), + cols: u("cols"), + width: UI_ATLAS_W as u32, + height: UI_ATLAS_H as u32, + }; + let mut map = Vec::new(); + if let Some(arr) = v["icons"].as_array() { + for ic in arr { + let id = ic["id"].as_str().unwrap_or("").to_string(); + let col = ic["col"].as_u64().unwrap_or(0) as u32; + let row = ic["row"].as_u64().unwrap_or(0) as u32; + map.push((id, (col, row))); + } + } + + let mut alpha = vec![0u8; UI_ATLAS_W * UI_ATLAS_H]; + for row in 0..icon_h { + let src = &ICONS_A8[row * icon_w..(row + 1) * icon_w]; + let dst = &mut alpha[row * UI_ATLAS_W..row * UI_ATLAS_W + icon_w]; + dst.copy_from_slice(src); + } + + let font = fontdue::Font::from_bytes( + UI_FONT_TTF, + fontdue::FontSettings { + collection_index: 0, + scale: UI_FONT_SOURCE_PX, + load_substitutions: true, + }, + ) + .expect("PT Sans font parse"); + let line = font + .horizontal_line_metrics(UI_FONT_SOURCE_PX) + .expect("PT Sans horizontal metrics"); + let mut glyphs = Vec::with_capacity(95); + let mut atlas_x = 2usize; + let mut atlas_y = icon_h + 4; + let mut row_h = 0usize; + for code in 32u8..=126 { + let ch = code as char; + let (metrics, bitmap) = font.rasterize(ch, UI_FONT_SOURCE_PX); + if atlas_x + metrics.width + 2 > UI_FONT_REGION_W { + atlas_x = 2; + atlas_y += row_h + 2; + row_h = 0; + } + assert!( + atlas_y + metrics.height + 2 <= UI_ATLAS_H, + "UI font atlas overflow" + ); + for row in 0..metrics.height { + let src = &bitmap[row * metrics.width..(row + 1) * metrics.width]; + let start = (atlas_y + row) * UI_ATLAS_W + atlas_x; + alpha[start..start + metrics.width].copy_from_slice(src); + } + glyphs.push(RasterGlyph { + ch, + uv: ( + atlas_x as f32 / UI_ATLAS_W as f32, + atlas_y as f32 / UI_ATLAS_H as f32, + (atlas_x + metrics.width) as f32 / UI_ATLAS_W as f32, + (atlas_y + metrics.height) as f32 / UI_ATLAS_H as f32, + ), + width: metrics.width as f32, + height: metrics.height as f32, + xmin: metrics.xmin as f32, + ymin: metrics.ymin as f32, + advance: metrics.advance_width, + }); + atlas_x += metrics.width + 2; + row_h = row_h.max(metrics.height); + } + let font = RasterFont { + source_px: UI_FONT_SOURCE_PX, + ascent: line.ascent, + line_height: line.new_line_size, + glyphs, + }; + + let mut rgba = vec![0u8; alpha.len() * 4]; + for (i, &a) in alpha.iter().enumerate() { + rgba[i * 4] = 255; + rgba[i * 4 + 1] = 255; + rgba[i * 4 + 2] = 255; + rgba[i * 4 + 3] = a; + } + blit_png(&mut rgba, LOADING_DESTINATION_PNG, 512, 0, 512, 512); + blit_png(&mut rgba, NAV_WEDGE_LEFT_PNG, 512, 512, 77, 109); + blit_png(&mut rgba, NAV_WEDGE_RIGHT_PNG, 608, 512, 85, 109); + blit_png(&mut rgba, ROSTER_CHEVRON_PNG, 704, 512, 75, 97); + blit_png(&mut rgba, RADIAL_TICK_CROWN_PNG, 800, 512, 147, 143); + blit_png(&mut rgba, PROGRESS_ARC_PNG, 512, 672, 132, 135); + Self { + meta, + rgba, + font, + map, + } + } + + pub fn ui_builder(&self) -> UiBuilder { + UiBuilder::new_with_font(self.meta, self.font.clone()) + } + + /// Atlas cell `(col, row)` for an icon id. + pub fn cell(&self, id: &str) -> Option<(u32, u32)> { + self.map.iter().find(|(k, _)| k == id).map(|(_, c)| *c) + } + + pub fn len(&self) -> usize { + self.map.len() + } + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } +} + +// ── Theme palettes (exact port of `ui/uiTheme.ts` UI_THEMES) ──────────────── + +/// Themeable palette — the seven chrome colours plus danger. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Palette { + pub bg_panel: [u8; 4], + pub bg_cell: [u8; 4], + pub ink: [u8; 4], + pub ink_dim: [u8; 4], + pub hairline: [u8; 4], + pub accent: [u8; 4], + pub accent_soft: [u8; 4], + pub danger: [u8; 4], +} + +const fn hex(rgb: u32) -> [u8; 4] { + [ + ((rgb >> 16) & 0xff) as u8, + ((rgb >> 8) & 0xff) as u8, + (rgb & 0xff) as u8, + 255, + ] +} + +/// Panel fills carry the reference translucency (DOM panels sit on glass). +const fn hexa(rgb: u32, a: u8) -> [u8; 4] { + let c = hex(rgb); + [c[0], c[1], c[2], a] +} + +pub const THEME_COUNT: usize = 4; + +/// SIGNAL / PHOSPHOR / AMBER / OXIDE — ids, labels and swatch order match the +/// reference cycle (signal→phosphor→amber→oxide→signal). +pub const THEME_IDS: [&str; THEME_COUNT] = ["signal", "phosphor", "amber", "oxide"]; +pub const THEME_LABELS: [&str; THEME_COUNT] = ["SIGNAL", "PHOSPHOR", "AMBER", "OXIDE"]; + +/// Theme palettes. +/// +/// Panel and cell tones are derived from each theme's accent rather than being +/// a near-black slab: the original's pane is a translucent tint under a bright +/// outline (`ui_options.inc` centre `#003848`), and a black fill reads as a +/// hole punched in the world, not a surface. Every pair below is checked +/// against WCAG AA at small-text size — ink clears 7.2:1 on its own panel, +/// dim ink clears 4.6:1, and the accent clears 4.7:1 — so no theme has +/// unreadable type. +pub const THEMES: [Palette; THEME_COUNT] = [ + // signal + Palette { + bg_panel: hexa(0x113337, 232), + bg_cell: hexa(0x0a1e20, 235), + ink: hex(0xcfe9ef), + ink_dim: hex(0x899a9e), + hairline: hex(0x1a4d53), + accent: hex(0x48d6e6), + accent_soft: hex(0x164045), + danger: hex(0xe34a4a), + }, + // phosphor + Palette { + bg_panel: hexa(0x113d1d, 232), + bg_cell: hexa(0x0a2411, 235), + ink: hex(0x9cf0b4), + ink_dim: hex(0x45b362), + hairline: hex(0x195c2c), + accent: hex(0x46ff7a), + accent_soft: hex(0x154c25), + danger: hex(0xe34a4a), + }, + // amber + Palette { + bg_panel: hexa(0x3d2b12, 232), + bg_cell: hexa(0x24190a, 235), + ink: hex(0xffd98c), + ink_dim: hex(0xad945f), + hairline: hex(0x5c401b), + accent: hex(0xffb24a), + accent_soft: hex(0x4c3516), + danger: hex(0xe34a4a), + }, + // oxide + Palette { + bg_panel: hexa(0x36190e, 232), + bg_cell: hexa(0x1f0e08, 235), + ink: hex(0xe6d4b8), + ink_dim: hex(0x938876), + hairline: hex(0x512515), + accent: hex(0xe0673a), + accent_soft: hex(0x431f11), + danger: hex(0xe34a4a), + }, +]; + +/// Palette for a theme index (out-of-range folds to the default SIGNAL). +pub fn palette(theme_index: usize) -> Palette { + THEMES[theme_index % THEME_COUNT] +} + +/// Workspace frame chrome for a palette. +/// +/// `WindowStyle::default` carries the measured Signal tones as literals, which +/// left every frame cyan under any other theme. The grammar is what is fixed — +/// translucent pane, one bright perimeter, dark ink on the caption rail — so +/// the tones come from the theme and only the structure is hardcoded. +pub fn window_style(palette: &Palette) -> successor_engine_render::window::WindowStyle { + let mut style = successor_engine_render::window::WindowStyle::default(); + let bright = palette.accent; + let inset = shade(palette.accent, 0.42); + style.frame.center = palette.bg_panel; + style.frame.north = with_alpha(bright, 210); + style.frame.north_east = with_alpha(bright, 210); + style.frame.north_west = with_alpha(bright, 210); + style.frame.south = with_alpha(inset, 220); + style.frame.east = with_alpha(inset, 220); + style.frame.west = with_alpha(inset, 220); + style.frame.south_east = with_alpha(inset, 220); + style.frame.south_west = with_alpha(inset, 220); + style.title_bar = with_alpha(inset, 232); + style.title_bar_focused = with_alpha(bright, 236); + style.edge = with_alpha(bright, 204); + // Caption ink is dark ON the bright rail, so it darkens with the accent + // rather than staying a fixed navy that would vanish on amber. + style.caption_text = with_alpha(shade(palette.accent, 0.18), 255); + style.close = style.caption_text; + style.text = palette.ink; + style.resize = with_alpha(bright, 220); + style +} + +/// Button chrome for the active theme. +/// +/// `ButtonStyle::default` is a fixed slate blue that the engine cannot theme — +/// it has no palette. It was the single largest unthemed surface in the UI, +/// frozen across chat, options, macros, the bug report, the inventory footer +/// and the character sheet. Every app-side button reads this instead. +pub fn button_style() -> successor_engine_render::ui::ButtonStyle { + let palette = active_palette(); + successor_engine_render::ui::ButtonStyle { + fill: faded(palette.bg_cell), + hover: with_alpha(palette.accent_soft, 235), + active: with_alpha(shade(palette.accent, 0.55), 240), + edge: palette.hairline, + text: palette.ink, + } +} + +fn shade(rgba: [u8; 4], factor: f32) -> [u8; 4] { + let scale = |c: u8| (c as f32 * factor).round().clamp(0.0, 255.0) as u8; + [scale(rgba[0]), scale(rgba[1]), scale(rgba[2]), rgba[3]] +} + +fn with_alpha(rgba: [u8; 4], alpha: u8) -> [u8; 4] { + [rgba[0], rgba[1], rgba[2], alpha] +} + +/// Theme index for a stored id; unknown ids reset to SIGNAL (0). +pub fn theme_index_for_id(id: &str) -> usize { + THEME_IDS.iter().position(|t| *t == id).unwrap_or(0) +} + +/// Opacity band for the transparency settings. The floor is where a pane stops +/// separating itself from terrain; the ceiling is opaque. +pub const MIN_UI_OPACITY: f32 = 0.35; +pub const MAX_UI_OPACITY: f32 = 1.0; + +thread_local! { + static ACTIVE_PALETTE: core::cell::Cell = const { core::cell::Cell::new(THEMES[0]) }; + static FILL_OPACITY: core::cell::Cell = const { core::cell::Cell::new(1.0) }; +} + +/// Theme palette for the frame being drawn. +/// +/// Window surfaces used to carry their own hardcoded ink, so switching themes +/// recoloured the HUD and left every workspace frame cyan. The host publishes +/// the active palette once per frame and every surface reads it from here, so +/// one theme covers the whole UI. +pub fn active_palette() -> Palette { + ACTIVE_PALETTE.with(core::cell::Cell::get) +} + +pub fn set_active_palette(palette: Palette) { + ACTIVE_PALETTE.with(|active| active.set(palette)); +} + +/// Alpha scale for pane, well, and slot FILLS. +/// +/// Fills only. The original's window transparency fades the pane and leaves +/// the type and the bright perimeter at full strength, because a translucent +/// glyph is unreadable over terrain. +pub fn fill_opacity() -> f32 { + FILL_OPACITY.with(core::cell::Cell::get) +} + +pub fn set_fill_opacity(value: f32) { + let value = if value.is_finite() { + value.clamp(MIN_UI_OPACITY, MAX_UI_OPACITY) + } else { + MAX_UI_OPACITY + }; + FILL_OPACITY.with(|opacity| opacity.set(value)); +} + +/// Scale a fill colour's alpha by [`fill_opacity`]. +pub fn faded(rgba: [u8; 4]) -> [u8; 4] { + let alpha = (rgba[3] as f32 * fill_opacity()).round().clamp(0.0, 255.0) as u8; + [rgba[0], rgba[1], rgba[2], alpha] +} + +// ── Text hygiene ──────────────────────────────────────────────────────────── + +/// Sanitize server/player text before shaping: strips control characters, +/// collapses whitespace runs, and caps the length in chars. Everything the +/// HUD renders from a non-static source flows through here. +pub fn sanitize_text(input: &str, max_chars: usize) -> String { + let mut out = String::with_capacity(input.len().min(max_chars * 4)); + let mut count = 0usize; + let mut last_space = true; // also trims leading whitespace + for ch in input.chars() { + if count >= max_chars { + break; + } + if ch.is_whitespace() { + if !last_space { + out.push(' '); + count += 1; + last_space = true; + } + continue; + } + if ch.is_control() { + continue; + } + last_space = false; + out.push(ch); + count += 1; + } + while out.ends_with(' ') { + out.pop(); + } + out +} + +/// Remove a trailing descriptor such as `(a rogue trooper)` from a label +/// (port of `actorNameSystem.stripTypeRead`). +pub fn strip_type_read(label: &str) -> String { + let trimmed = label.trim(); + if let Some(open) = trimmed.rfind('(') { + if trimmed.ends_with(')') && !trimmed[open + 1..trimmed.len() - 1].contains('(') { + let stripped = trimmed[..open].trim_end(); + if !stripped.is_empty() { + return stripped.to_string(); + } + } + } + trimmed.to_string() +} + +/// Clean actor name: `display_name` first, then `label` with the trailing +/// type read stripped, then the fallback (port of `cleanActorName`). +pub fn clean_actor_name(display_name: &str, label: &str, fallback: &str) -> String { + let display = display_name.trim(); + if !display.is_empty() { + return sanitize_text(display, 48); + } + let label = label.trim(); + if !label.is_empty() { + return sanitize_text(&strip_type_read(label), 48); + } + fallback.to_string() +} + +// ── Live HUD state ────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ConnectionHud { + /// No accepted snapshot / actor — plate reads NO SIGNAL. + #[default] + NoSignal, + Live, + Reconnecting, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct GaugeHud { + pub value: f32, + pub max: f32, +} + +impl GaugeHud { + pub fn frac(&self) -> f32 { + if self.max > 0.0 { + (self.value / self.max).clamp(0.0, 1.0) + } else { + 0.0 + } + } + /// Low-vital emphasis threshold (reference: ≤25%). + pub fn low(&self) -> bool { + self.max > 0.0 && self.frac() <= 0.25 + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum LifeHud { + #[default] + Alive, + Downed, + Respawning, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SprintHud { + #[default] + Off, + On, + /// Authority sprint-recovery lock — label reads WINDED. + Winded, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct WeaponHud { + /// Stenciled field designation (already display-cleaned). + pub label: String, + pub melee: bool, + pub magazine_size: u32, + pub loaded_rounds: u32, + /// Prebuilt rounds readout (`7/8 · 24`, `REARMING…`, `READY`). + pub rounds_text: String, + pub reloading: bool, + /// 0..1 refill sweep progress while reloading. + pub reload_frac: f32, + /// Melee swing timer (time-to-next-swing where ammo normally lives). + pub swing_ready: bool, + pub swing_frac: f32, +} + +/// Who an actor is to you, as the nameplate and radar read it. +/// +/// Owner ruling 2026-08-04, and it is the authority over both clients: an NPC +/// name is white; a corpse is white; a passive attackable NPC is yellow and +/// turns red once it has been attacked; an aggressive attackable NPC is red; a +/// neutral player is bright blue; a player in your guild or faction is purple; +/// and a player open to you in PVP is red. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RelationHud { + /// Will fight you now: aggressive NPC, a provoked passive, or a PVP-open player. + Hostile, + /// Attackable but will not start it. Becomes [`RelationHud::Hostile`] once engaged. + Attackable, + /// Not a combatant, and every corpse. + #[default] + Social, + /// Another player with no standing either way. + Player, + /// Another player in your guild or faction. + Allied, +} + +impl RelationHud { + /// Relation ink is actor identity, not chrome, so it never follows the + /// theme: an ally is the same purple in every palette, and in the web + /// client too. + pub fn tint(self, _pal: &Palette) -> [u8; 4] { + match self { + RelationHud::Hostile => [0xd3, 0x3b, 0x32, 255], + RelationHud::Attackable => [0xf1, 0xd0, 0x6b, 255], + RelationHud::Social => [0xf8, 0xf7, 0xf1, 255], + RelationHud::Player => [0x4a, 0xa9, 0xff, 255], + RelationHud::Allied => [0xb0, 0x66, 0xff, 255], + } + } +} + +/// Target plate state chip (attitude/posture/status; MAX 4 — reference cap). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ChipHud { + pub label: String, + pub danger: bool, +} + +pub const TARGET_CHIP_MAX: usize = 4; + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TargetHud { + pub actor_id: String, + pub name: String, + pub relation: RelationHud, + pub health: GaugeHud, + /// Present only when the selected authority frame carries a real action + /// pool (objects and simple creatures expose health alone). + pub action: Option, + /// Present only when the selected authority frame carries a real spirit + /// pool (objects and simple creatures expose health alone). + pub spirit: Option, + /// North-up world-plane distance from the authority-selected player. + pub distance_m: Option, + /// No level field exists in the current authority actor snapshot, so this + /// remains `None` until such a field is streamed. + pub level: Option, + pub alive: bool, + /// DOWN/DEAD stamp text once an observed death holds the frame. + pub stamp: Option<&'static str>, + pub chips: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct GroupMemberHud { + pub actor_id: String, + pub name: String, + pub leader: bool, + pub health_frac: f32, + pub down: bool, + pub link_dead: bool, +} + +/// Member rail cap (reference `MAX_MEMBER_CHIPS`). +pub const GROUP_CHIP_MAX: usize = 5; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadarClass { + Hostile, + Passive, + Civilian, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RadarContactHud { + pub actor_id: String, + /// Raw world-cell deltas in the shared north-up basis (+x east, +y south). + pub dx_cells: f32, + pub dy_cells: f32, + pub class: RadarClass, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RadarWaypointHud { + pub id: u32, + pub dx_cells: f32, + pub dy_cells: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum QueueEntryStateHud { + Queued, + Ready, + /// Fired this frame — pane flashes then retires the row. + Fired, + Rejected, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct QueueEntryHud { + pub entry_id: String, + pub label: String, + pub target_label: String, + pub state: QueueEntryStateHud, + /// Short deny stamp (combat reason copy) for rejected rows. + pub reason: String, +} + +pub const QUEUE_ROW_MAX: usize = 6; + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct InteractHud { + /// Chip copy, e.g. `[F] OPEN DOOR`. + pub label: String, + /// Radial hold fill for loot hold-to-take-all. + pub hold_frac: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct BannerHud { + pub text: String, + pub bad: bool, + /// Wall-clock (monotonic ms) after which the banner stops drawing. + pub until_ms: u64, +} + +/// First-steps guidance rows (progressive disclosure — bounded). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FirstStepRowHud { + pub key: String, + pub text: String, + pub done: bool, +} + +pub const FIRST_STEP_ROW_MAX: usize = 3; + +/// Live values every HUD panel binds to. Built by [`HudState::project`] from +/// the authority store; `Default` is the honest disconnected state. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct HudState { + pub connection: ConnectionHud, + /// Fine-print line: ` · N IN FIELD` or `NO SIGNAL` (prebuilt). + pub fine_text: String, + pub name: String, + pub observer: bool, + pub health: GaugeHud, + pub action: GaugeHud, + pub spirit: GaugeHud, + /// Prebuilt numeric gauge readouts (value or `—`). + pub health_text: String, + pub action_text: String, + pub spirit_text: String, + pub life: LifeHud, + pub sprint: SprintHud, + pub weapon: Option, + pub sheltered: bool, + /// `CAMP COLLAPSE · MM:SS` when the owned camp abandon grace is armed. + pub camp_countdown: Option, + /// `AUTO-SAMPLE · N.NS` while the sample loop is pending. + pub sampler_text: Option, + pub credits: Option, + /// Streamed area id (uppercased for display) — never a hard-coded sector. + pub area_label: String, + pub position: Option<(f32, f32)>, + pub world_seed: i32, + pub biome: crate::world::terrain::Biome, + pub target: Option, + pub group_invite_from: Option, + pub group_members: Vec, + pub radar_contacts: Vec, + pub radar_waypoints: Vec, + pub queue: Vec, + pub repeat_armed: bool, + pub interact: Option, + pub extraction_toast: Option, + pub banner: Option, + /// `CLONE SICKNESS · MM:SS` chip while the post-clone debuff ticks down. + pub clone_sickness: Option, + pub first_steps: Vec, +} + +impl HudState { + /// Rebuild the store-derived portion of the HUD state. Call after applying + /// a network packet (NOT per frame). Fields owned by other systems + /// (interact, banners, first steps, radar waypoints, sprint intent, + /// extraction toast) are left untouched. + pub fn project( + &mut self, + store: &crate::game::authority::AuthorityStore, + player_id: &str, + selected_actor_id: Option<&str>, + ) { + let player = store + .actors + .get(&store.player_actor_id) + .or_else(|| store.actors.get(player_id)); + let player_position = player.map(|actor| (actor.x, actor.y)); + // Standing is judged against the looking player's own organization. + let viewer_org = player + .and_then(|actor| actor.player_organization_id.as_deref()) + .filter(|org| !org.is_empty()); + match player { + Some(a) => { + self.connection = ConnectionHud::Live; + self.name = clean_actor_name(&a.display_name, &a.label, player_id).to_uppercase(); + self.health = GaugeHud { + value: a.vitals.health, + max: a.max_vitals.health, + }; + self.action = GaugeHud { + value: a.vitals.action, + max: a.max_vitals.action, + }; + self.spirit = GaugeHud { + value: a.vitals.spirit, + max: a.max_vitals.spirit, + }; + self.health_text = gauge_text(&self.health); + self.action_text = gauge_text(&self.action); + self.spirit_text = gauge_text(&self.spirit); + self.life = match a.life_state.as_str() { + "downed" => LifeHud::Downed, + "respawning" => LifeHud::Respawning, + _ => LifeHud::Alive, + }; + self.credits = a.credits; + self.area_label = sanitize_text(&a.area_id, 32).to_uppercase(); + self.position = Some((a.x, a.y)); + self.weapon = a.weapon.as_ref().and_then(|w| { + let id = w.weapon_id.as_deref()?; + let reloading = w.reload_remaining_ticks.unwrap_or(0) > 0; + Some(WeaponHud { + label: weapon_display_name(id), + melee: id.contains("sword") || id.contains("melee"), + magazine_size: 0, + loaded_rounds: 0, + rounds_text: if reloading { + "REARMING...".to_string() + } else { + String::new() + }, + reloading, + reload_frac: 0.0, + swing_ready: !reloading, + swing_frac: if reloading { 0.0 } else { 1.0 }, + }) + }); + let count = store.actors.len(); + self.fine_text = format!("LIVE / {count} IN FIELD"); + } + None => { + let live = self.connection == ConnectionHud::Reconnecting; + if !live { + self.connection = ConnectionHud::NoSignal; + } + self.fine_text = if live { + "RELINKING...".to_string() + } else { + "NO SIGNAL".to_string() + }; + self.health = GaugeHud::default(); + self.action = GaugeHud::default(); + self.spirit = GaugeHud::default(); + self.health_text = "--".into(); + self.action_text = "--".into(); + self.spirit_text = "--".into(); + self.weapon = None; + self.position = None; + } + } + + // Target plate from the selection (relation-tinted; chips bounded). + self.target = selected_actor_id.and_then(|sel| { + let a = store.actors.get(sel)?; + let alive = a.life_state == "alive"; + let mut chips: Vec = Vec::with_capacity(TARGET_CHIP_MAX); + if !alive { + // stamp carries death; chips stay for posture/status + } + if a.posture.as_deref() == Some("kneel") && chips.len() < TARGET_CHIP_MAX { + chips.push(ChipHud { + label: "KNEELING".into(), + danger: false, + }); + } + for status in &a.statuses { + if chips.len() >= TARGET_CHIP_MAX { + break; + } + if let Some(label) = status.as_str().map(str::to_string).or_else(|| { + status + .get("id") + .and_then(|v| v.as_str()) + .map(str::to_string) + }) { + chips.push(ChipHud { + label: sanitize_text(&label, 16).to_uppercase(), + danger: false, + }); + } + } + let relation = relation_for(a, player_id, viewer_org); + if relation == RelationHud::Hostile && chips.len() < TARGET_CHIP_MAX { + chips.insert( + 0, + ChipHud { + label: "HOSTILE".into(), + danger: true, + }, + ); + chips.truncate(TARGET_CHIP_MAX); + } + let distance_m = player_position + .map(|(player_x, player_y)| (a.x - player_x).hypot(a.y - player_y)) + .filter(|distance| distance.is_finite()); + Some(TargetHud { + actor_id: sel.to_string(), + name: clean_actor_name(&a.display_name, &a.label, sel).to_uppercase(), + relation, + health: GaugeHud { + value: a.vitals.health, + max: a.max_vitals.health, + }, + action: (a.max_vitals.action > 0.0).then_some(GaugeHud { + value: a.vitals.action, + max: a.max_vitals.action, + }), + spirit: (a.max_vitals.spirit > 0.0).then_some(GaugeHud { + value: a.vitals.spirit, + max: a.max_vitals.spirit, + }), + distance_m, + level: None, + alive, + stamp: match a.life_state.as_str() { + "downed" => Some("DOWN"), + "respawning" | "dead" => Some("DEAD"), + _ => None, + }, + chips, + }) + }); + + // Group HUD from the streamed groups section (owning-session channel). + self.group_invite_from = None; + self.group_members.clear(); + if let Some(groups) = &store.groups { + if let Some(invite) = groups.get("pendingInvite") { + self.group_invite_from = invite + .get("fromName") + .or_else(|| invite.get("from")) + .and_then(|v| v.as_str()) + .map(|s| sanitize_text(s, 32).to_uppercase()); + } + if let Some(members) = groups.get("members").and_then(|v| v.as_array()) { + for m in members { + if self.group_members.len() > GROUP_CHIP_MAX { + break; + } + let id = m.get("actorId").and_then(|v| v.as_str()).unwrap_or(""); + if id == player_id || id == store.player_actor_id { + continue; // self stays on the status plate + } + let name = m + .get("name") + .and_then(|v| v.as_str()) + .map(|s| sanitize_text(s, 24).to_uppercase()) + .unwrap_or_else(|| id.to_string()); + let health = m.get("healthFrac").and_then(|v| v.as_f64()).unwrap_or(1.0); + self.group_members.push(GroupMemberHud { + actor_id: id.to_string(), + name, + leader: m.get("leader").and_then(|v| v.as_bool()).unwrap_or(false), + health_frac: health as f32, + down: m.get("down").and_then(|v| v.as_bool()).unwrap_or(false), + link_dead: m.get("linkDead").and_then(|v| v.as_bool()).unwrap_or(false), + }); + } + } + } + + // Radar contacts: relation-filtered live actors around the player. + self.radar_contacts.clear(); + if let Some((px, py)) = self.position { + for (id, a) in store.render_actors() { + if id == player_id || *id == store.player_actor_id { + continue; + } + let dx = a.x - px; + let dy = a.y - py; + if dx * dx + dy * dy > radar::RADIUS_CELLS * radar::RADIUS_CELLS * 4.0 { + continue; // beyond twice the scope — never plotted, skip early + } + let class = match relation_for(a, player_id, viewer_org) { + RelationHud::Hostile => RadarClass::Hostile, + RelationHud::Attackable => RadarClass::Passive, + RelationHud::Social | RelationHud::Player | RelationHud::Allied => { + RadarClass::Civilian + } + }; + self.radar_contacts.push(RadarContactHud { + actor_id: id.clone(), + dx_cells: dx, + dy_cells: dy, + class, + }); + } + } + } +} + +fn gauge_text(g: &GaugeHud) -> String { + if g.max > 0.0 { + format!("{}", g.value.max(0.0).round() as i64) + } else { + "--".to_string() + } +} + +/// Whether an actor fights at all, mirroring `actorRoleProfiles` in +/// `client/src/slice-core/npcSystem.ts`. Roles the table does not name are +/// social, which is the safe read: an unknown actor is not painted as a threat. +fn is_combat_role(role: Option<&str>) -> bool { + matches!(role, Some("creature") | Some("range_guard")) + || role.is_some_and(|r| r.starts_with("skirmisher")) +} + +fn is_player_role(role: Option<&str>) -> bool { + matches!(role, Some("player") | Some("agent_player")) +} + +/// Relation classification from streamed actor fields. +/// +/// `viewer_org` is the looking player's guild/faction id; without it no actor +/// can be an ally, which is the correct fallback rather than guessing. +pub fn relation_for( + a: &successor_client_proto::packets::GameActorSnapshot, + _player_id: &str, + viewer_org: Option<&str>, +) -> RelationHud { + // A corpse is a corpse whatever it was in life. + if matches!(a.life_state.as_str(), "dead" | "downed" | "respawning") { + return RelationHud::Social; + } + + let role = a.role.as_deref(); + if is_player_role(role) { + if a.pvp_status.as_deref() == Some("hostile") { + return RelationHud::Hostile; + } + let allied = match (viewer_org, a.player_organization_id.as_deref()) { + (Some(mine), Some(theirs)) => !mine.is_empty() && mine == theirs, + _ => false, + }; + return if allied { + RelationHud::Allied + } else { + RelationHud::Player + }; + } + + if !is_combat_role(role) { + return RelationHud::Social; + } + + // A passive only reads yellow until it has been drawn into a fight; once + // engaged it is as dangerous as anything that opened on sight. + let aggressive = a.will_auto_aggro.unwrap_or(false); + let engaged = a.in_combat.unwrap_or(false); + if aggressive || engaged { + RelationHud::Hostile + } else { + RelationHud::Attackable + } +} + +/// Weapon id → stenciled field designation (port of `theme.weaponDisplayName`). +pub fn weapon_display_name(weapon_id: &str) -> String { + let cleaned = weapon_id.trim().trim_start_matches("weapon_"); + let mut out = String::with_capacity(cleaned.len()); + for ch in cleaned.chars() { + if ch == '_' || ch == '-' { + out.push(' '); + } else { + out.push(ch.to_ascii_uppercase()); + } + } + if out.is_empty() { + "SIDEARM".to_string() + } else { + out + } +} + +// ── HUD intents ───────────────────────────────────────────────────────────── + +/// Intents the HUD emits; the connected host routes them through the public +/// gameplay action path (`game::actions`) or the window manager. The HUD +/// never mutates authority-owned state. +#[derive(Clone, Debug, PartialEq)] +pub enum HudAction { + ToggleWindow(&'static str), + OpenWindow(&'static str), + /// A toolbar verb slot fired (registry action id). + RunVerb(&'static str), + /// A toolbar item slot fired (item catalog id). + UseToolbarItem(String), + ToggleSprint, + GroupAccept, + GroupDecline, + CloneRespawn, + RadarSelect(String), + RadarMove { + dx_cells: f32, + dy_cells: f32, + }, + QueueCancel(String), + CycleTheme, + /// Toolbar layout/binds changed — host persists the doc (Local scope). + ToolbarChanged, +} + +/// Stable host id for the player-status plate. +pub const PLAYER_STATUS_ID: &str = "hud.player-status"; +/// Stable host id for the current-target plate. +pub const TARGET_STATUS_ID: &str = "hud.target-status"; +/// Stable host id for the wielded-weapon readout. +pub const WEAPON_STATUS_ID: &str = "hud.weapon-status"; +/// Stable host id for the group roster. +pub const GROUP_ROSTER_ID: &str = "hud.group-roster"; +/// Stable host id for the twelve-slot command bar. +pub const COMMAND_BAR_ID: &str = "hud.command-bar"; +/// Stable host id for the persistent chat console. +pub const CHAT_CONSOLE_ID: &str = "hud.chat-console"; +/// Stable host id for the ability queue. +pub const ABILITY_QUEUE_ID: &str = "hud.ability-queue"; +/// Stable host id for the compact notifications/status strip. +pub const STATUS_STRIP_ID: &str = "hud.notifications"; +/// Stable host id for the managed ground radar. +pub const GROUND_RADAR_ID: &str = "ground-radar"; + +/// Caption carried by the manager chrome when a host elects to show it. +pub const GROUND_RADAR_TITLE: &str = "RADAR"; +/// Atlas icon used by the radar's workspace icon. +pub const GROUND_RADAR_ICON: &str = "survey"; +/// Measured `SwgCuiGroundRadar` workspace lane: a fixed 32×32 icon at (0,64). +pub const GROUND_RADAR_ICON_SLOT: (f32, f32) = (0.0, 64.0); + +/// Largest viewport in the supported HUD matrix. Hosts that know their first +/// framebuffer should prefer [`register_hud_surfaces_at`] so first-run defaults +/// come directly from that framebuffer's [`layout::compute`] result. +const HUD_REGISTRATION_VIEWPORT: (f32, f32) = (1600.0, 1200.0); + +/// A persistent, manager-owned HUD pane. `default_rect` resolves its first-run +/// rect from [`layout::compute`]; it is never consulted after registration or a +/// persisted layout restore. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct HudSurface { + pub id: &'static str, + pub title: &'static str, + pub icon: &'static str, + pub min_size: [f32; 2], + kind: HudSurfaceKind, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HudSurfaceKind { + PlayerStatus, + WeaponStatus, + GroupRoster, + TargetStatus, + CommandBar, + ChatConsole, + AbilityQueue, + StatusStrip, + GroundRadar, +} + +impl HudSurface { + /// First-run content rect for this surface at a particular framebuffer. + pub fn default_rect(self, layout: layout::HudLayout) -> [f32; 4] { + match self.kind { + HudSurfaceKind::PlayerStatus => layout.plate, + HudSurfaceKind::WeaponStatus => layout.weapon, + HudSurfaceKind::GroupRoster => layout.group, + HudSurfaceKind::TargetStatus => layout.target, + HudSurfaceKind::CommandBar => layout.bar, + HudSurfaceKind::ChatConsole => layout.chat, + HudSurfaceKind::AbilityQueue => layout.queue, + HudSurfaceKind::StatusStrip => layout.strip, + HudSurfaceKind::GroundRadar => layout.radar, + } + } + + /// Whether first run opens this pane. + /// + /// The status strip is the one HUD pane that tells the player nothing they + /// are not already looking at - shard state and the zone name, parked in + /// the bottom-right corner over the world. It stays registered and stays + /// reachable, it just does not claim a corner before anyone asks for it. + pub fn opens_on_first_run(self) -> bool { + !matches!(self.kind, HudSurfaceKind::StatusStrip) + } +} + +/// Number of persistent HUD workspace panes. +pub const HUD_SURFACE_COUNT: usize = 9; + +/// Every persistent HUD surface. These are workspace windows, but normal play +/// leaves them chromeless and locked so no frame intercepts gameplay input. +pub const HUD_SURFACES: [HudSurface; HUD_SURFACE_COUNT] = [ + HudSurface { + id: PLAYER_STATUS_ID, + title: "PLAYER STATUS", + icon: "character", + min_size: [layout::PLATE_MIN_W, layout::PLATE_MIN_H], + kind: HudSurfaceKind::PlayerStatus, + }, + HudSurface { + id: WEAPON_STATUS_ID, + title: "WEAPON", + icon: "weapon", + min_size: [layout::WEAPON_MIN_W, layout::WEAPON_MIN_H], + kind: HudSurfaceKind::WeaponStatus, + }, + HudSurface { + id: GROUP_ROSTER_ID, + title: "GROUP", + icon: "association", + min_size: [layout::PLATE_MIN_W, plate::GROUP_CHIP_H], + kind: HudSurfaceKind::GroupRoster, + }, + HudSurface { + id: TARGET_STATUS_ID, + title: "TARGET STATUS", + icon: "crosshair", + min_size: [layout::TARGET_MIN_W, layout::TARGET_MIN_H], + kind: HudSurfaceKind::TargetStatus, + }, + HudSurface { + id: COMMAND_BAR_ID, + title: "COMMAND BAR", + icon: "actions", + min_size: [layout::BAR_MIN_W, layout::BAR_MIN_H], + kind: HudSurfaceKind::CommandBar, + }, + HudSurface { + id: CHAT_CONSOLE_ID, + title: "CHAT CONSOLE", + icon: "converse", + min_size: [layout::CHAT_MIN_W, layout::CHAT_MIN_H], + kind: HudSurfaceKind::ChatConsole, + }, + HudSurface { + id: ABILITY_QUEUE_ID, + title: "ABILITY QUEUE", + icon: "actions", + min_size: [layout::QUEUE_MIN_W, layout::QUEUE_MIN_H], + kind: HudSurfaceKind::AbilityQueue, + }, + HudSurface { + id: STATUS_STRIP_ID, + title: "NOTIFICATIONS", + icon: "options", + min_size: [layout::STRIP_MIN_W, layout::STRIP_MIN_H], + kind: HudSurfaceKind::StatusStrip, + }, + HudSurface { + id: GROUND_RADAR_ID, + title: GROUND_RADAR_TITLE, + icon: GROUND_RADAR_ICON, + min_size: [layout::RADAR_LANE_W, layout::RADAR_LANE_H], + kind: HudSurfaceKind::GroundRadar, + }, +]; + +/// Registry lookup without allocating. The stable descriptor is also the +/// source of truth for whether a manager frame is a HUD pane. +pub fn hud_surface(id: &str) -> Option<&'static HudSurface> { + HUD_SURFACES.iter().find(|surface| surface.id == id) +} + +/// Whether `id` names one of the persistent HUD workspace panes. +pub fn is_hud_surface(id: &str) -> bool { + hud_surface(id).is_some() +} + +/// Apply layout-derived first-run bounds to every registered HUD pane. +/// +/// Hosts that restored any user geometry should call +/// [`apply_missing_hud_surface_defaults`] instead, so an older layout document +/// that lacks a newly added pane does not reset the panes it did contain. +pub fn apply_hud_surface_defaults( + manager: &mut successor_engine_render::window::WindowManager, + viewport: (f32, f32), +) { + apply_missing_hud_surface_defaults(manager, viewport, &[true; HUD_SURFACE_COUNT]); +} + +/// Apply layout-derived first-run bounds only for pane slots absent from a +/// persisted workspace document. +pub fn apply_missing_hud_surface_defaults( + manager: &mut successor_engine_render::window::WindowManager, + viewport: (f32, f32), + missing: &[bool; HUD_SURFACE_COUNT], +) { + let defaults = layout::compute(viewport.0, viewport.1); + for (surface, missing) in HUD_SURFACES.iter().zip(missing) { + if *missing { + let _ = manager.set_rect(surface.id, surface.default_rect(defaults)); + } + } +} + +fn register_hud_surface( + manager: &mut successor_engine_render::window::WindowManager, + surface: HudSurface, + icon: Option<(u32, u32)>, + defaults: layout::HudLayout, +) { + let bounds = surface.default_rect(defaults); + manager.register( + surface.id, + surface.title, + icon, + bounds, + surface.min_size[0], + surface.min_size[1], + ); + // HUD panes own their visual content. At rest they have neither workspace + // chrome nor pointer capture; right-clicking a pane unlocks its manager + // move/resize gestures through `set_hud_surface_locked`. + manager.set_chrome(surface.id, false); + manager.set_interactive(surface.id, false); + if surface.id == GROUND_RADAR_ID { + manager.set_icon_slot(surface.id, Some(GROUND_RADAR_ICON_SLOT)); + } + if surface.opens_on_first_run() { + manager.open(surface.id); + } +} + +fn register_hud_surfaces_with_at( + manager: &mut successor_engine_render::window::WindowManager, + viewport: (f32, f32), + mut icon_for: F, +) where + F: FnMut(&str) -> Option<(u32, u32)>, +{ + let defaults = layout::compute(viewport.0, viewport.1); + for surface in HUD_SURFACES { + register_hud_surface(manager, surface, icon_for(surface.icon), defaults); + } +} + +/// Register every persistent HUD pane at an explicit framebuffer. This is the +/// host entry point for first-run registration and deterministic layout tests. +pub fn register_hud_surfaces_at( + manager: &mut successor_engine_render::window::WindowManager, + icons: &Icons, + viewport: (f32, f32), +) { + register_hud_surfaces_with_at(manager, viewport, |icon| icons.cell(icon)); +} + +/// Register every persistent HUD pane using the supported-max fallback. A host +/// should follow this with [`apply_hud_surface_defaults`] once it knows the +/// first framebuffer, unless it restores persisted geometry first. +pub fn register_hud_surfaces( + manager: &mut successor_engine_render::window::WindowManager, + icons: &Icons, +) { + register_hud_surfaces_at(manager, icons, HUD_REGISTRATION_VIEWPORT); +} + +/// Register only the ground radar for hosts that do not own the complete HUD +/// registry yet. New hosts should use [`register_hud_surfaces_at`]. +pub(crate) fn register_ground_radar_at( + manager: &mut successor_engine_render::window::WindowManager, + icon: Option<(u32, u32)>, + viewport: (f32, f32), +) { + let defaults = layout::compute(viewport.0, viewport.1); + let surface = *hud_surface(GROUND_RADAR_ID).expect("ground radar is registered"); + register_hud_surface(manager, surface, icon, defaults); +} + +/// Register the one managed radar frame, including its workspace icon slot. +pub fn register_ground_radar( + manager: &mut successor_engine_render::window::WindowManager, + icon: Option<(u32, u32)>, +) { + register_ground_radar_at(manager, icon, HUD_REGISTRATION_VIEWPORT); +} + +/// Toggle the per-pane layout lock for the frontmost HUD surface under a +/// right-click. Locked panes are visual-only; unlocked panes opt into the +/// manager's body move and eight-edge resize gestures. +pub fn toggle_hud_surface_lock_at( + manager: &mut successor_engine_render::window::WindowManager, + mouse_x: f32, + mouse_y: f32, +) -> Option<&'static str> { + let order = manager.z_order(); + for index in order.into_iter().rev() { + let id = manager.window_id(index); + if !manager.is_open(id) || manager.is_iconified(id) { + continue; + } + let Some(rect) = manager.rect(id) else { + continue; + }; + if !successor_engine_render::ui::UiBuilder::hit( + rect[0], rect[1], rect[2], rect[3], mouse_x, mouse_y, + ) { + continue; + } + let surface = hud_surface(id)?; + manager.set_interactive(surface.id, !manager.is_interactive(surface.id)); + return Some(surface.id); + } + None +} + +/// Restore a persistent HUD pane's lock bit. `locked` is stored alongside its +/// manager geometry by the connected host. +pub fn set_hud_surface_locked( + manager: &mut successor_engine_render::window::WindowManager, + id: &str, + locked: bool, +) -> bool { + let Some(surface) = hud_surface(id) else { + return false; + }; + manager.set_interactive(surface.id, !locked); + true +} + +/// Draw the only layout-edit affordance. It is deliberately hover-only: normal +/// play has no rail, perimeter, close control, or pointer interception. +pub fn draw_hud_layout_affordance( + ui: &mut UiBuilder, + manager: &successor_engine_render::window::WindowManager, + id: &str, + palette: &Palette, +) { + if !manager.is_interactive(id) || !manager.is_open(id) { + return; + } + let Some([x, y, w, h]) = manager.content_rect(id) else { + return; + }; + let (mouse_x, mouse_y) = ui.mouse(); + if !UiBuilder::hit(x, y, w, h, mouse_x, mouse_y) { + return; + } + let mut tint = palette.accent; + tint[3] = 220; + ui.border(x, y, w, h, 1.0, tint); + // The two-pixel top stroke is the revealed body-drag surface; every edge + // and corner remains resize-active through WindowManager::update_at. + ui.rect(x, y, w, 2.0, tint); +} + +// ── Window registry ───────────────────────────────────────────────────────── + +/// Dock-visible windows: `(id, title, icon id, hotkey code)`. Generated from +/// [`crate::windows::spec::SURFACES`] so the registry and the surface specs +/// cannot drift. +pub const PERMANENT_WINDOWS: [(&str, &str, &str, &str); 8] = permanent_windows(); + +/// Context windows: opened from their terminal/target/roster/item route, never +/// from the dock. `(id, title, icon id)`. +pub const CONTEXT_WINDOWS: [(&str, &str, &str); 13] = context_windows(); + +/// Every registered surface, for the standalone UI demo (`--demo ui`). The +/// union of the permanent and context sets by construction. +pub const DEMO_WINDOWS: [(&str, &str, &str); 21] = demo_windows(); + +const fn permanent_windows() -> [(&'static str, &'static str, &'static str, &'static str); 8] { + let mut out = [("", "", "", ""); 8]; + let mut index = 0; + let mut slot = 0; + while index < crate::windows::spec::SURFACES.len() { + let surface = &crate::windows::spec::SURFACES[index]; + if surface.dock { + out[slot] = (surface.id, surface.title, surface.icon, surface.hotkey); + slot += 1; + } + index += 1; + } + out +} + +const fn context_windows() -> [(&'static str, &'static str, &'static str); 13] { + let mut out = [("", "", ""); 13]; + let mut index = 0; + let mut slot = 0; + while index < crate::windows::spec::SURFACES.len() { + let surface = &crate::windows::spec::SURFACES[index]; + if !surface.dock { + out[slot] = (surface.id, surface.title, surface.icon); + slot += 1; + } + index += 1; + } + out +} + +const fn demo_windows() -> [(&'static str, &'static str, &'static str); 21] { + let mut out = [("", "", ""); 21]; + let mut index = 0; + while index < crate::windows::spec::SURFACES.len() { + let surface = &crate::windows::spec::SURFACES[index]; + out[index] = (surface.id, surface.title, surface.icon); + index += 1; + } + out +} + +/// Window a dock hotkey code opens. The registry is the single source of truth +/// for the advertised binds, so the host's key routing and the dock badges +/// cannot disagree. +pub fn window_for_code(code: &str) -> Option<&'static str> { + if code.is_empty() { + return None; + } + PERMANENT_WINDOWS + .iter() + .find(|(_, _, _, hotkey)| *hotkey == code) + .map(|(id, _, _, _)| *id) +} + +/// Short key glyph for a `KeyboardEvent.code`-style bind (dock badges, +/// toolbar hotkey corners). Port of `icons.hotkeyGlyph`. +pub fn code_glyph(code: &str) -> &str { + match code { + "Digit1" => "1", + "Digit2" => "2", + "Digit3" => "3", + "Digit4" => "4", + "Digit5" => "5", + "Digit6" => "6", + "Digit7" => "7", + "Digit8" => "8", + "Digit9" => "9", + "Digit0" => "0", + "Minus" => "-", + "Equal" => "=", + "Semicolon" => ";", + "Comma" => ",", + "Period" => ".", + "Slash" => "/", + "Tab" => "TAB", + "Escape" => "ESC", + "" => "", + _ => code.strip_prefix("Key").unwrap_or(code), + } +} + +// ── Frame composition ─────────────────────────────────────────────────────── + +/// Everything `build_hud` needs beyond the projection: mutable toolbar/chat +/// state, theme palette, monotonic time and pointer capture. +pub struct HudFrame<'a> { + pub state: &'a HudState, + pub toolbar: &'a mut toolbar::Toolbar, + /// The connected host supplies this; standalone visual demos have no chat + /// transport and pass `None`. + pub chat: Option<(&'a mut crate::game::chat_net::ChatClient, &'a mut TextField)>, + pub palette: Palette, + pub now_ms: u64, + /// Pointer already captured by a window/overlay — HUD stays visual-only. + pub captured: bool, + /// Right-button pressed edge for toolbar slot clear. The host consumes a + /// HUD-pane layout-lock click before this draw path runs. + pub right_pressed: bool, +} + +fn hud_content_rect( + manager: &successor_engine_render::window::WindowManager, + id: &str, +) -> Option<[f32; 4]> { + (manager.is_open(id) && !manager.is_iconified(id)) + .then(|| manager.content_rect(id)) + .flatten() +} + +/// Longest UTF-8 prefix that leaves room for an ellipsis when it is needed. +/// Status text is authority-provided, so clipping must never split a codepoint. +fn clip_status_text<'a>(ui: &UiBuilder, text: &'a str, px: f32, max_w: f32) -> (&'a str, bool) { + if ui.measure_text(text, px) <= max_w { + return (text, false); + } + let budget = (max_w - ui.measure_text("...", px)).max(0.0); + let mut end = 0; + let mut width = 0.0; + for (offset, ch) in text.char_indices() { + let advance = ui.measure_text(&text[offset..offset + ch.len_utf8()], px); + if width + advance > budget { + break; + } + width += advance; + end = offset + ch.len_utf8(); + } + (&text[..end], true) +} + +/// Compact notification/status region. It deliberately has one content well +/// and no nested card or divider, matching the sparse HUD grammar. +fn draw_status_strip(ui: &mut UiBuilder, palette: &Palette, state: &HudState, rect: [f32; 4]) { + let [x, y, w, h] = rect; + let mut backing = palette.bg_panel; + backing[3] = 220; + ui.rect(x, y, w, h, backing); + + let primary = if state.fine_text.is_empty() { + "NO SIGNAL" + } else { + state.fine_text.as_str() + }; + let px = 1.35; + let text_y = y + (h - 14.0).max(0.0) * 0.5; + let primary_tint = if state.connection == ConnectionHud::Live { + palette.ink_dim + } else { + palette.danger + }; + let area_w = ui.measure_text(&state.area_label, px); + let show_area = !state.area_label.is_empty() && area_w + ui.measure_text("...", px) + 24.0 <= w; + let primary_room = (w - 16.0 - if show_area { area_w + 8.0 } else { 0.0 }).max(0.0); + let (primary, clipped) = clip_status_text(ui, primary, px, primary_room); + let primary_end = ui.text(primary, x + 8.0, text_y, px, primary_tint); + if clipped { + ui.text("...", primary_end, text_y, px, primary_tint); + } + if show_area { + ui.text( + &state.area_label, + x + w - area_w - 8.0, + text_y, + px, + palette.ink, + ); + } +} + +/// Build the connected HUD. Persistent panes draw into their live, +/// manager-owned content rects; `layout::compute` is never consulted here. +/// The host sets UI input enabled only when `frame.captured` is false before +/// calling this function, so an unlocked pane cannot also activate HUD content. +pub fn build_hud( + ui: &mut UiBuilder, + icons: &Icons, + frame: &mut HudFrame, + manager: &successor_engine_render::window::WindowManager, + w: u32, + h: u32, + out: &mut Vec, +) { + let sw = w as f32; + let sh = h as f32; + let pal = frame.palette; + let st = frame.state; + let player_rect = hud_content_rect(manager, PLAYER_STATUS_ID); + let target_rect = hud_content_rect(manager, TARGET_STATUS_ID); + let command_rect = hud_content_rect(manager, COMMAND_BAR_ID); + let chat_rect = hud_content_rect(manager, CHAT_CONSOLE_ID); + let queue_rect = hud_content_rect(manager, ABILITY_QUEUE_ID); + let strip_rect = hud_content_rect(manager, STATUS_STRIP_ID); + let radar_rect = hud_content_rect(manager, GROUND_RADAR_ID); + + if let Some(rect) = player_rect { + plate::draw_status_plate(ui, &pal, st, rect, out); + } + if let (Some(target), Some(rect)) = (&st.target, target_rect) { + plate::draw_target_plate(ui, &pal, target, rect); + } + if let Some(rect) = queue_rect { + plate::draw_queue(ui, &pal, st, rect, out); + } + if let Some(rect) = strip_rect { + draw_status_strip(ui, &pal, st, rect); + } + if let Some(rect) = radar_rect { + radar::draw_radar(ui, &pal, st, rect, frame.captured, out); + } + if let (Some(rect), Some((chat_client, chat_input))) = (chat_rect, frame.chat.as_mut()) { + crate::game::chat_ui::draw_chat_pane( + ui, + chat_client, + chat_input, + rect[0], + rect[1], + rect[2], + rect[3], + ); + } + + // Group roster and weapon readout are managed panes of the same grammar as + // the player plate: each paints nothing when it has no content, so an + // unarmed solo player sees one tight status plate and no empty furniture. + if let Some(rect) = hud_content_rect(manager, WEAPON_STATUS_ID) { + plate::draw_weapon_plate(ui, &pal, st, rect); + } + let group_rail = + hud_content_rect(manager, GROUP_ROSTER_ID).unwrap_or_else(|| layout::compute(sw, sh).group); + plate::draw_group(ui, &pal, st, sw, group_rail, out); + + // Interact chip and toasts stay tied to the live chat pane, so they follow + // a player-moved console without becoming persistent surfaces themselves. + let chat_anchor = chat_rect.unwrap_or([sw * 0.5, sh - 32.0, 0.0, 0.0]); + let chip_x = chat_anchor[0] + chat_anchor[2] * 0.5; + let chip_y = chat_anchor[1] - 26.0; + if let Some(chip) = &st.interact { + plate::draw_interact_chip(ui, &pal, chip, chip_x, chip_y); + } + plate::draw_toasts(ui, &pal, st, frame.now_ms, chip_x, chip_y - 24.0); + + // First-steps guidance follows the live player plate rather than a stale + // default origin. + let player_anchor = player_rect.unwrap_or([layout::MARGIN, 0.0, 0.0, 0.0]); + plate::draw_first_steps( + ui, + &pal, + st, + player_anchor[0] + layout::MARGIN, + player_anchor[1] + player_anchor[3] + 24.0, + ); + + // No fixed screen-centre crosshair. The original client has no such + // reticle on the ground HUD: aim lives on the pointer, which switches to + // `ui_cursor_attack` over a valid target. See `engine_render::cursor`. + + // The fixed Successor launcher rail is not a persistent pane. The command + // bar itself, however, is manager-owned and receives its live rect above. + let dock_h = (sh * 0.5).min(360.0); + let dock = [ + sw - layout::MARGIN - layout::DOCK_BTN, + (sh - dock_h) * 0.5, + layout::DOCK_BTN, + dock_h, + ]; + toolbar::draw_dock(ui, icons, &pal, frame.toolbar, dock, frame.captured, out); + if let Some(rect) = command_rect { + toolbar::draw_toolbar( + ui, + icons, + &pal, + frame.toolbar, + st, + rect, + frame.captured, + frame.right_pressed, + frame.now_ms, + out, + ); + } + + // Unlocked panes get one hover-revealed functional outline. At rest this + // loop emits nothing, preserving the original HUD's chromeless appearance. + for surface in HUD_SURFACES { + draw_hud_layout_affordance(ui, manager, surface.id, &pal); + } + + // Death / clone overlay draws over everything but keeps chat usable + // (backdrop is visual-only; only the panel takes clicks). + plate::draw_death_overlay(ui, &pal, st, sw, sh, out); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn actor(role: &str) -> successor_client_proto::packets::GameActorSnapshot { + successor_client_proto::packets::GameActorSnapshot { + role: Some(role.to_string()), + life_state: "alive".to_string(), + ..Default::default() + } + } + + /// Owner ruling 2026-08-04. Each arm is a distinct in-world read, so they + /// are asserted individually rather than as one table. + #[test] + fn relations_follow_the_owner_ruling() { + // An NPC that does not fight, and anything dead, is not a threat read. + assert_eq!( + relation_for(&actor("profession_trainer"), "me", None), + RelationHud::Social + ); + let mut corpse = actor("skirmisher"); + corpse.will_auto_aggro = Some(true); + corpse.life_state = "dead".to_string(); + assert_eq!(relation_for(&corpse, "me", None), RelationHud::Social); + + // A passive attackable is yellow until something starts a fight. + let mut passive = actor("creature"); + passive.will_auto_aggro = Some(false); + assert_eq!(relation_for(&passive, "me", None), RelationHud::Attackable); + passive.in_combat = Some(true); + assert_eq!( + relation_for(&passive, "me", None), + RelationHud::Hostile, + "a provoked passive must escalate to the hostile read" + ); + + // Anything that opens on sight is hostile from the start. + let mut aggressive = actor("skirmisher_assault"); + aggressive.will_auto_aggro = Some(true); + assert_eq!(relation_for(&aggressive, "me", None), RelationHud::Hostile); + } + + #[test] + fn player_standing_reads_pvp_then_organization() { + let neutral = actor("player"); + assert_eq!(relation_for(&neutral, "me", None), RelationHud::Player); + + let mut ally = actor("player"); + ally.player_organization_id = Some("guild-a".to_string()); + assert_eq!( + relation_for(&ally, "me", Some("guild-a")), + RelationHud::Allied + ); + assert_eq!( + relation_for(&ally, "me", Some("guild-b")), + RelationHud::Player, + "a different organization is not an ally" + ); + assert_eq!( + relation_for(&ally, "me", None), + RelationHud::Player, + "an unaffiliated viewer has no allies" + ); + + // PVP outranks shared colours: an open enemy reads red even in-guild. + let mut enemy = ally.clone(); + enemy.pvp_status = Some("hostile".to_string()); + assert_eq!( + relation_for(&enemy, "me", Some("guild-a")), + RelationHud::Hostile + ); + } + + #[test] + fn default_state_is_disconnected_and_sample_free() { + let st = HudState::default(); + assert_eq!(st.connection, ConnectionHud::NoSignal); + assert!(st.name.is_empty()); + assert!(st.area_label.is_empty()); + assert!(st.weapon.is_none()); + assert!(st.target.is_none()); + assert!(st.radar_contacts.is_empty()); + assert_eq!(st.health.max, 0.0); + assert_eq!(st.spirit.max, 0.0); + } + + #[test] + fn ground_radar_registration_uses_the_managed_layout_frame() { + let mut manager = successor_engine_render::window::WindowManager::new(); + register_ground_radar_at(&mut manager, None, (1024.0, 768.0)); + let layout = layout::compute(1024.0, 768.0); + assert_eq!(manager.rect(GROUND_RADAR_ID), Some(layout.radar)); + // HUD panes are chromeless, so the pane owns its whole rect and the + // scope no longer sits inside workspace rails. + assert!(!manager.has_chrome(GROUND_RADAR_ID)); + assert!(!manager.is_interactive(GROUND_RADAR_ID)); + assert_eq!(manager.content_rect(GROUND_RADAR_ID), Some(layout.radar)); + assert!(manager.is_open(GROUND_RADAR_ID)); + assert_eq!( + manager.icon_rect(GROUND_RADAR_ID), + Some([ + GROUND_RADAR_ICON_SLOT.0, + GROUND_RADAR_ICON_SLOT.1, + successor_engine_render::window::ICON_SLOT, + successor_engine_render::window::ICON_SLOT, + ]) + ); + assert!(manager.iconify(GROUND_RADAR_ID)); + assert!(manager.is_iconified(GROUND_RADAR_ID)); + } + + const TEST_ATLAS: AtlasMeta = AtlasMeta { + cell: 32, + cols: 8, + width: 256, + height: 160, + }; + + #[test] + fn every_hud_registration_default_matches_layout_compute() { + for viewport in [ + (800.0, 600.0), + (1024.0, 768.0), + (1280.0, 1024.0), + (1600.0, 1200.0), + ] { + let mut manager = successor_engine_render::window::WindowManager::new(); + register_hud_surfaces_with_at(&mut manager, viewport, |_| None); + let mut ui = UiBuilder::new(TEST_ATLAS); + ui.set_input(0.0, 0.0, false); + ui.begin(viewport.0 as u32, viewport.1 as u32); + manager.update(&ui, viewport.0 as u32, viewport.1 as u32); + let defaults = layout::compute(viewport.0, viewport.1); + for surface in HUD_SURFACES { + let expected = surface.default_rect(defaults); + assert_eq!( + manager.rect(surface.id), + Some(expected), + "{} default drifted at {}x{}", + surface.id, + viewport.0, + viewport.1 + ); + assert_eq!(manager.content_rect(surface.id), Some(expected)); + assert_eq!( + manager.is_open(surface.id), + surface.opens_on_first_run(), + "{} first-run visibility drifted", + surface.id + ); + assert!(!manager.has_chrome(surface.id)); + assert!(!manager.is_interactive(surface.id)); + } + } + } + + #[test] + fn locked_hud_needs_layout_edit_before_manager_captures_or_moves() { + let mut manager = successor_engine_render::window::WindowManager::new(); + register_hud_surfaces_with_at(&mut manager, (1024.0, 768.0), |_| None); + let mut ui = UiBuilder::new(TEST_ATLAS); + let original = manager.rect(PLAYER_STATUS_ID).unwrap(); + + // Normal play: clicking the status pane is gameplay-transparent. + ui.set_input(100.0, 30.0, false); + ui.begin(1024, 768); + manager.update(&ui, 1024, 768); + ui.set_input(100.0, 30.0, true); + ui.begin(1024, 768); + manager.update(&ui, 1024, 768); + assert!(!manager.pointer_captured()); + assert_eq!(manager.rect(PLAYER_STATUS_ID), Some(original)); + + // A right-click context toggle unlocks exactly this pane. The manager + // then owns its body drag and every edge/corner resize gesture. + assert_eq!( + toggle_hud_surface_lock_at(&mut manager, 100.0, 30.0), + Some(PLAYER_STATUS_ID) + ); + assert!(manager.is_interactive(PLAYER_STATUS_ID)); + ui.set_input(100.0, 30.0, false); + ui.begin(1024, 768); + manager.update(&ui, 1024, 768); + ui.set_input(100.0, 30.0, true); + ui.begin(1024, 768); + manager.update(&ui, 1024, 768); + assert!(manager.pointer_captured()); + ui.set_input(140.0, 60.0, true); + ui.begin(1024, 768); + manager.update(&ui, 1024, 768); + let moved = manager.rect(PLAYER_STATUS_ID).unwrap(); + assert_eq!(moved[0], original[0] + 40.0); + assert_eq!(moved[1], original[1] + 30.0); + ui.set_input(140.0, 60.0, false); + ui.begin(1024, 768); + manager.update(&ui, 1024, 768); + + let resize_x = moved[0] + moved[2] - 2.0; + let resize_y = moved[1] + moved[3] - 2.0; + ui.set_input(resize_x, resize_y, true); + ui.begin(1024, 768); + manager.update(&ui, 1024, 768); + ui.set_input(resize_x + 36.0, resize_y + 24.0, true); + ui.begin(1024, 768); + manager.update(&ui, 1024, 768); + let resized = manager.rect(PLAYER_STATUS_ID).unwrap(); + assert_eq!(resized[2], moved[2] + 36.0); + assert_eq!(resized[3], moved[3] + 24.0); + } + + #[test] + fn hud_workspace_state_round_trip_keeps_geometry_open_order_and_lock() { + let mut previous = successor_engine_render::window::WindowManager::new(); + register_hud_surfaces_with_at(&mut previous, (1280.0, 1024.0), |_| None); + assert!(set_hud_surface_locked( + &mut previous, + PLAYER_STATUS_ID, + false + )); + assert!(previous.set_rect(PLAYER_STATUS_ID, [90.0, 70.0, 360.0, 220.0])); + previous.close(TARGET_STATUS_ID); + previous.open(COMMAND_BAR_ID); + + let mut restored = successor_engine_render::window::WindowManager::new(); + register_hud_surfaces_with_at(&mut restored, (1280.0, 1024.0), |_| None); + restored.restore_workspace_state_from(&previous); + + assert_eq!( + restored.rect(PLAYER_STATUS_ID), + Some([90.0, 70.0, 360.0, 220.0]) + ); + assert!(restored.is_interactive(PLAYER_STATUS_ID)); + assert!(!restored.is_open(TARGET_STATUS_ID)); + let order = restored.z_order(); + assert_eq!( + restored.window_id(*order.last().expect("command bar is open")), + COMMAND_BAR_ID + ); + } + + #[test] + fn sanitize_strips_controls_and_bounds() { + assert_eq!(sanitize_text("a\x07b\nc", 16), "ab c"); // control dropped, newline collapses + assert_eq!(sanitize_text(" hello world ", 32), "hello world"); + assert_eq!(sanitize_text("xxxxxxxxxx", 4), "xxxx"); + assert_eq!(sanitize_text("\u{202e}rtl\u{0000}", 8), "\u{202e}rtl"); + } + + #[test] + fn clean_actor_name_prefers_display_then_stripped_label() { + assert_eq!( + clean_actor_name("Mori Maddox", "ignored", "fb"), + "Mori Maddox" + ); + assert_eq!( + clean_actor_name("", "Mori Maddox (a rogue trooper)", "fb"), + "Mori Maddox" + ); + assert_eq!(clean_actor_name("", "", "fb"), "fb"); + } + + #[test] + fn themes_match_reference_order_and_accents() { + assert_eq!(THEME_IDS, ["signal", "phosphor", "amber", "oxide"]); + assert_eq!(palette(0).accent, [0x48, 0xd6, 0xe6, 255]); + assert_eq!(palette(1).accent, [0x46, 0xff, 0x7a, 255]); + assert_eq!(palette(2).accent, [0xff, 0xb2, 0x4a, 255]); + // Oxide's original rust accent could not clear 4.5:1 on any warm dark + // pane, so it was lifted rather than leaving one theme unreadable. + assert_eq!(palette(3).accent, [0xe0, 0x67, 0x3a, 255]); + assert_eq!(theme_index_for_id("amber"), 2); + assert_eq!(theme_index_for_id("unknown"), 0); + } + + /// Every theme has to be readable, and no theme may paint a pane that + /// reads as a hole in the world rather than a surface. + #[test] + fn every_theme_is_readable_and_tinted() { + fn luminance(rgba: [u8; 4]) -> f32 { + let channel = |value: u8| { + let c = value as f32 / 255.0; + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * channel(rgba[0]) + 0.7152 * channel(rgba[1]) + 0.0722 * channel(rgba[2]) + } + fn contrast(a: [u8; 4], b: [u8; 4]) -> f32 { + let (la, lb) = (luminance(a), luminance(b)); + (la.max(lb) + 0.05) / (la.min(lb) + 0.05) + } + for (index, theme) in THEMES.iter().enumerate() { + let id = THEME_IDS[index]; + // A pane tone this dark is a black slab, not a translucent tint. + let brightest = theme.bg_panel[..3].iter().copied().max().unwrap_or(0); + assert!( + brightest > 40, + "{id} panel {:?} is a near-black slab", + &theme.bg_panel[..3] + ); + // WCAG AA at small-text size, which is the size this UI uses. + for (label, ink) in [ + ("ink", theme.ink), + ("ink_dim", theme.ink_dim), + ("accent", theme.accent), + ] { + let on_panel = contrast(ink, theme.bg_panel); + let on_cell = contrast(ink, theme.bg_cell); + assert!( + on_panel >= 4.5, + "{id} {label} is {on_panel:.2}:1 on its panel" + ); + assert!(on_cell >= 4.5, "{id} {label} is {on_cell:.2}:1 on its cell"); + } + } + } + + #[test] + fn projection_from_empty_store_reads_no_signal() { + let store = crate::game::authority::AuthorityStore::new(); + let mut st = HudState::default(); + st.project(&store, "me", None); + assert_eq!(st.connection, ConnectionHud::NoSignal); + assert_eq!(st.fine_text, "NO SIGNAL"); + assert_eq!(st.health_text, "--"); + } + + #[test] + fn weapon_display_name_folds_ids() { + assert_eq!( + weapon_display_name("weapon_slugthrower_mk2"), + "SLUGTHROWER MK2" + ); + assert_eq!(weapon_display_name(""), "SIDEARM"); + } +} diff --git a/client-rust/source/app/src/hud/layout.rs b/client-rust/source/app/src/hud/layout.rs new file mode 100644 index 00000000..2f2c6509 --- /dev/null +++ b/client-rust/source/app/src/hud/layout.rs @@ -0,0 +1,469 @@ +//! Deterministic first-run HUD placement. +//! +//! [`compute`] is deliberately a registration policy, not a live layout pass: +//! the workspace manager owns every persistent pane after first registration or +//! restore. Its defaults preserve the sparse arrangement measured from the +//! clean original-client world-entry capture while guaranteeing that the real +//! pane floors remain fully visible and non-overlapping at every supported +//! framebuffer. +//! +//! The player plate is flush top-left, the command bar stays as near the +//! top-centre as its neighbour gutter allows, and the target/queue hold the +//! right rail. At 800×600, the adaptable panes contract toward their measured +//! floors rather than crossing bands. + +use successor_engine_render::window::SIDE_MARGIN; + +/// Screen edge gutter for panes that are not intentionally flush. +pub const MARGIN: f32 = 6.0; +/// Gap kept between neighbouring first-run panes. +pub const GUTTER: f32 = 7.0; + +/// Framebuffer the HUD sizes were measured against. +pub const REFERENCE_W: f32 = 1024.0; +pub const REFERENCE_H: f32 = 768.0; +/// Ceiling on growth: past this the HUD is simply large, not more readable. +const MAX_UI_SCALE: f32 = 2.0; + +/// Uniform HUD scale for a framebuffer. +/// +/// Every pane size here was measured off the original client at 1024x768. Held +/// at those literal pixels the whole HUD shrinks into a corner of a large +/// display, so panes grow with the viewport instead. The smaller axis drives +/// the factor so a wide framebuffer cannot push the bottom band off screen, +/// and the floor is 1.0: at or below the reference the measured pixels are +/// used unchanged, which is what the resize-floor contract depends on. +pub fn ui_scale(viewport_w: f32, viewport_h: f32) -> f32 { + let by_width = viewport_w / REFERENCE_W; + let by_height = viewport_h / REFERENCE_H; + by_width.min(by_height).clamp(1.0, MAX_UI_SCALE) +} + +/// Measured command bar default and source-verified resize floor. +pub const BAR_W: f32 = 474.0; +pub const BAR_H: f32 = 64.0; +pub const BAR_MIN_W: f32 = 260.0; +pub const BAR_MIN_H: f32 = 48.0; +/// Player status plate default and resize floor. Keep these in lockstep with +/// `plate::draw_status_plate`: the manager cannot register a smaller default +/// than the renderer's measured content well. +pub const PLATE_W: f32 = super::plate::PLATE_W; +pub const PLATE_H: f32 = super::plate::PLATE_H; +pub const PLATE_MIN_W: f32 = 240.0; +pub const PLATE_MIN_H: f32 = 46.0; +/// Weapon/magazine pane: its own lane under the player plate, because it only +/// exists while something is wielded. +pub const WEAPON_W: f32 = super::plate::WEAPON_PLATE_W; +pub const WEAPON_H: f32 = super::plate::WEAPON_PLATE_H; +pub const WEAPON_MIN_W: f32 = 160.0; +pub const WEAPON_MIN_H: f32 = 24.0; +/// Group rail: the lane directly beneath the player plate belongs to group +/// members and nothing else. It is sized for the full chip run so a filling +/// group never pushes into the panes below. +pub const GROUP_W: f32 = PLATE_W; +pub const GROUP_H: f32 = super::plate::GROUP_CHIP_H * super::GROUP_CHIP_MAX as f32 + + super::plate::GROUP_CHIP_GAP * (super::GROUP_CHIP_MAX as f32 - 1.0); +/// Target status stays in the top-right rail, beneath the command bar. +pub const TARGET_W: f32 = 280.0; +pub const TARGET_H: f32 = 84.0; +pub const TARGET_MIN_W: f32 = 200.0; +pub const TARGET_MIN_H: f32 = 64.0; +pub const CHAT_W: f32 = 512.0; +pub const CHAT_H: f32 = 145.0; +pub const CHAT_MIN_W: f32 = 160.0; +pub const CHAT_MIN_H: f32 = 96.0; +pub const QUEUE_W: f32 = 232.0; +pub const QUEUE_H: f32 = 180.0; +pub const QUEUE_MIN_W: f32 = 160.0; +pub const QUEUE_MIN_H: f32 = 60.0; +pub const STRIP_W: f32 = 296.0; +pub const STRIP_H: f32 = 49.0; +pub const STRIP_MIN_W: f32 = 200.0; +pub const STRIP_MIN_H: f32 = 32.0; +/// Measured radar content square. Its chromeless workspace rect preserves the +/// former content lane, including the artwork's intentional surrounding air. +pub const RADAR_SIZE: f32 = 148.0; +/// Measured left edge of the radar content (capture: `x=21..169`). +pub const RADAR_INSET: f32 = 21.0; +/// Space between the radar workspace lane and the chat console. +pub const RADAR_CHAT_GUTTER: f32 = 8.0; +/// Air above the scope inside its lane. Mirrors `radar::SCOPE_Y`, which is the +/// inset the radar itself draws at; keeping the lane and the renderer in step +/// is what stops the scope sitting off-centre in its own pane. +pub const RADAR_SCOPE_INSET: f32 = 2.0; +/// The radar's registration lane, and its resize floor: the scope is a fixed +/// instrument, so the pane that carries it does not usefully shrink. Both the +/// default and the floor come from here so they cannot drift apart - a floor +/// above the default silently clamps the pane larger on first run. +pub const RADAR_LANE_W: f32 = RADAR_SIZE + SIDE_MARGIN * 2.0 + 1.0; +pub const RADAR_LANE_H: f32 = RADAR_SIZE + super::radar::COORD_RAIL + RADAR_SCOPE_INSET; +/// Window dock rail (a Successor addition, not a persistent HUD pane). +pub const DOCK_BTN: f32 = 30.0; + +/// Resolved HUD geometry for one framebuffer. Every rect is `[x, y, w, h]`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct HudLayout { + /// Player status plate, top-left. + pub plate: [f32; 4], + /// Weapon/magazine pane, directly beneath the plate. + pub weapon: [f32; 4], + /// Group member rail, directly beneath the plate. + pub group: [f32; 4], + /// Target plate, top-right beneath the command bar. + pub target: [f32; 4], + /// Command bar (toolbar slots), fixed size, top-center. + pub bar: [f32; 4], + /// Tactical radar's managed outer frame, fixed bottom-left. + pub radar: [f32; 4], + /// Chat console, bottom, clear of the radar frame and status strip. + pub chat: [f32; 4], + /// Command/receipt status strip, bottom-right. + pub strip: [f32; 4], + /// Ability queue, under the target plate. + pub queue: [f32; 4], + /// Window dock rail, right edge. + pub dock: [f32; 4], + /// Interaction chip, bottom-center above the chat band. + pub chip: [f32; 2], + /// Toast/banner baseline, above the chip. + pub toast: [f32; 2], + /// First-steps guidance rail, left edge below the plate. + pub guidance: [f32; 2], +} + +/// Interpolate only through the compact 800→1024 band. Larger viewports retain +/// the measured defaults; 800×600 reaches the source-verified resize floor. +fn compact_dimension(floor: f32, default: f32, viewport_w: f32) -> f32 { + let progress = ((viewport_w - 800.0) / (1024.0 - 800.0)).clamp(0.0, 1.0); + floor + (default - floor) * progress +} + +/// Resolve first-run HUD geometry for a framebuffer. +/// +/// The band arithmetic below is written in the measured 1024x768 reference +/// space. Solving it against a reference-sized viewport and scaling the result +/// keeps every anchor exact — `(vw / s) * s == vw`, so a flush edge stays flush +/// — while pane sizes, gutters, and margins all grow together. +pub fn compute(viewport_w: f32, viewport_h: f32) -> HudLayout { + let scale = ui_scale(viewport_w.max(320.0), viewport_h.max(240.0)); + if scale <= 1.0 { + return compute_reference(viewport_w, viewport_h); + } + compute_reference(viewport_w / scale, viewport_h / scale).scaled(scale) +} + +fn compute_reference(viewport_w: f32, viewport_h: f32) -> HudLayout { + let vw = viewport_w.max(320.0); + let vh = viewport_h.max(240.0); + + // Top band: player status is flush-left. The command bar stays centred + // until that would cross the player gutter; the target uses the separate + // top-right rail below it, matching the existing Successor arrangement. + let plate_w = compact_dimension(PLATE_MIN_W, PLATE_W, vw); + let plate_h = compact_dimension(PLATE_MIN_H, PLATE_H, vw); + let plate = [0.0, 0.0, plate_w, plate_h]; + + // The plate's column continues downward: weapon readout, then the group. + // Each is its own pane, so an unarmed solo player shows one tight plate + // and nothing else. + let weapon_y = plate[1] + plate[3] + GUTTER; + let weapon = [plate[0], weapon_y, plate_w.min(WEAPON_W), WEAPON_H]; + let group_y = weapon[1] + weapon[3] + GUTTER; + let group = [plate[0], group_y, plate_w, GROUP_H]; + + let target_w = compact_dimension(TARGET_MIN_W, TARGET_W, vw); + let target_h = compact_dimension(TARGET_MIN_H, TARGET_H, vw); + + // The command bar is a fixed twelve-slot rail; narrowing it resizes every + // slot, so its measured width is not negotiable and the band is planned + // around it. + let bar_room = (vw - plate_w - GUTTER - MARGIN).max(BAR_MIN_W); + let bar_w = BAR_W.min(bar_room).max(BAR_MIN_W); + let bar_left = plate[0] + plate[2] + GUTTER; + + // Flush top-right, mirroring the player plate's flush top-left: the two are + // the same kind of readout - you and your mark - so they take opposite + // corners of one band. Only where the bar still fits between them; below + // that width the target keeps its old lane under the bar instead of + // overlapping it. + let shares_top_band = bar_left + bar_w + GUTTER + target_w <= vw; + let target = if shares_top_band { + [vw - target_w, 0.0, target_w, target_h] + } else { + [vw - MARGIN - target_w, BAR_H + GUTTER, target_w, target_h] + }; + + let bar_right_stop = if shares_top_band { + target[0] - GUTTER + } else { + vw - MARGIN + }; + let bar_x = ((vw - bar_w) * 0.5) + .min(bar_right_stop - bar_w) + .max(bar_left); + let bar = [bar_x, 0.0, bar_w, BAR_H]; + + // The radar is chromeless: the manager suppresses its caption and footer, + // so reserving a caption rail and a bottom rail inside its pane only buys + // 52 px of dead band around the scope. The lane is the scope plus its own + // coordinate readout, nothing more. + let radar_content_size = RADAR_SIZE.min(vw * 0.3).min(vh * 0.3); + let radar_w = radar_content_size + SIDE_MARGIN * 2.0 + 1.0; + let radar_h = radar_content_size + super::radar::COORD_RAIL + RADAR_SCOPE_INSET; + let radar = [ + (RADAR_INSET - SIDE_MARGIN).max(0.0), + vh - radar_h - MARGIN, + radar_w, + radar_h, + ]; + + // Bottom band: radar → chat → notification strip, each separated by its + // gutter. The compact strip releases horizontal room to chat at 800 px. + let strip_w = compact_dimension(STRIP_MIN_W, STRIP_W, vw); + let strip_h = compact_dimension(STRIP_MIN_H, STRIP_H, vw); + let strip = [ + vw - strip_w - MARGIN, + vh - strip_h - MARGIN, + strip_w, + strip_h, + ]; + let chat_x = radar[0] + radar[2] + RADAR_CHAT_GUTTER; + let chat_room = (strip[0] - GUTTER - chat_x).max(CHAT_MIN_W); + let chat_w = CHAT_W.min(chat_room); + let chat_h = compact_dimension(CHAT_MIN_H, CHAT_H, vw); + let chat = [chat_x, vh - chat_h - MARGIN, chat_w, chat_h]; + + // The ability queue stays inboard of the fixed launcher rail, directly + // beneath the top-right target plate. + let queue_w = compact_dimension(QUEUE_MIN_W, QUEUE_W, vw); + let queue_x = (vw - MARGIN - DOCK_BTN - GUTTER - queue_w).max(MARGIN); + let queue_y = target[1] + target[3] + GUTTER; + let queue_h = QUEUE_H.min((chat[1] - queue_y - GUTTER).max(QUEUE_MIN_H)); + let queue = [queue_x, queue_y, queue_w, queue_h]; + + // The dock remains a fixed Successor launcher, not a manager-owned HUD + // pane. Its geometry is retained solely for the existing dock renderer. + let dock_h = (vh * 0.5).min(360.0); + let dock = [ + vw - MARGIN - DOCK_BTN, + (vh - dock_h) * 0.5, + DOCK_BTN, + dock_h, + ]; + + // Transient overlays are intentionally not registration defaults. + let chip_y = (chat[1] - 26.0).max(bar[1] + bar[3] + GUTTER); + let chip = [vw * 0.5, chip_y]; + let toast = [vw * 0.5, (chip_y - 24.0).max(bar[1] + bar[3] + GUTTER)]; + let guidance = [MARGIN, group[1] + group[3] + 24.0]; + + HudLayout { + plate, + weapon, + group, + target, + bar, + radar, + chat, + strip, + queue, + dock, + chip, + toast, + guidance, + } +} + +impl HudLayout { + /// Multiply every rect and anchor by a uniform factor. + fn scaled(self, scale: f32) -> Self { + let rect = |r: [f32; 4]| [r[0] * scale, r[1] * scale, r[2] * scale, r[3] * scale]; + let point = |p: [f32; 2]| [p[0] * scale, p[1] * scale]; + Self { + plate: rect(self.plate), + weapon: rect(self.weapon), + group: rect(self.group), + target: rect(self.target), + bar: rect(self.bar), + radar: rect(self.radar), + chat: rect(self.chat), + strip: rect(self.strip), + queue: rect(self.queue), + dock: rect(self.dock), + chip: point(self.chip), + toast: point(self.toast), + guidance: point(self.guidance), + } + } +} + +/// Whether two rects overlap. +fn overlaps(a: [f32; 4], b: [f32; 4]) -> bool { + a[0] < b[0] + b[2] && b[0] < a[0] + a[2] && a[1] < b[1] + b[3] && b[1] < a[1] + a[3] +} + +impl HudLayout { + /// Persistent manager-owned defaults, for collision checks and host wiring. + /// The fixed window-launcher dock is intentionally excluded. + pub fn rects(&self) -> [(&'static str, [f32; 4]); 8] { + [ + ("plate", self.plate), + ("group", self.group), + ("target", self.target), + ("bar", self.bar), + ("radar", self.radar), + ("chat", self.chat), + ("strip", self.strip), + ("queue", self.queue), + ] + } + + /// First overlapping pair, if any. `None` means the layout is legal. + pub fn first_overlap(&self) -> Option<(&'static str, &'static str)> { + let rects = self.rects(); + for (index, (name, rect)) in rects.iter().enumerate() { + for (other_name, other) in rects.iter().skip(index + 1) { + if overlaps(*rect, *other) { + return Some((name, other_name)); + } + } + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every framebuffer required by the persistent-HUD contract. + const SIZES: [(f32, f32); 4] = [ + (800.0, 600.0), + (1024.0, 768.0), + (1280.0, 1024.0), + (1600.0, 1200.0), + ]; + + #[test] + fn no_hud_element_overlaps_at_any_supported_framebuffer() { + for (vw, vh) in SIZES { + let layout = compute(vw, vh); + assert_eq!( + layout.first_overlap(), + None, + "overlap at {vw}x{vh}: {layout:?}" + ); + } + } + + #[test] + fn every_element_stays_on_screen() { + for (vw, vh) in SIZES { + let layout = compute(vw, vh); + for (name, [x, y, w, h]) in layout.rects() { + assert!( + x >= 0.0 && y >= 0.0 && x + w <= vw + 0.5 && y + h <= vh + 0.5, + "{name} escapes {vw}x{vh}: {:?}", + [x, y, w, h] + ); + } + } + } + + #[test] + fn defaults_contract_toward_floors_without_crossing_bands() { + let compact = compute(800.0, 600.0); + let measured = compute(1024.0, 768.0); + let large = compute(1600.0, 1200.0); + + assert_eq!(compact.plate[2], PLATE_MIN_W); + assert_eq!(compact.plate[3], PLATE_MIN_H); + assert_eq!(compact.target[2], TARGET_MIN_W); + assert_eq!(compact.target[3], TARGET_MIN_H); + assert_eq!(compact.queue[2], QUEUE_MIN_W); + assert_eq!(compact.strip[2], STRIP_MIN_W); + assert_eq!(compact.strip[3], STRIP_MIN_H); + + assert_eq!(measured.plate[2], PLATE_W); + assert_eq!(measured.plate[3], PLATE_H); + assert_eq!(measured.target[2], TARGET_W); + assert_eq!(measured.target[3], TARGET_H); + assert_eq!(measured.bar[2], BAR_W); + assert_eq!(measured.bar[3], BAR_H); + assert_eq!(measured.chat[2], CHAT_W); + assert_eq!(measured.chat[3], CHAT_H); + assert_eq!(measured.queue[2], QUEUE_W); + assert_eq!(measured.strip[2], STRIP_W); + assert_eq!(measured.strip[3], STRIP_H); + + // Past the reference the HUD grows with the framebuffer instead of + // shrinking into a corner: every pane is the measured pixel size times + // the uniform scale, and the radar lane scales with them. + let scale = ui_scale(1600.0, 1200.0); + assert!(scale > 1.0, "1600x1200 is above the reference"); + let close = |left: f32, right: f32| (left - right).abs() < 0.01; + assert!(close(large.bar[2], BAR_W * scale)); + assert!(close(large.plate[2], PLATE_W * scale)); + assert!(close(large.chat[2], CHAT_W * scale)); + assert!(close(large.radar[2], measured.radar[2] * scale)); + assert!(close(large.radar[3], measured.radar[3] * scale)); + // Flush and right-rail anchors survive the scale exactly. + assert!(close(large.plate[0], 0.0)); + assert!(close( + large.strip[0] + large.strip[2], + 1600.0 - MARGIN * scale + )); + + // A centre-preferred bar may shift right only enough to clear the + // flush player plate; it never crosses that required gutter. + for (vw, vh) in SIZES { + let layout = compute(vw, vh); + assert!(layout.bar[0] >= layout.plate[0] + layout.plate[2] + GUTTER); + assert!( + (layout.bar[0] + layout.bar[2] * 0.5) >= vw * 0.5, + "command bar crossed left of centre at {vw}x{vh}" + ); + } + } + + #[test] + fn matches_the_measured_1024_registration_defaults() { + let layout = compute(1024.0, 768.0); + assert_eq!(layout.plate, [0.0, 0.0, PLATE_W, PLATE_H]); + assert_eq!(layout.bar, [307.0, 0.0, BAR_W, BAR_H]); + assert_eq!(layout.target, [738.0, 71.0, TARGET_W, TARGET_H]); + assert_eq!(layout.queue, [749.0, 162.0, QUEUE_W, QUEUE_H]); + + // Chat stays clear of the complete radar lane and status strip. + assert_eq!( + layout.chat[0], + layout.radar[0] + layout.radar[2] + RADAR_CHAT_GUTTER + ); + assert_eq!(layout.chat[2], CHAT_W); + assert_eq!(layout.chat[3], CHAT_H); + assert_eq!(layout.chat[1], 617.0); + + // The radar lane is the scope plus the coordinate rail the radar itself + // draws - not a window's caption and footer, which this pane suppresses + // and which only ever showed up as dead band around the instrument. + assert_eq!(layout.radar[2], RADAR_LANE_W); + assert_eq!(layout.radar[3], RADAR_LANE_H); + assert_eq!(layout.radar[1] + layout.radar[3], 768.0 - MARGIN); + assert_eq!(layout.strip, [722.0, 713.0, STRIP_W, STRIP_H]); + } + + #[test] + fn the_bottom_center_stack_clears_the_chat_band() { + for (vw, vh) in SIZES { + let layout = compute(vw, vh); + assert!( + layout.chip[1] + 24.0 <= layout.chat[1] + 0.5, + "interact chip overlaps chat at {vw}x{vh}" + ); + assert!( + layout.toast[1] < layout.chip[1], + "toast must sit above the chip at {vw}x{vh}" + ); + } + } +} diff --git a/client-rust/source/app/src/hud/overlays.rs b/client-rust/source/app/src/hud/overlays.rs new file mode 100644 index 00000000..cbabb225 --- /dev/null +++ b/client-rust/source/app/src/hud/overlays.rs @@ -0,0 +1,747 @@ +//! World-anchored overlay chrome: nameplates, spatial chat bubbles and +//! floating combat/status text (ports of `actorPresentation` nameplates, +//! `spatialBubbleSystem.ts` and the combat float vocabulary). +//! +//! The overlay layer never projects world coordinates itself — the world +//! renderer resolves each anchor to screen px and calls the draw helpers, so +//! this module stays camera-agnostic. All pools are bounded; expired entries +//! recycle in place without per-frame heap work. + +use successor_engine_render::font::GLYPH_H; +use successor_engine_render::ui::UiBuilder; + +use super::{ + plate::{hostility_tint, readable_dim}, + sanitize_text, Palette, RelationHud, +}; + +// Reference tuning (`client/src/slice-core/specs/tuning.v1.json` spatialChat). +pub const BUBBLE_MIN_TTL_MS: f32 = 2200.0; +pub const BUBBLE_MAX_TTL_MS: f32 = 7000.0; +pub const BUBBLE_MS_PER_CHAR: f32 = 56.0; +pub const BUBBLE_FADE_IN_MS: f32 = 120.0; +pub const BUBBLE_FADE_OUT_MS: f32 = 320.0; +pub const BUBBLE_MAX_STACK: usize = 3; +pub const BUBBLE_ACTOR_MAX: usize = 16; +pub const BUBBLE_POOL_MAX: usize = BUBBLE_MAX_STACK * BUBBLE_ACTOR_MAX; +/// Bubble body cap before wrap (sanitized chars). +pub const BUBBLE_TEXT_MAX: usize = 160; +pub const BUBBLE_MAX_LINES: usize = 4; +pub const BUBBLE_LINE_CHARS: usize = 28; + +pub const FLOAT_POOL_MAX: usize = 48; +pub const FLOAT_TTL_MS: f32 = 900.0; +pub const FLOAT_RISE_PX: f32 = 34.0; + +pub const NAMEPLATE_NAME_MAX: usize = 24; + +/// TTL scales with body length (reference `spatialBubbleTtlMs`). +pub fn bubble_ttl_ms(body: &str) -> f32 { + (body.chars().count() as f32 * BUBBLE_MS_PER_CHAR).clamp(BUBBLE_MIN_TTL_MS, BUBBLE_MAX_TTL_MS) +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Bubble { + /// Owning actor; bubbles with an unknown/missing anchor never fall back + /// to another pawn (reference actor-ownership rule). + pub actor_id: String, + pub lines: Vec, + pub ttl_ms: f32, + pub total_ms: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FloatTone { + Damage, + Heal, + Miss, + Deflect, + Status, + Reject, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct FloatText { + pub actor_id: String, + pub text: String, + pub tone: FloatTone, + pub age_ms: f32, +} + +/// Greedy word wrap at a fixed column budget (5×7 font is monospaced), with a +/// trailing ellipsis when the line cap truncates the body. +pub fn wrap_bubble_text(body: &str, max_chars: usize, max_lines: usize) -> Vec { + let mut lines: Vec = Vec::with_capacity(max_lines); + let mut current = String::new(); + let mut truncated = false; + for word in body.split_whitespace() { + let mut word = word; + // Hard-split words longer than a line. + while word.chars().count() > max_chars { + if lines.len() == max_lines { + truncated = true; + break; + } + if !current.is_empty() { + lines.push(core::mem::take(&mut current)); + continue; + } + let split: String = word.chars().take(max_chars).collect(); + let rest_start = split.len(); + lines.push(split); + word = &word[rest_start..]; + } + if truncated { + break; + } + let needed = if current.is_empty() { + word.chars().count() + } else { + current.chars().count() + 1 + word.chars().count() + }; + if needed <= max_chars { + if !current.is_empty() { + current.push(' '); + } + current.push_str(word); + } else { + if lines.len() == max_lines { + truncated = true; + break; + } + lines.push(core::mem::take(&mut current)); + current.push_str(word); + } + } + if !current.is_empty() && lines.len() < max_lines { + lines.push(current); + } else if !current.is_empty() { + truncated = true; + } + if lines.len() > max_lines { + lines.truncate(max_lines); + truncated = true; + } + if truncated { + if let Some(last) = lines.last_mut() { + while last.chars().count() > max_chars.saturating_sub(1) { + last.pop(); + } + last.push('…'); + } + } + lines +} + +/// Bounded overlay pools. The connected runtime owns one instance. +#[derive(Default)] +pub struct Overlays { + pub bubbles: Vec, + pub floats: Vec, +} + +impl Overlays { + pub fn new() -> Self { + Self { + bubbles: Vec::with_capacity(BUBBLE_POOL_MAX), + floats: Vec::with_capacity(FLOAT_POOL_MAX), + } + } + + /// Enqueue a LOCAL chat bubble over its speaker. Text is sanitized and + /// wrapped here — bubbles never render raw wire bytes. + pub fn push_bubble(&mut self, actor_id: &str, body: &str) { + let text = sanitize_text(body, BUBBLE_TEXT_MAX); + if text.is_empty() || actor_id.is_empty() { + return; + } + let ttl = bubble_ttl_ms(&text); + self.bubbles.insert( + 0, + Bubble { + actor_id: actor_id.to_string(), + lines: wrap_bubble_text(&text, BUBBLE_LINE_CHARS, BUBBLE_MAX_LINES), + ttl_ms: ttl, + total_ms: ttl, + }, + ); + let mut actor_count = 0usize; + self.bubbles.retain(|bubble| { + if bubble.actor_id == actor_id { + actor_count += 1; + actor_count <= BUBBLE_MAX_STACK + } else { + true + } + }); + self.bubbles.truncate(BUBBLE_POOL_MAX); + } + + /// Enqueue floating combat/status text over an actor (bounded pool — + /// oldest entry recycles when full). + pub fn push_float(&mut self, actor_id: &str, text: &str, tone: FloatTone) { + if actor_id.is_empty() { + return; + } + let text = sanitize_text(text, 24); + if text.is_empty() { + return; + } + if self.floats.len() >= FLOAT_POOL_MAX { + // Recycle the oldest. + let oldest = self + .floats + .iter() + .enumerate() + .max_by(|a, b| a.1.age_ms.total_cmp(&b.1.age_ms)) + .map(|(i, _)| i) + .unwrap_or(0); + self.floats.swap_remove(oldest); + } + self.floats.push(FloatText { + actor_id: actor_id.to_string(), + text, + tone, + age_ms: 0.0, + }); + } + + /// Age pools; expired entries drop. + pub fn update(&mut self, dt_ms: f32) { + self.bubbles.retain_mut(|b| { + b.ttl_ms -= dt_ms; + b.ttl_ms > 0.0 + }); + self.floats.retain_mut(|f| { + f.age_ms += dt_ms; + f.age_ms < FLOAT_TTL_MS + }); + } + + /// Draw every anchored overlay. `anchor` resolves an actor id to the + /// screen-px point above its head; `None` skips (off-screen/occluded/ + /// despawned anchors draw nothing — no fallback pawn). + pub fn draw Option<(f32, f32)>>( + &self, + ui: &mut UiBuilder, + pal: &Palette, + sw: f32, + sh: f32, + anchor: F, + ) { + for (index, bubble) in self.bubbles.iter().enumerate() { + if let Some((x, y)) = anchor(&bubble.actor_id) { + let stack_offset = self.bubbles[..index] + .iter() + .filter(|newer| newer.actor_id == bubble.actor_id) + .map(|newer| bubble_dimensions(ui, newer).1 + 5.0) + .sum::(); + draw_bubble(ui, bubble, x, y - stack_offset, sw); + } + } + for ft in &self.floats { + if let Some((x, y)) = anchor(&ft.actor_id) { + draw_float(ui, pal, ft, x, y, sh); + } + } + } +} + +fn alpha_scale(color: [u8; 4], alpha: f32) -> [u8; 4] { + [ + color[0], + color[1], + color[2], + (color[3] as f32 * alpha.clamp(0.0, 1.0)) as u8, + ] +} + +/// Original-client speech bubble: centered black prose on a translucent pale +/// rectangle with a tapered spout. It is clamped to the screen and fades at +/// the same ingress/egress timings as the runtime contract. +fn bubble_dimensions(ui: &UiBuilder, bubble: &Bubble) -> (f32, f32) { + let text_px = 1.5; + let line_h = 7.0 * text_px + 3.0; + let widest = bubble + .lines + .iter() + .map(|line| ui.measure_text(line, text_px)) + .fold(0.0f32, f32::max); + (widest + 16.0, bubble.lines.len() as f32 * line_h + 9.0) +} + +fn draw_bubble(ui: &mut UiBuilder, bubble: &Bubble, x: f32, y: f32, screen_w: f32) { + let text_px = 1.5; + let line_h = 7.0 * text_px + 3.0; + let (width, height) = bubble_dimensions(ui, bubble); + let mut bubble_x = x - width * 0.5; + bubble_x = bubble_x.clamp(4.0, (screen_w - width - 4.0).max(4.0)); + let bubble_y = (y - height - 7.0).max(4.0); + + let lived = bubble.total_ms - bubble.ttl_ms; + let fade_in = (lived / BUBBLE_FADE_IN_MS).clamp(0.0, 1.0); + let fade_out = (bubble.ttl_ms / BUBBLE_FADE_OUT_MS).clamp(0.0, 1.0); + let alpha = fade_in.min(fade_out); + let background = alpha_scale([214, 222, 211, 202], alpha); + let edge = alpha_scale([242, 247, 237, 226], alpha); + let ink = alpha_scale([14, 20, 20, 255], alpha); + + ui.rect( + bubble_x + 2.0, + bubble_y + 3.0, + width, + height, + alpha_scale([0, 0, 0, 76], alpha), + ); + ui.rect(bubble_x, bubble_y, width, height, background); + ui.border(bubble_x, bubble_y, width, height, 1.0, edge); + + let tail_x = x.clamp(bubble_x + 8.0, bubble_x + width - 8.0); + ui.rect(tail_x - 3.0, bubble_y + height, 6.0, 2.0, background); + ui.rect(tail_x - 2.0, bubble_y + height + 2.0, 4.0, 2.0, background); + ui.rect(tail_x - 1.0, bubble_y + height + 4.0, 2.0, 2.0, background); + + for (index, line) in bubble.lines.iter().enumerate() { + let line_width = ui.measure_text(line, text_px); + ui.text( + line, + bubble_x + (width - line_width) * 0.5, + bubble_y + 5.0 + index as f32 * line_h, + text_px, + ink, + ); + } +} + +fn draw_float(ui: &mut UiBuilder, pal: &Palette, ft: &FloatText, x: f32, y: f32, _sh: f32) { + let t = (ft.age_ms / FLOAT_TTL_MS).clamp(0.0, 1.0); + let rise = t * FLOAT_RISE_PX; + let alpha = 1.0 - t * t; // ease-out fade + let tint = match ft.tone { + FloatTone::Damage => pal.danger, + FloatTone::Heal => [110, 214, 130, 255], + FloatTone::Miss | FloatTone::Deflect => pal.ink_dim, + FloatTone::Status => pal.accent, + FloatTone::Reject => pal.danger, + }; + let px = if ft.tone == FloatTone::Damage { + 2.0 + } else { + 1.6 + }; + let tw = ui.measure_text(&ft.text, px); + ui.text( + &ft.text, + x - tw * 0.5, + (y - 18.0 - rise).max(2.0), + px, + alpha_scale(tint, alpha), + ); +} + +/// Screen-space lift from the head anchor to the name's baseline. Mirrors +/// `nameplateScreenLiftPx` in `client-3d/src/overlay/nameplates.ts` so a pawn +/// wears its name at the same height in both clients. +pub const NAMEPLATE_SCREEN_LIFT_PX: f32 = 24.0; + +/// Original-client-shaped nameplate: unboxed centered text, relation color, +/// parenthesized title/role, and a subtle target bracket. The host owns +/// visibility and distance opacity. +/// +/// `y` is the BASELINE the name sits on, matching the web renderer's canvas +/// `fillText`. The engine's `ui.text` anchors glyph TOPS, so the name is lifted +/// by its own cap height here; passing a top-anchored y instead dropped the +/// whole plate a full text height onto the pawn's head. +#[allow(clippy::too_many_arguments)] +pub fn draw_nameplate( + ui: &mut UiBuilder, + pal: &Palette, + name: &str, + descriptor: Option<&str>, + relation: RelationHud, + life_tag: Option<&str>, + selected: bool, + opacity: f32, + x: f32, + y: f32, +) { + if name.is_empty() || opacity <= 0.0 { + return; + } + let is_dead = life_tag.is_some_and(|t| t == "DEAD" || t == "DOWN"); + let name_px = 1.45; + let base_hostility_tint = hostility_tint(relation, pal); + let base_tint = if is_dead { + readable_dim(pal) + } else { + base_hostility_tint + }; + let tint = alpha_scale(base_tint, opacity); + let shadow = alpha_scale([0, 0, 0, 220], opacity); + // Cap height of the 5x7 face at this scale. Lifting by it turns the + // caller's baseline into the glyph top the engine actually wants. + let name_cap = GLYPH_H as f32 * name_px; + let name_top = y - name_cap; + let name_width = ui.measure_text(name, name_px); + let name_x = x - name_width * 0.5; + ui.text(name, name_x + 1.0, name_top + 1.0, name_px, shadow); + ui.text(name, name_x, name_top, name_px, tint); + + if is_dead { + let strike_tint = alpha_scale(readable_dim(pal), opacity); + let strike_y = name_top + name_cap * 0.5; + ui.line( + name_x, + strike_y, + name_x + name_width, + strike_y, + 1.0, + strike_tint, + ); + } + + let mut line_y = y + 2.0; + let desc_val = descriptor.filter(|value| !value.is_empty()); + if let Some(desc) = desc_val { + let descriptor_px = 1.2; + let width = ui.measure_text(desc, descriptor_px); + ui.text( + desc, + x - width * 0.5 + 1.0, + line_y + 1.0, + descriptor_px, + shadow, + ); + ui.text( + desc, + x - width * 0.5, + line_y, + descriptor_px, + alpha_scale(pal.ink_dim, opacity), + ); + line_y += 10.0; + } + if let Some(tag) = life_tag { + let width = ui.measure_text(tag, 1.25); + let tag_tint = if is_dead { + alpha_scale(readable_dim(pal), opacity) + } else { + alpha_scale(pal.danger, opacity) + }; + ui.text(tag, x - width * 0.5, line_y, 1.25, tag_tint); + line_y += 10.0; + } + + if selected { + let mut max_w = name_width; + if let Some(desc) = desc_val { + max_w = max_w.max(ui.measure_text(desc, 1.2)); + } + if let Some(tag) = life_tag { + max_w = max_w.max(ui.measure_text(tag, 1.25)); + } + + let pad_x = 6.0; + let pad_y = 3.0; + let left = x - max_w * 0.5 - pad_x; + let right = x + max_w * 0.5 + pad_x; + let top = y - pad_y; + let bottom = line_y - 2.0; + + let arm = 5.0f32.min((right - left) * 0.25).min((bottom - top) * 0.25); + let bracket_base = if is_dead { + readable_dim(pal) + } else { + base_hostility_tint + }; + let bracket = alpha_scale(bracket_base, opacity); + + // Corner brackets + ui.line(left, top, left + arm, top, 1.0, bracket); + ui.line(left, top, left, top + arm, 1.0, bracket); + + ui.line(right - arm, top, right, top, 1.0, bracket); + ui.line(right, top, right, top + arm, 1.0, bracket); + + ui.line(left, bottom, left + arm, bottom, 1.0, bracket); + ui.line(left, bottom - arm, left, bottom, 1.0, bracket); + + ui.line(right - arm, bottom, right, bottom, 1.0, bracket); + ui.line(right, bottom - arm, right, bottom, 1.0, bracket); + + // Baseline tick + let tick_w = 6.0; + ui.line( + x - tick_w * 0.5, + bottom, + x + tick_w * 0.5, + bottom, + 1.0, + bracket, + ); + ui.line(x, bottom, x, bottom - 3.0, 1.0, bracket); + } +} + +/// Contextual label for the nearest tangible world object. The legacy client exposes object +/// names as the same unboxed, projected text family as creature plates. +pub fn draw_world_label( + ui: &mut UiBuilder, + pal: &Palette, + label: &str, + action: Option<&str>, + x: f32, + y: f32, +) { + if label.is_empty() { + return; + } + let text_px = 1.35; + let width = ui.measure_text(label, text_px); + let text_x = x - width * 0.5; + ui.text(label, text_x + 1.0, y + 1.0, text_px, [0, 0, 0, 220]); + ui.text(label, text_x, y, text_px, pal.ink); + if let Some(action) = action { + let action_px = 1.1; + let action_width = ui.measure_text(action, action_px); + ui.text( + action, + x - action_width * 0.5 + 1.0, + y + 12.0, + action_px, + [0, 0, 0, 220], + ); + ui.text( + action, + x - action_width * 0.5, + y + 11.0, + action_px, + pal.accent, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hud::{palette, Icons}; + + #[test] + fn bubble_ttl_follows_reference_curve() { + assert_eq!(bubble_ttl_ms("hi"), BUBBLE_MIN_TTL_MS); + assert_eq!(bubble_ttl_ms(&"x".repeat(200)), BUBBLE_MAX_TTL_MS); + let mid = bubble_ttl_ms(&"x".repeat(80)); + assert!((mid - 80.0 * BUBBLE_MS_PER_CHAR).abs() < 1e-3); + } + + #[test] + fn bubble_stack_is_bounded_and_sanitized() { + let mut ov = Overlays::new(); + for i in 0..6 { + ov.push_bubble("actor", &format!("message {i}")); + } + assert_eq!(ov.bubbles.len(), BUBBLE_MAX_STACK); + assert_eq!(ov.bubbles[0].lines[0], "message 5", "newest first"); + ov.push_bubble("actor", " \u{7}\u{8} "); + assert_eq!( + ov.bubbles.len(), + BUBBLE_MAX_STACK, + "empty-after-sanitize dropped" + ); + ov.push_bubble("", "orphan"); + assert!( + ov.bubbles.iter().all(|b| !b.actor_id.is_empty()), + "ownerless bubbles never enqueue" + ); + } + + #[test] + fn bubble_stack_limit_is_per_actor_not_global() { + let mut overlays = Overlays::new(); + for actor in ["a", "b"] { + for index in 0..BUBBLE_MAX_STACK { + overlays.push_bubble(actor, &format!("{actor}-{index}")); + } + } + assert_eq!(overlays.bubbles.len(), BUBBLE_MAX_STACK * 2); + assert_eq!( + overlays + .bubbles + .iter() + .filter(|bubble| bubble.actor_id == "a") + .count(), + BUBBLE_MAX_STACK + ); + } + + #[test] + fn wrap_caps_lines_with_ellipsis() { + let lines = wrap_bubble_text(&"word ".repeat(40), 10, 3); + assert_eq!(lines.len(), 3); + assert!(lines[2].ends_with('…')); + let short = wrap_bubble_text("two words", 28, 4); + assert_eq!(short, vec!["two words".to_string()]); + // Oversized single word hard-splits instead of overflowing. + let split = wrap_bubble_text("abcdefghijklmnop", 6, 4); + assert!(split[0].chars().count() <= 6); + } + + #[test] + fn float_pool_recycles_oldest() { + let mut ov = Overlays::new(); + for i in 0..FLOAT_POOL_MAX { + ov.push_float("a", &format!("{i}"), FloatTone::Damage); + } + ov.update(100.0); // age everyone + ov.push_float("a", "newest", FloatTone::Heal); + assert_eq!(ov.floats.len(), FLOAT_POOL_MAX); + assert!(ov.floats.iter().any(|f| f.text == "newest")); + } + + #[test] + fn update_expires_pools() { + let mut ov = Overlays::new(); + ov.push_bubble("a", "hello there"); + ov.push_float("a", "12", FloatTone::Damage); + ov.update(BUBBLE_MAX_TTL_MS + 1.0); + assert!(ov.bubbles.is_empty()); + assert!(ov.floats.is_empty()); + } + + #[test] + fn draw_skips_unresolved_anchors() { + let icons = Icons::load(); + let mut ui = successor_engine_render::ui::UiBuilder::new(icons.meta); + ui.begin(1280, 720); + let mut ov = Overlays::new(); + ov.push_bubble("gone", "hello"); + ov.draw(&mut ui, &palette(0), 1280.0, 720.0, |_| None); + assert_eq!(ui.quads, 0, "no fallback anchor for unknown actors"); + ov.draw(&mut ui, &palette(0), 1280.0, 720.0, |_| { + Some((400.0, 300.0)) + }); + assert!(ui.quads > 0); + } + #[test] + fn hostility_tint_agreement_between_plate_and_overlay() { + let pal = palette(0); + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + ui.begin(1280, 720); + draw_nameplate( + &mut ui, + &pal, + "IMPERIAL SOLDIER", + Some("ROOKIE"), + RelationHud::Hostile, + None, + true, + 1.0, + 400.0, + 300.0, + ); + assert!(ui.quads > 0, "selection indicator draws quads"); + assert_eq!( + hostility_tint(RelationHud::Hostile, &pal), + [0xd3, 0x3b, 0x32, 255], + "the overlay must draw the shared hostile ink, not a theme tone" + ); + } + + #[test] + fn bracket_geometry_stays_inside_plate_bounds() { + let pal = palette(0); + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + ui.begin(1280, 720); + let x = 400.0f32; + let y = 300.0f32; + draw_nameplate( + &mut ui, + &pal, + "TARGET NAME", + Some("DESCRIPTOR"), + RelationHud::Hostile, + Some("TAG"), + true, + 1.0, + x, + y, + ); + + let max_w = ui + .measure_text("TARGET NAME", 1.45) + .max(ui.measure_text("DESCRIPTOR", 1.2)) + .max(ui.measure_text("TAG", 1.25)); + // `y` is the name's baseline, so the plate reaches a full cap height + // above it before the bracket's own padding. + let name_cap = GLYPH_H as f32 * 1.45; + let left = x - max_w * 0.5 - 6.0 - 0.5; + let right = x + max_w * 0.5 + 6.0 + 0.5; + let top = y - name_cap - 3.0 - 0.5; + let bottom = y + 32.0 - 0.5; // descriptor + tag below the baseline + + let mut min_px = f32::MAX; + let mut max_px = f32::MIN; + let mut min_py = f32::MAX; + let mut max_py = f32::MIN; + + for vertex in ui.buf.chunks_exact(8) { + let px = (vertex[0] + 1.0) * 0.5 * 1280.0; + let py = (1.0 - vertex[1]) * 0.5 * 720.0; + min_px = min_px.min(px); + max_px = max_px.max(px); + min_py = min_py.min(py); + max_py = max_py.max(py); + } + + assert!(min_px >= left, "brackets min_px {min_px} >= left {left}"); + assert!(max_px <= right, "brackets max_px {max_px} <= right {right}"); + assert!(min_py >= top, "brackets min_py {min_py} >= top {top}"); + assert!( + max_py <= bottom, + "brackets max_py {max_py} <= bottom {bottom}" + ); + } + + #[test] + fn dead_target_overlay_presentation() { + let pal = palette(0); + let icons = Icons::load(); + + let mut ui_alive = UiBuilder::new(icons.meta); + ui_alive.begin(1280, 720); + draw_nameplate( + &mut ui_alive, + &pal, + "TARGET NAME", + None, + RelationHud::Hostile, + None, + false, + 1.0, + 400.0, + 300.0, + ); + + let mut ui_dead = UiBuilder::new(icons.meta); + ui_dead.begin(1280, 720); + draw_nameplate( + &mut ui_dead, + &pal, + "TARGET NAME", + None, + RelationHud::Hostile, + Some("DEAD"), + false, + 1.0, + 400.0, + 300.0, + ); + + assert!( + ui_dead.quads > ui_alive.quads, + "dead nameplate must emit extra quads for strike-through line" + ); + } +} diff --git a/client-rust/source/app/src/hud/plate.rs b/client-rust/source/app/src/hud/plate.rs new file mode 100644 index 00000000..5f558e7f --- /dev/null +++ b/client-rust/source/app/src/hud/plate.rs @@ -0,0 +1,1207 @@ +//! Status plate, target plate, group HUD, ability queue, interact chip, +//! toasts/banners, first steps and the death/clone overlay — ports of the +//! `client-3d` HUD panes (`statusPlate.ts`, `targetPlate.ts`, `groupHud.ts`, +//! `combatQueue.ts`, `extractionHud.ts`, `firstSteps.ts`, `deathOverlay.ts`) +//! onto the immediate-mode `UiBuilder`. + +use successor_engine_render::ui::{ButtonStyle, UiBuilder}; + +use super::{ + BannerHud, GaugeHud, HudAction, HudState, InteractHud, LifeHud, Palette, QueueEntryStateHud, + RelationHud, SprintHud, TargetHud, GROUP_CHIP_MAX, QUEUE_ROW_MAX, +}; + +/// Physical magazine pips cap (reference `MAX_PIPS`). +pub const MAX_PIPS: u32 = 48; + +/// Measured player-status content well. +/// +/// The height is exactly what the pane always draws: 4 top pad + 17 name/RUN +/// row + 33 pool stack + 5 floor. State chips ride the name row instead of +/// claiming a band of their own, and the weapon/magazine readout is its own +/// pane, so nothing here is reserved for content that may not exist. The +/// target, the group roster, and the weapon readout are separate panes of the +/// same grammar. +pub const PLATE_W: f32 = 300.0; +pub const PLATE_H: f32 = 59.0; + +/// Weapon/magazine pane: one label+rounds row over the pip or swing bar. +pub const WEAPON_PLATE_W: f32 = 300.0; +pub const WEAPON_PLATE_H: f32 = 30.0; + +/// One group member chip: name/state row over a health sliver, plus the gap to +/// the next chip. The reserved group rail is sized from these. +pub const GROUP_CHIP_H: f32 = 30.0; +pub const GROUP_CHIP_GAP: f32 = 4.0; + +fn button_style(pal: &Palette) -> ButtonStyle { + ButtonStyle { + fill: pal.bg_cell, + hover: pal.accent_soft, + active: pal.accent_soft, + edge: pal.hairline, + text: pal.ink, + } +} +pub(crate) fn readable_dim(pal: &Palette) -> [u8; 4] { + let mut dim = pal.ink_dim; + dim[3] = 230; + dim +} + +/// Hostility tint mapping derived systematically from [`RelationHud`] for target +/// plate names, frame accents, world nameplates, and target selection brackets. +pub fn hostility_tint(relation: RelationHud, pal: &Palette) -> [u8; 4] { + relation.tint(pal) +} + +/// Longest player weapon reach the authority will accept, in cells (== metres: +/// `WORLD_UNITS_PER_CELL` is 1.0). Mirrors the 18_000 milli-cell ceiling in the +/// authority's weapon profiles (`crates/successor-sim/src/authority/helpers.rs`). +/// The plate dims past this because `combat_roll.rs:1957` rejects the swing. +pub const MAX_WEAPON_REACH_CELLS: f32 = 18.0; + +/// Pool tints. The original status window gives each pool its own hue so the +/// triple bar reads at a glance; only the third pool follows the theme accent. +pub const POOL_HEALTH: [u8; 4] = [0xD8, 0x42, 0x42, 255]; +pub const POOL_ACTION: [u8; 4] = [0x68, 0xD0, 0x74, 255]; + +/// One pool in a status stack. The tint is the pool's identity, exactly as in +/// the original status window — there is no per-row word label to spend height +/// on; `value` is the compact `current/max` readout. +#[derive(Clone, Copy)] +pub struct Pool<'a> { + pub gauge: &'a GaugeHud, + pub tint: [u8; 4], + pub value: &'a str, +} + +/// Row pitch of a pool stack: a 7 px track plus its gap. The original packs +/// its three bars into roughly this rhythm. +pub const POOL_ROW_H: f32 = 11.0; +/// Readout glyph size inside a pool row. +const POOL_VALUE_PX: f32 = 1.1; +/// Height of a pool stack of `count` rows. +pub fn pool_stack_h(count: usize) -> f32 { + POOL_ROW_H * count as f32 +} + +/// `current/max` readout for a pool, or `--` when the pool is unknown. +fn reading(g: &GaugeHud) -> String { + if g.max > 0.0 { + format!( + "{}/{}", + g.value.max(0.0).round() as i64, + g.max.round() as i64 + ) + } else { + "--".to_string() + } +} + +/// Draw a stack of pool rows into `rect`. +/// +/// Track length encodes each pool's *maximum* relative to the deepest pool in +/// the stack, and the fill encodes current/max — the original status window's +/// convention, where a deeper pool is visibly a longer bar (measured off the +/// clean world-entry capture: 120/143/159 px tracks for 700/900/1000 pools). +/// Low pools (≤25%) tint toward danger. +/// +/// `mirrored` flips the whole stack: labels and tracks anchor to the right +/// edge and readouts run down the left, which is how the original's target +/// window mirrors the player's. +fn draw_pools( + ui: &mut UiBuilder, + pal: &Palette, + rect: [f32; 4], + pools: &[Pool<'_>], + mirrored: bool, + dead: bool, +) { + let [x, y, column, band_h] = rect; + let value_w = pools + .iter() + .fold(0.0f32, |widest, pool| { + widest.max(ui.measure_text(pool.value, POOL_VALUE_PX)) + }) + .min(column * 0.4); + let track_room = (column - value_w - 6.0).max(16.0); + let deepest = pools + .iter() + .fold(0.0f32, |deepest, pool| deepest.max(pool.gauge.max)); + for (index, pool) in pools.iter().enumerate() { + let row_y = y + index as f32 * POOL_ROW_H; + // A resized pane drops rows it cannot show rather than painting them + // outside its own bounds. + if row_y + POOL_ROW_H > y + band_h + 0.5 { + break; + } + let track_w = if deepest <= 0.0 { + track_room + } else { + (track_room * (pool.gauge.max / deepest)).max(16.0) + }; + let (track_x, value_x) = if mirrored { + ( + x + column - track_w, + x + value_w - ui.measure_text(pool.value, POOL_VALUE_PX), + ) + } else { + (x, x + column - ui.measure_text(pool.value, POOL_VALUE_PX)) + }; + ui.rect(track_x, row_y, track_w, 7.0, pal.bg_cell); + let frac = if dead { 0.0 } else { pool.gauge.frac() }; + if frac > 0.0 { + let fill = if pool.gauge.low() { + pal.danger + } else { + pool.tint + }; + let filled = track_w * frac; + let fill_x = if mirrored { + track_x + track_w - filled + } else { + track_x + }; + ui.rect(fill_x, row_y, filled, 7.0, fill); + } + let val_tint = if dead { pal.ink_dim } else { readable_dim(pal) }; + ui.text(pool.value, value_x, row_y, POOL_VALUE_PX, val_tint); + } +} + +/// Player status plate: tags, the three pool bars, RUN toggle and the magazine +/// pips / swing timer. Emits [`HudAction::ToggleSprint`] on RUN click. +/// +/// `rect` is the pane's live bounds. Bands stack from the top and each one is +/// skipped when the pane is too short for it, so a resized plate condenses +/// instead of spilling. The notification strip owns the connection/area line. +pub fn draw_status_plate( + ui: &mut UiBuilder, + pal: &Palette, + st: &HudState, + rect: [f32; 4], + out: &mut Vec, +) { + let [x, y, plate_w, plate_h] = rect; + let bottom = y + plate_h; + let mut backing = pal.bg_panel; + backing[3] = 230; + ui.rect(x, y, plate_w, plate_h, backing); + + // ── State chips ride the name row ──────────────────────────────────── + // A dedicated tag band spent 13 px on content most sessions never carry. + // The chips are short, the name row is 17 px tall, and the original keeps + // state flags beside the name rather than above it. + let mut cursor = y + 4.0; + + // ── Name + RUN toggle share one row ────────────────────────────────── + let (run_label, run_tint) = match st.sprint { + SprintHud::Off => ("RUN", pal.ink_dim), + SprintHud::On => ("RUN", pal.accent), + SprintHud::Winded => ("WINDED", pal.danger), + }; + let run_w = 50.0f32.min((plate_w - 24.0).max(0.0)); + let run_x = x + plate_w - run_w - 6.0; + let name_room = (run_x - (x + 8.0) - 6.0).max(0.0); + let (name, name_clipped) = clip_name(ui, &st.name, 1.9, name_room); + let name_end = ui.text(name, x + 8.0, cursor, 1.9, pal.accent); + if name_clipped { + ui.text("...", name_end, cursor, 1.9, pal.accent); + } + let mut style = button_style(pal); + style.text = run_tint; + if st.sprint == SprintHud::On { + style.edge = pal.accent; + } + if run_w >= 30.0 && ui.button(run_x, cursor - 2.0, run_w, 16.0, run_label, style) { + out.push(HudAction::ToggleSprint); + } + cursor += 17.0; + + // ── Pools: three tight bars, the original's whole status readout ───── + let column = (plate_w - 16.0).max(48.0); + let stack_room = (bottom - cursor - 4.0).max(0.0); + draw_pools( + ui, + pal, + [x + 8.0, cursor, column, stack_room.min(pool_stack_h(3))], + &[ + Pool { + gauge: &st.health, + tint: POOL_HEALTH, + value: &st.health_text, + }, + Pool { + gauge: &st.action, + tint: POOL_ACTION, + value: &st.action_text, + }, + Pool { + gauge: &st.spirit, + tint: pal.accent, + value: &st.spirit_text, + }, + ], + false, + false, + ); + cursor += stack_room.min(pool_stack_h(3)); + + // Chips ride the right of the name row, so nothing below the pools is + // reserved. `cursor` is retained only to document that the pane ends here. + let _ = cursor; + draw_state_chips(ui, pal, st, x, y + 4.0, run_x - (x + 8.0)); +} + +/// State flags as compact chips on the name row, right-aligned into the space +/// the name did not use. Dropped entirely when the row is too narrow — a +/// truncated flag is worse than none. +fn draw_state_chips(ui: &mut UiBuilder, pal: &Palette, st: &HudState, x: f32, y: f32, row_w: f32) { + let mut right = x + 8.0 + row_w; + let mut chip = |ui: &mut UiBuilder, text: &str, tint: [u8; 4]| { + let w = ui.measure_text(text, 1.3) + 8.0; + if right - w < x + 8.0 { + return; + } + ui.rect(right - w, y + 2.0, w, 11.0, pal.bg_cell); + ui.text(text, right - w + 4.0, y + 4.0, 1.3, tint); + right -= w + 5.0; + }; + if st.life != LifeHud::Alive { + let stamp = if st.life == LifeHud::Respawning { + "DEAD" + } else { + "DOWN" + }; + chip(ui, stamp, pal.danger); + } + if let Some(sick) = &st.clone_sickness { + chip(ui, sick, pal.ink_dim); + } + if let Some(campdown) = &st.camp_countdown { + chip(ui, campdown, pal.danger); + } + if let Some(sampler) = &st.sampler_text { + chip(ui, sampler, pal.accent); + } + if st.sheltered { + chip(ui, "SHELTERED", pal.accent); + } + if st.observer { + chip(ui, "OBSERVER", pal.ink_dim); + } +} + +/// Weapon/magazine pane: the label, the round count, and either the magazine +/// pips or the melee swing timer. +/// +/// Its own pane rather than a band inside the status plate: it exists only +/// while something is wielded, and a plate sized to hold it stands half empty +/// the rest of the time. Nothing is painted when there is no weapon, so the +/// registered pane is invisible until it has something to say. +pub fn draw_weapon_plate(ui: &mut UiBuilder, pal: &Palette, st: &HudState, rect: [f32; 4]) { + let Some(weapon) = &st.weapon else { + return; + }; + let [x, y, w, h] = rect; + let bottom = y + h; + let mut backing = pal.bg_panel; + backing[3] = 230; + ui.rect(x, y, w, h, backing); + + let mut cursor = y + 3.0; + if cursor + 10.0 > bottom { + return; + } + ui.text(&weapon.label, x + 8.0, cursor, 1.4, pal.ink); + let rounds_x = x + 8.0 + ui.measure_text(&weapon.label, 1.4) + 8.0; + ui.text(&weapon.rounds_text, rounds_x, cursor, 1.4, pal.ink_dim); + cursor += 11.0; + if cursor + 6.0 > bottom { + return; + } + let bar_w = (w - 16.0).max(0.0); + let bar_x = x + 8.0; + if weapon.melee { + // Swing timer: fill sweeps to READY where pips normally live. + ui.rect(bar_x, cursor, bar_w, 6.0, pal.bg_cell); + let fill = weapon.swing_frac.clamp(0.0, 1.0); + let tint = if weapon.swing_ready { + pal.accent + } else { + pal.ink_dim + }; + ui.rect(bar_x, cursor, bar_w * fill, 6.0, tint); + } else if weapon.magazine_size > 0 { + // One pip per round (<=48); reload sweeps the pips back in. + let count = weapon.magazine_size.min(MAX_PIPS); + let filled = if weapon.reloading { + ((weapon.reload_frac * count as f32).floor() as u32).min(count) + } else { + weapon.loaded_rounds.min(count) + }; + let pip_w = (bar_w / count as f32 - 2.0).clamp(2.0, 10.0); + for index in 0..count { + let px = bar_x + index as f32 * (pip_w + 2.0); + let tint = if index < filled { + pal.accent + } else { + pal.bg_cell + }; + ui.rect(px, cursor, pip_w, 6.0, tint); + } + } +} + +/// Longest prefix of `name` that fits `max_w`, and whether it was cut. +fn clip_name<'a>(ui: &UiBuilder, name: &'a str, px: f32, max_w: f32) -> (&'a str, bool) { + if ui.measure_text(name, px) <= max_w { + return (name, false); + } + let budget = (max_w - ui.measure_text("...", px)).max(0.0); + let mut end = 0; + let mut width = 0.0; + for (offset, ch) in name.char_indices() { + let advance = ui.measure_text(&name[offset..offset + ch.len_utf8()], px); + if width + advance > budget { + break; + } + width += advance; + end = offset + ch.len_utf8(); + } + (&name[..end], true) +} + +/// Target status plate: the player plate mirrored — relation-tinted name and +/// rail on the right, pool tracks anchored right with their readouts down the +/// left, then range/level and state chips. +/// +/// `rect` is the pane's live bounds. Each band is skipped when the pane is too +/// short for it, so nothing escapes the container on a resize. +pub fn draw_target_plate(ui: &mut UiBuilder, pal: &Palette, target: &TargetHud, rect: [f32; 4]) { + let [x, y, w, h] = rect; + let bottom = y + h; + let dead = !target.alive; + let out_of_range = target + .distance_m + .is_some_and(|d| !d.is_finite() || d > MAX_WEAPON_REACH_CELLS); + + let tint = if dead { + readable_dim(pal) + } else { + hostility_tint(target.relation, pal) + }; + let accent_rail_tint = if dead { + pal.bg_cell + } else { + hostility_tint(target.relation, pal) + }; + + let mut backing = pal.bg_panel; + backing[3] = 220; + ui.rect(x, y, w, h, backing); + + // Relation rail on the right edge: the target plate is the player plate + // mirrored. Suppressed when dead. + ui.rect(x + w - 3.0, y, 3.0, h, accent_rail_tint); + + let mut cursor = y + 4.0; + if cursor + 14.0 > bottom { + return; + } + + // Top row: Level chip (left of name), Name (right-aligned), Stamp (if any). + let stamp_w = target + .stamp + .map(|stamp| ui.measure_text(stamp, 1.6) + 10.0 + 6.0) + .unwrap_or(0.0); + + let level_str = target.level.map(|lvl| format!("{lvl}")); + let level_chip_w = level_str + .as_ref() + .map(|s| ui.measure_text(s, 1.2) + 6.0) + .unwrap_or(0.0); + let level_w = if level_str.is_some() { + level_chip_w + 4.0 + } else { + 0.0 + }; + + let name_room = (w - 18.0 - stamp_w - level_w).max(0.0); + let (name, name_clipped) = clip_name(ui, &target.name, 1.9, name_room); + let name_w = ui.measure_text(name, 1.9) + + if name_clipped { + ui.measure_text("...", 1.9) + } else { + 0.0 + }; + + let name_end = ui.text(name, x + w - 8.0 - name_w, cursor, 1.9, tint); + if name_clipped { + ui.text("...", name_end, cursor, 1.9, tint); + } + + // Strike-through line for dead target name + if dead && name_w > 0.0 { + ui.line( + x + w - 8.0 - name_w, + cursor + 7.0, + x + w - 8.0, + cursor + 7.0, + 1.0, + readable_dim(pal), + ); + } + + // Level chip left of the name + if let Some(ref lvl_s) = level_str { + let chip_x = x + w - 8.0 - name_w - level_chip_w - 4.0; + if chip_x >= x + 6.0 { + ui.rect(chip_x, cursor + 2.0, level_chip_w, 13.0, pal.bg_cell); + ui.text( + lvl_s, + chip_x + 3.0, + cursor + 3.0, + 1.2, + if dead { pal.ink_dim } else { readable_dim(pal) }, + ); + } + } + + if let Some(stamp) = target.stamp { + let sw = ui.measure_text(stamp, 1.6); + ui.rect(x + 6.0, cursor - 1.0, sw + 10.0, 14.0, pal.danger); + ui.text(stamp, x + 11.0, cursor, 1.6, [10, 10, 10, 255]); + } + cursor += 17.0; + + // Sub-row: Compact distance right-aligned under name (if present) + if cursor + 10.0 <= bottom { + if let Some(distance) = target.distance_m { + let (dist_text, dist_tint) = + if !distance.is_finite() || distance > MAX_WEAPON_REACH_CELLS { + ("OUT OF RANGE".to_string(), pal.danger) + } else { + ( + format!("{:.0}M", distance.max(0.0)), + if dead { pal.ink_dim } else { readable_dim(pal) }, + ) + }; + let dist_w = ui.measure_text(&dist_text, 1.2); + ui.text(&dist_text, x + w - 8.0 - dist_w, cursor, 1.2, dist_tint); + cursor += 12.0; + } + } + + // Pools, mirrored. Empty when dead. + let hp = &target.health; + let hp_text = reading(hp); + let action_text = target.action.as_ref().map(reading); + let spirit_text = target.spirit.as_ref().map(reading); + let health_pool = Pool { + gauge: hp, + tint: if dead { readable_dim(pal) } else { POOL_HEALTH }, + value: &hp_text, + }; + let mut pools = [health_pool; 3]; + let mut pool_count = 1; + if let (Some(action), Some(text)) = (target.action.as_ref(), action_text.as_deref()) { + pools[pool_count] = Pool { + gauge: action, + tint: if dead { readable_dim(pal) } else { POOL_ACTION }, + value: text, + }; + pool_count += 1; + } + if let (Some(spirit), Some(text)) = (target.spirit.as_ref(), spirit_text.as_deref()) { + pools[pool_count] = Pool { + gauge: spirit, + tint: if dead { readable_dim(pal) } else { pal.accent }, + value: text, + }; + pool_count += 1; + } + let stack_h = pool_stack_h(pool_count).min((bottom - cursor - 2.0).max(0.0)); + draw_pools( + ui, + pal, + [x + 8.0, cursor, (w - 16.0).max(48.0), stack_h], + &pools[..pool_count], + true, + dead, + ); + cursor += stack_h + 3.0; + + // State chips (max 4), right-aligned on the band under the readouts. + // Dimmed when out of range or dead. + let mut cx = x + w - 8.0; + for chip in &target.chips { + let cw = ui.measure_text(&chip.label, 1.3) + 8.0; + if cx - cw < x + 6.0 || cursor + 12.0 > bottom { + break; + } + let chip_tint = if dead || out_of_range { + pal.ink_dim + } else if chip.danger { + pal.danger + } else { + pal.ink_dim + }; + ui.rect(cx - cw, cursor, cw, 12.0, pal.bg_cell); + ui.text(&chip.label, cx - cw + 4.0, cursor + 2.0, 1.3, chip_tint); + cx -= cw + 5.0; + } +} + +/// Group invite toast (top-center) + member rail. Emits GroupAccept/Decline. +/// +/// `rail` is the reserved group rect directly beneath the player status plate; +/// members stack down it and stop at its floor rather than running under the +/// panes below. +pub fn draw_group( + ui: &mut UiBuilder, + pal: &Palette, + st: &HudState, + sw: f32, + rail: [f32; 4], + out: &mut Vec, +) { + if let Some(inviter) = &st.group_invite_from { + let w = 340.0; + let x = (sw - w) * 0.5; + let y = 18.0; + ui.panel(x, y, w, 58.0, pal.bg_panel, pal.accent); + ui.text("GROUP INVITE", x + 10.0, y + 6.0, 1.5, pal.ink_dim); + ui.text(inviter, x + 10.0, y + 20.0, 2.0, pal.ink); + let style = button_style(pal); + if ui.button(x + w - 150.0, y + 26.0, 66.0, 22.0, "JOIN", style) { + out.push(HudAction::GroupAccept); + } + if ui.button(x + w - 78.0, y + 26.0, 66.0, 22.0, "DECLINE", style) { + out.push(HudAction::GroupDecline); + } + } + + // Member rail: one compact chip per OTHER member (≤5 + overflow count). + if st.group_members.is_empty() { + return; + } + let [x, rail_y, rail_w, rail_h] = rail; + let rail_bottom = rail_y + rail_h; + let mut y = rail_y; + for member in st.group_members.iter().take(GROUP_CHIP_MAX) { + let w = rail_w; + if y + GROUP_CHIP_H > rail_bottom { + break; + } + ui.panel(x, y, w, GROUP_CHIP_H, pal.bg_panel, pal.hairline); + if member.leader { + ui.rect(x + 4.0, y + 4.0, 4.0, 4.0, pal.accent); // leader pip + } + ui.text(&member.name, x + 12.0, y + 4.0, 1.5, pal.ink); + let tag = if member.link_dead { + Some(("LD", pal.ink_dim)) + } else if member.down { + Some(("DOWN", pal.danger)) + } else { + None + }; + if let Some((t, tint)) = tag { + let tw = ui.measure_text(t, 1.4); + ui.text(t, x + w - tw - 6.0, y + 4.0, 1.4, tint); + } + // Health sliver. + ui.rect(x + 12.0, y + 20.0, w - 24.0, 4.0, pal.bg_cell); + let frac = member.health_frac.clamp(0.0, 1.0); + if frac > 0.0 { + let tint = if frac <= 0.25 { pal.danger } else { pal.accent }; + ui.rect(x + 12.0, y + 20.0, (w - 24.0) * frac, 4.0, tint); + } + y += GROUP_CHIP_H + GROUP_CHIP_GAP; + } + let overflow = st.group_members.len().saturating_sub(GROUP_CHIP_MAX); + if overflow > 0 && y + 8.0 <= rail_bottom { + ui.text(&format!("+{overflow} MORE"), x, y + 2.0, 1.4, pal.ink_dim); + } +} + +/// ACTION QUEUE — vertically stacked combat queue rows. +/// Click a row's gadget to cancel it (routes `CancelAbilityQueue`). +/// +/// `rect` is the pane's live bounds; rows stop at its bottom edge. +pub fn draw_queue( + ui: &mut UiBuilder, + pal: &Palette, + st: &HudState, + rect: [f32; 4], + out: &mut Vec, +) { + if st.queue.is_empty() && !st.repeat_armed { + return; + } + let [x, y, w, pane_h] = rect; + let bottom = y + pane_h; + let mut ry = y; + if st.repeat_armed { + ui.text("REPEAT ARMED", x + 4.0, ry, 1.4, pal.accent); + ry += 14.0; + } + for entry in st.queue.iter().take(QUEUE_ROW_MAX) { + let h = 26.0; + if ry + h > bottom { + break; + } + let (edge, label_tint) = match entry.state { + QueueEntryStateHud::Ready => (pal.accent, pal.ink), + QueueEntryStateHud::Fired => (pal.accent, pal.accent), + QueueEntryStateHud::Rejected => (pal.danger, pal.danger), + QueueEntryStateHud::Queued => (pal.hairline, pal.ink), + }; + ui.panel(x, ry, w, h, pal.bg_panel, edge); + ui.text(&entry.label, x + 6.0, ry + 4.0, 1.5, label_tint); + if entry.state == QueueEntryStateHud::Rejected && !entry.reason.is_empty() { + ui.text(&entry.reason, x + 6.0, ry + 15.0, 1.3, pal.danger); + } else if !entry.target_label.is_empty() { + ui.text(&entry.target_label, x + 6.0, ry + 15.0, 1.3, pal.ink_dim); + } + // Cancel gadget. + if ui.interact(x + w - 20.0, ry + 4.0, 16.0, 16.0).clicked { + out.push(HudAction::QueueCancel(entry.entry_id.clone())); + } + ui.text("X", x + w - 16.0, ry + 6.0, 1.5, pal.ink_dim); + ry += h + 4.0; + } +} + +/// Interaction chip: `[F] VERB` bottom-center, with an optional hold fill +/// (loot hold-to-take-all radial, drawn as a horizontal sweep). +pub fn draw_interact_chip(ui: &mut UiBuilder, pal: &Palette, chip: &InteractHud, cx: f32, y: f32) { + let text_w = ui.measure_text(&chip.label, 1.8); + let w = text_w + 20.0; + let x = cx - w * 0.5; + ui.panel(x, y, w, 24.0, pal.bg_panel, pal.hairline); + if let Some(frac) = chip.hold_frac { + ui.rect(x, y + 21.0, w * frac.clamp(0.0, 1.0), 3.0, pal.accent); + } + ui.text(&chip.label, x + 10.0, y + 5.0, 1.8, pal.ink); +} + +fn draw_banner_line(ui: &mut UiBuilder, pal: &Palette, banner: &BannerHud, cx: f32, y: f32) { + let text_w = ui.measure_text(&banner.text, 1.7); + let w = text_w + 18.0; + let x = cx - w * 0.5; + let tint = if banner.bad { pal.danger } else { pal.accent }; + ui.rect(x, y, w, 20.0, pal.bg_panel); + + ui.text(&banner.text, x + 9.0, y + 4.0, 1.7, tint); +} + +/// Extraction/camp toast (one step above the toolbar) + the command +/// rejection/status banner. Both auto-expire on `until_ms`. +pub fn draw_toasts( + ui: &mut UiBuilder, + pal: &Palette, + st: &HudState, + now_ms: u64, + sw: f32, + sh: f32, +) { + if let Some(toast) = &st.extraction_toast { + if toast.until_ms > now_ms { + draw_banner_line(ui, pal, toast, sw * 0.5, sh - 200.0); + } + } + if let Some(banner) = &st.banner { + if banner.until_ms > now_ms { + draw_banner_line(ui, pal, banner, sw * 0.5, sh - 226.0); + } + } +} + +/// FIRST STEPS — progressive one-shot guidance rows (bounded, no nag). +pub fn draw_first_steps(ui: &mut UiBuilder, pal: &Palette, st: &HudState, x: f32, y: f32) { + if st.first_steps.is_empty() { + return; + } + let mut ry = y; + ui.text("FIRST STEPS", x, ry, 1.4, pal.ink_dim); + ry += 14.0; + for row in st.first_steps.iter().take(super::FIRST_STEP_ROW_MAX) { + let tint = if row.done { pal.ink_dim } else { pal.ink }; + if !row.key.is_empty() { + let kw = ui.measure_text(&row.key, 1.5) + 6.0; + ui.rect(x, ry, kw, 13.0, pal.bg_cell); + + ui.text(&row.key, x + 3.0, ry + 2.0, 1.5, pal.accent); + ui.text(&row.text, x + kw + 6.0, ry + 2.0, 1.5, tint); + } else { + ui.text(&row.text, x, ry + 2.0, 1.5, tint); + } + if row.done { + // Strike-through for completed rows. + let tw = ui.measure_text(&row.text, 1.5); + ui.rect(x, ry + 7.0, tw + 14.0, 1.0, pal.ink_dim); + } + ry += 16.0; + } +} + +/// DEATH / CLONE overlay — fullscreen state driven by the server life state. +/// Backdrop is visual-only; only the panel takes clicks (chat stays usable). +pub fn draw_death_overlay( + ui: &mut UiBuilder, + pal: &Palette, + st: &HudState, + sw: f32, + sh: f32, + out: &mut Vec, +) { + if st.life == LifeHud::Alive { + return; + } + // Screen-edge vignette (visual only). + let edge = [pal.danger[0], pal.danger[1], pal.danger[2], 46]; + ui.rect(0.0, 0.0, sw, 42.0, edge); + ui.rect(0.0, sh - 42.0, sw, 42.0, edge); + ui.rect(0.0, 42.0, 42.0, sh - 84.0, edge); + ui.rect(sw - 42.0, 42.0, 42.0, sh - 84.0, edge); + + let w = 360.0; + let h = 120.0; + let x = (sw - w) * 0.5; + let y = sh * 0.28; + ui.panel(x, y, w, h, pal.bg_panel, pal.danger); + let (title, help) = if st.life == LifeHud::Downed { + ("YOU ARE DOWN", "HOLD FOR AID - OR BURN A CLONE TO GIVE UP.") + } else { + ("YOU DIED", "ACTIVATE A CLONE TO RETURN TO THE FIELD.") + }; + let tw = ui.measure_text(title, 3.0); + ui.text(title, x + (w - tw) * 0.5, y + 12.0, 3.0, pal.danger); + let hw = ui.measure_text(help, 1.5); + ui.text(help, x + (w - hw) * 0.5, y + 44.0, 1.5, pal.ink_dim); + let mut style = button_style(pal); + style.edge = pal.danger; + if ui.button( + x + (w - 180.0) * 0.5, + y + 68.0, + 180.0, + 30.0, + "ACTIVATE CLONE", + style, + ) { + out.push(HudAction::CloneRespawn); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hud::{palette, GroupMemberHud, Icons}; + use successor_engine_render::ui::UiBuilder; + + fn ui() -> UiBuilder { + UiBuilder::new(Icons::load().meta) + } + + /// The reserved group rail from the default 1280x720 layout. + const GROUP_RAIL: [f32; 4] = { + let l = crate::hud::layout::GROUP_H; + [0.0, PLATE_H + crate::hud::layout::GUTTER, PLATE_W, l] + }; + + #[test] + fn disconnected_plate_draws_without_actions() { + let mut ui = ui(); + ui.begin(1280, 720); + let st = HudState::default(); + let mut out = Vec::new(); + draw_status_plate( + &mut ui, + &palette(0), + &st, + [16.0, 542.0, PLATE_W, PLATE_H], + &mut out, + ); + assert!(ui.quads > 0); + assert!(out.is_empty()); + } + + /// Screen-pixel bounds of everything drawn into `ui` this frame, read back + /// from the NDC vertex buffer (`pos:2, uv:2, color:4`). + fn drawn_bounds(ui: &UiBuilder, sw: f32, sh: f32) -> [f32; 4] { + let mut min = [f32::MAX; 2]; + let mut max = [f32::MIN; 2]; + for vertex in ui.buf.chunks_exact(8) { + let px = (vertex[0] + 1.0) * 0.5 * sw; + let py = (1.0 - vertex[1]) * 0.5 * sh; + min[0] = min[0].min(px); + min[1] = min[1].min(py); + max[0] = max[0].max(px); + max[1] = max[1].max(py); + } + [min[0], min[1], max[0], max[1]] + } + + fn populated_state() -> HudState { + HudState { + name: "A VERY LONG OPERATIVE NAME".into(), + health: GaugeHud { + value: 616.0, + max: 700.0, + }, + action: GaugeHud { + value: 540.0, + max: 900.0, + }, + spirit: GaugeHud { + value: 1000.0, + max: 1000.0, + }, + health_text: "616".into(), + action_text: "540".into(), + spirit_text: "1000".into(), + weapon: Some(crate::hud::WeaponHud { + label: "SLUGTHROWER PISTOL".into(), + magazine_size: 8, + loaded_rounds: 6, + rounds_text: "6/8".into(), + ..Default::default() + }), + ..Default::default() + } + } + + /// Both status panes are resizable surfaces, so every band has to stay + /// inside the rect it was handed — at the resize floor as well as the + /// default size. + #[test] + fn status_panes_stay_inside_every_pane_size() { + let pal = palette(0); + let st = populated_state(); + let target = TargetHud { + name: "RAIDER SCOUT WITH A LONG NAME".into(), + relation: crate::hud::RelationHud::Hostile, + health: GaugeHud { + value: 434.0, + max: 700.0, + }, + action: Some(GaugeHud { + value: 720.0, + max: 900.0, + }), + spirit: Some(GaugeHud { + value: 850.0, + max: 1000.0, + }), + distance_m: Some(23.0), + level: Some(12), + alive: true, + stamp: Some("DOWN"), + chips: vec![ + crate::hud::ChipHud { + label: "HOSTILE".into(), + danger: true, + }, + crate::hud::ChipHud { + label: "SHIELDED".into(), + danger: false, + }, + ], + ..Default::default() + }; + for size in [ + [180.0, 44.0], + [200.0, 56.0], + [PLATE_W, PLATE_H], + [420.0, 160.0], + ] { + let rect = [40.0, 60.0, size[0], size[1]]; + let mut plate_ui = ui(); + plate_ui.begin(1280, 720); + let mut out = Vec::new(); + draw_status_plate(&mut plate_ui, &pal, &st, rect, &mut out); + let bounds = drawn_bounds(&plate_ui, 1280.0, 720.0); + assert!( + bounds[0] >= rect[0] - 0.5 + && bounds[1] >= rect[1] - 0.5 + && bounds[2] <= rect[0] + rect[2] + 0.5 + && bounds[3] <= rect[1] + rect[3] + 0.5, + "player plate escaped {rect:?}: {bounds:?}" + ); + + let mut target_ui = ui(); + target_ui.begin(1280, 720); + draw_target_plate(&mut target_ui, &pal, &target, rect); + let bounds = drawn_bounds(&target_ui, 1280.0, 720.0); + assert!( + bounds[0] >= rect[0] - 0.5 + && bounds[1] >= rect[1] - 0.5 + && bounds[2] <= rect[0] + rect[2] + 0.5 + && bounds[3] <= rect[1] + rect[3] + 0.5, + "target plate escaped {rect:?}: {bounds:?}" + ); + } + } + + #[test] + fn run_button_click_emits_toggle_sprint() { + let mut ui = ui(); + let st = HudState::default(); + let pal = palette(0); + // RUN shares the name row: 50x16 at the pane's right edge, 6 px in, + // two pixels above the name baseline (pane y + 4). + let bx = 16.0 + PLATE_W - 50.0 - 6.0 + 25.0; + let by = 100.0 + 4.0 - 2.0 + 8.0; + ui.set_input(bx, by, true); + ui.begin(1280, 720); + let mut out = Vec::new(); + draw_status_plate( + &mut ui, + &pal, + &st, + [16.0, 100.0, PLATE_W, PLATE_H], + &mut out, + ); + ui.set_input(bx, by, false); + ui.begin(1280, 720); + out.clear(); + draw_status_plate( + &mut ui, + &pal, + &st, + [16.0, 100.0, PLATE_W, PLATE_H], + &mut out, + ); + assert_eq!(out, vec![HudAction::ToggleSprint]); + } + + #[test] + fn death_overlay_only_when_not_alive() { + let mut ui = ui(); + ui.begin(1280, 720); + let mut st = HudState::default(); + let mut out = Vec::new(); + draw_death_overlay(&mut ui, &palette(0), &st, 1280.0, 720.0, &mut out); + assert_eq!(ui.quads, 0); + st.life = LifeHud::Respawning; + ui.begin(1280, 720); + draw_death_overlay(&mut ui, &palette(0), &st, 1280.0, 720.0, &mut out); + assert!(ui.quads > 0); + } + + #[test] + fn queue_rows_are_bounded_and_cancelable() { + let mut ui = ui(); + let pal = palette(0); + let mut st = HudState::default(); + for i in 0..10 { + st.queue.push(crate::hud::QueueEntryHud { + entry_id: format!("q{i}"), + label: "SHOT".into(), + target_label: "TARGET".into(), + state: QueueEntryStateHud::Queued, + reason: String::new(), + }); + } + // Click the first row's cancel gadget: x=1038+232-20+8, y=200+4+8. + let bx = 1038.0 + 232.0 - 20.0 + 8.0; + let by = 200.0 + 4.0 + 8.0; + ui.set_input(bx, by, true); + ui.begin(1280, 720); + let mut out = Vec::new(); + draw_queue(&mut ui, &pal, &st, [1038.0, 200.0, 232.0, 240.0], &mut out); + ui.set_input(bx, by, false); + ui.begin(1280, 720); + out.clear(); + draw_queue(&mut ui, &pal, &st, [1038.0, 200.0, 232.0, 240.0], &mut out); + assert_eq!(out, vec![HudAction::QueueCancel("q0".into())]); + } + + #[test] + fn group_invite_join_and_decline() { + let mut ui = ui(); + let pal = palette(0); + let st = HudState { + group_invite_from: Some("KESTREL".into()), + ..HudState::default() + }; + // JOIN button: x = (1280-340)/2 + 340 - 150 + 33, y = 18 + 26 + 11. + let bx = (1280.0 - 340.0) * 0.5 + 340.0 - 150.0 + 33.0; + let by = 18.0 + 26.0 + 11.0; + ui.set_input(bx, by, true); + ui.begin(1280, 720); + let mut out = Vec::new(); + draw_group(&mut ui, &pal, &st, 1280.0, GROUP_RAIL, &mut out); + ui.set_input(bx, by, false); + ui.begin(1280, 720); + out.clear(); + draw_group(&mut ui, &pal, &st, 1280.0, GROUP_RAIL, &mut out); + assert_eq!(out, vec![HudAction::GroupAccept]); + } + + #[test] + fn group_rail_stacks_under_the_plate_and_stops_at_its_floor() { + // Six members against a rail with room for two: the rail must clip to + // its own floor instead of running down over the panes below it. + let pal = palette(0); + let members: Vec = (0..6) + .map(|i| GroupMemberHud { + actor_id: format!("actor-{i}"), + name: format!("MEMBER {i}"), + leader: i == 0, + health_frac: 1.0, + down: false, + link_dead: false, + }) + .collect(); + let st = HudState { + group_members: members, + ..HudState::default() + }; + let short_rail = [0.0, 100.0, 300.0, GROUP_CHIP_H * 2.0 + GROUP_CHIP_GAP]; + + let mut short_ui = ui(); + short_ui.begin(1280, 720); + let mut out = Vec::new(); + draw_group(&mut short_ui, &pal, &st, 1280.0, short_rail, &mut out); + let clipped = short_ui.quads; + + let mut full_ui = ui(); + full_ui.begin(1280, 720); + out.clear(); + draw_group(&mut full_ui, &pal, &st, 1280.0, GROUP_RAIL, &mut out); + let full = full_ui.quads; + + // The short rail admits strictly fewer chips than the reserved rail, + // and the reserved rail is sized to hold the whole capped run. + assert!(clipped < full, "clipped={clipped} full={full}"); + assert!( + GROUP_RAIL[3] + >= GROUP_CHIP_H * GROUP_CHIP_MAX as f32 + + GROUP_CHIP_GAP * (GROUP_CHIP_MAX as f32 - 1.0) + ); + } + + /// Owner ruling 2026-08-04: NPC and corpse white, passive attackable + /// yellow, anything that will fight you red, neutral player bright blue, + /// guild/faction ally purple. + #[test] + fn relation_tints_match_the_owner_ruling() { + let pal = palette(0); + assert_eq!( + hostility_tint(RelationHud::Hostile, &pal), + [0xd3, 0x3b, 0x32, 255] + ); + assert_eq!( + hostility_tint(RelationHud::Attackable, &pal), + [0xf1, 0xd0, 0x6b, 255] + ); + assert_eq!( + hostility_tint(RelationHud::Social, &pal), + [0xf8, 0xf7, 0xf1, 255] + ); + assert_eq!( + hostility_tint(RelationHud::Player, &pal), + [0x4a, 0xa9, 0xff, 255] + ); + assert_eq!( + hostility_tint(RelationHud::Allied, &pal), + [0xb0, 0x66, 0xff, 255] + ); + } + + /// Relation ink is actor identity, so it must NOT follow the theme - a + /// grouped ally reads the same purple in every palette. + #[test] + fn relation_tints_do_not_follow_the_theme() { + for relation in [ + RelationHud::Hostile, + RelationHud::Attackable, + RelationHud::Social, + RelationHud::Player, + RelationHud::Allied, + ] { + let first = hostility_tint(relation, &palette(0)); + for theme in 1..crate::hud::THEME_COUNT { + assert_eq!( + hostility_tint(relation, &palette(theme)), + first, + "{relation:?} drifted with theme {theme}" + ); + } + } + } + + #[test] + fn target_plate_level_and_distance_presence_and_absence() { + let pal = palette(0); + let t_present = TargetHud { + name: "TARGET".into(), + relation: RelationHud::Hostile, + level: Some(80), + distance_m: Some(14.2), + alive: true, + health: GaugeHud { + value: 100.0, + max: 100.0, + }, + ..Default::default() + }; + let mut ui_pres = ui(); + ui_pres.begin(1280, 720); + draw_target_plate( + &mut ui_pres, + &pal, + &t_present, + [10.0, 10.0, PLATE_W, PLATE_H], + ); + let quads_pres = ui_pres.quads; + + let mut t_absent = t_present.clone(); + t_absent.level = None; + t_absent.distance_m = None; + let mut ui_abs = ui(); + ui_abs.begin(1280, 720); + draw_target_plate(&mut ui_abs, &pal, &t_absent, [10.0, 10.0, PLATE_W, PLATE_H]); + let quads_abs = ui_abs.quads; + + assert!( + quads_pres > quads_abs, + "present level/distance must emit quads for level chip & distance text" + ); + } + + #[test] + fn dead_target_presentation_in_plate() { + let pal = palette(0); + let t_alive = TargetHud { + name: "TARGET".into(), + relation: RelationHud::Hostile, + alive: true, + health: GaugeHud { + value: 100.0, + max: 100.0, + }, + ..Default::default() + }; + let mut t_dead = t_alive.clone(); + t_dead.alive = false; + + let mut ui_a = ui(); + ui_a.begin(1280, 720); + draw_target_plate(&mut ui_a, &pal, &t_alive, [10.0, 10.0, PLATE_W, PLATE_H]); + + let mut ui_d = ui(); + ui_d.begin(1280, 720); + draw_target_plate(&mut ui_d, &pal, &t_dead, [10.0, 10.0, PLATE_W, PLATE_H]); + + // Dead target plate draws empty pools (fewer filled quads than alive full health) and strike-through line + assert!(ui_d.quads > 0, "dead target plate must render UI quads"); + assert!( + !ui_d.buf.is_empty(), + "dead target plate must render vertex data" + ); + } +} diff --git a/client-rust/source/app/src/hud/radar.rs b/client-rust/source/app/src/hud/radar.rs new file mode 100644 index 00000000..ec57ef9a --- /dev/null +++ b/client-rust/source/app/src/hud/radar.rs @@ -0,0 +1,1190 @@ +//! RADAR — the managed-window north-up tactical scope (port of +//! `ui/hud/radar.ts`). +//! +//! Preserves the shared north-up projection contract: `+x` is screen-right / +//! east and negative `y` is screen-up / north. `d_cells` remains the raw +//! world-cell distance so rim clamping keeps the exact bearing while plotting +//! in projected coordinates. Dot clicks take priority over ground clicks +//! (`CLICK_GRAB_PX`); ground clicks inside the scope request a relative move. + +use crate::world::terrain::{sample_terrain, Biome, TerrainSample}; +use core::cell::{Cell, RefCell}; +use core::fmt::Write; + +use successor_engine_render::ui::UiBuilder; + +use super::{HudAction, HudState, Palette, RadarClass, TextBuffer}; + +/// World radius the scope covers (cells). +pub const RADIUS_CELLS: f32 = 96.0; +/// The fixed managed-window content square. Window chrome supplies the title +/// and perimeter, so this renderer must not add another card around it. +pub const CONTENT_SIZE_PX: f32 = super::layout::RADAR_SIZE; +pub const PANEL_W: f32 = CONTENT_SIZE_PX; +pub const PANEL_H: f32 = CONTENT_SIZE_PX; +/// Square occupied by the circular scope, leaving a compact coordinate rail. +pub const SIZE_PX: f32 = 128.0; +const SCOPE_Y: f32 = 2.0; +/// Click grab radius around a dot — dot priority over ground clicks. +pub const CLICK_GRAB_PX: f32 = 11.0; +/// Visible instrument circle radius (px). +pub const SCOPE_RIM_PX: f32 = SIZE_PX / 2.0 - 1.5; +/// Plot scale at the default range: px per cell. Must agree with `scale_for`, +/// which is the single runtime authority now that the range ladder exists. +pub const SCALE: f32 = (SIZE_PX / 2.0 - 9.0) / RADIUS_CELLS; +/// Coordinate readout glyph scale. +const COORD_PX: f32 = 1.15; +/// Air between the scope rim and the coordinate readout. +const COORD_GAP: f32 = 5.0; +/// Coordinate rail kept under the scope: the readout's cap box plus its gap +/// above and a hairline of air below. Nothing else lives down here, so the rail +/// reserves what it draws instead of a rounded-up band. +pub const COORD_RAIL: f32 = COORD_GAP + COORD_PX * 7.0 + 3.0; +/// Cardinal glyph scale. +const CARDINAL_PX: f32 = 1.4; +/// Rim tick length behind each cardinal. `radar.ts` marks all four: at this +/// glyph size the tick, not the letter, is what makes a bearing readable. +const CARDINAL_TICK: f32 = 4.0; +/// Cardinal glyph centre, measured in from the rim. +const CARDINAL_INSET: f32 = 9.5; +/// Smallest legible scope. +pub const MIN_SCOPE_PX: f32 = 96.0; +/// Span rows used to fill the scope face. The draw list is quad-only, so the +/// disc is assembled from horizontal spans; 40 rows is under a pixel of stair +/// stepping at the default scope and stays cheap at the resize ceiling. +const FACE_ROWS: u32 = 40; + +/// Fixed range ladder of visible scope radii in world cells. +pub const RANGE_LADDER_CELLS: &[f32] = &[32.0, 64.0, 96.0, 128.0, 192.0]; +pub const DEFAULT_RANGE_INDEX: usize = 2; // 96.0 cells + +const PREVIEW_GRID_SIZE: usize = 32; + +#[derive(Clone, Copy, Debug, PartialEq)] +struct TerrainCacheKey { + cell_x: i32, + cell_z: i32, + range_index: usize, + seed: i32, + biome: Biome, + pal: Palette, +} + +struct TerrainPreviewCache { + key: Option, + colors: [[u8; 4]; PREVIEW_GRID_SIZE * PREVIEW_GRID_SIZE], + resample_count: usize, +} + +impl TerrainPreviewCache { + const fn new() -> Self { + Self { + key: None, + colors: [[0; 4]; PREVIEW_GRID_SIZE * PREVIEW_GRID_SIZE], + resample_count: 0, + } + } +} + +std::thread_local! { + static CURRENT_RANGE_INDEX: Cell = const { Cell::new(DEFAULT_RANGE_INDEX) }; + static CAMERA_HEADING_RAD: Cell = const { Cell::new(0.0) }; + static TERRAIN_CACHE: RefCell = const { RefCell::new(TerrainPreviewCache::new()) }; +} + +pub fn terrain_cache_resample_count() -> usize { + TERRAIN_CACHE.with(|c| c.borrow().resample_count) +} + +fn sample_to_tint(sample: &TerrainSample, pal: &Palette) -> [u8; 4] { + let b0 = pal.bg_cell[0] as f32; + let b1 = pal.bg_cell[1] as f32; + let b2 = pal.bg_cell[2] as f32; + + let h0 = pal.hairline[0] as f32; + let h1 = pal.hairline[1] as f32; + let h2 = pal.hairline[2] as f32; + + let a0 = pal.accent[0] as f32; + let a1 = pal.accent[1] as f32; + let a2 = pal.accent[2] as f32; + + let d0 = pal.ink_dim[0] as f32; + let d1 = pal.ink_dim[1] as f32; + let d2 = pal.ink_dim[2] as f32; + + let w0 = sample.weights[0]; + let w1 = sample.weights[1]; + let w2 = sample.weights[2]; + + let r = (b0 * (0.65 + 0.35 * w0) + h0 * (0.35 * w1) + a0 * (0.30 * w2) + d0 * 0.05) + * sample.macro_tint; + let g = (b1 * (0.65 + 0.35 * w0) + h1 * (0.35 * w1) + a1 * (0.30 * w2) + d1 * 0.05) + * sample.macro_tint; + let b = (b2 * (0.65 + 0.35 * w0) + h2 * (0.35 * w1) + a2 * (0.30 * w2) + d2 * 0.05) + * sample.macro_tint; + + [ + r.clamp(0.0, 255.0) as u8, + g.clamp(0.0, 255.0) as u8, + b.clamp(0.0, 255.0) as u8, + pal.bg_cell[3], + ] +} + +fn get_or_update_terrain_cache( + player_x: f32, + player_z: f32, + seed: i32, + biome: Biome, + pal: &Palette, +) -> [[u8; 4]; PREVIEW_GRID_SIZE * PREVIEW_GRID_SIZE] { + let range_index = CURRENT_RANGE_INDEX.with(|c| c.get()); + let cell_x = player_x.floor() as i32; + let cell_z = player_z.floor() as i32; + let key = TerrainCacheKey { + cell_x, + cell_z, + range_index, + seed, + biome, + pal: *pal, + }; + + TERRAIN_CACHE.with(|cache_cell| { + let mut cache = cache_cell.borrow_mut(); + if cache.key != Some(key) { + cache.key = Some(key); + cache.resample_count += 1; + let range_radius = RANGE_LADDER_CELLS[range_index.min(RANGE_LADDER_CELLS.len() - 1)]; + let step = (2.0 * range_radius) / (PREVIEW_GRID_SIZE as f32); + for gy in 0..PREVIEW_GRID_SIZE { + let wz = + (cell_z as f64 + 0.5) - range_radius as f64 + (gy as f64 + 0.5) * step as f64; + for gx in 0..PREVIEW_GRID_SIZE { + let wx = (cell_x as f64 + 0.5) - range_radius as f64 + + (gx as f64 + 0.5) * step as f64; + let sample = sample_terrain(seed, wx, wz, biome); + cache.colors[gy * PREVIEW_GRID_SIZE + gx] = sample_to_tint(&sample, pal); + } + } + } + cache.colors + }) +} + +pub fn range_rings_for_radius(radius: f32) -> &'static [f32] { + if (radius - 32.0).abs() < 1e-3 { + &[16.0, 32.0] + } else if (radius - 64.0).abs() < 1e-3 { + &[16.0, 32.0, 48.0, 64.0] + } else if (radius - 96.0).abs() < 1e-3 { + &[24.0, 48.0, 72.0, 96.0] + } else if (radius - 128.0).abs() < 1e-3 { + &[32.0, 64.0, 96.0, 128.0] + } else { + &[48.0, 96.0, 144.0, 192.0] + } +} + +/// Returns the active scope cell radius. +pub fn current_range_radius() -> f32 { + CURRENT_RANGE_INDEX.with(|cell| { + let idx = cell.get().min(RANGE_LADDER_CELLS.len() - 1); + RANGE_LADDER_CELLS[idx] + }) +} + +/// Sets the scope range to the step closest to `radius`. +pub fn set_range_radius(radius: f32) { + let mut best_idx = 0; + let mut best_diff = f32::MAX; + for (i, &r) in RANGE_LADDER_CELLS.iter().enumerate() { + let diff = (r - radius).abs(); + if diff < best_diff { + best_diff = diff; + best_idx = i; + } + } + CURRENT_RANGE_INDEX.with(|cell| cell.set(best_idx)); +} + +/// Step scope range in (smaller world radius, higher zoom). +pub fn step_range_in() -> f32 { + CURRENT_RANGE_INDEX.with(|cell| { + let idx = cell.get().saturating_sub(1); + cell.set(idx); + RANGE_LADDER_CELLS[idx] + }) +} + +/// Step scope range out (larger world radius, wider view). +pub fn step_range_out() -> f32 { + CURRENT_RANGE_INDEX.with(|cell| { + let idx = (cell.get() + 1).min(RANGE_LADDER_CELLS.len() - 1); + cell.set(idx); + RANGE_LADDER_CELLS[idx] + }) +} + +/// Active camera heading angle in radians (0.0 is North-up). +pub fn camera_heading() -> f32 { + CAMERA_HEADING_RAD.with(|cell| cell.get()) +} + +/// Set the camera heading angle in radians. +pub fn set_camera_heading(rad: f32) { + CAMERA_HEADING_RAD.with(|cell| cell.set(rad)); +} + +/// Plot scale for a scope of `scope_px` across. +fn scale_for(scope_px: f32) -> f32 { + let max_r = (scope_px / 2.0 - 9.0).max(1.0); + max_r / current_range_radius() +} + +/// Fill the circular instrument face. The radar also renders chromeless on the +/// HUD, where no managed window supplies a background — without this the grid, +/// cardinals and contacts sit directly on the world and stop being readable. +fn fill_scope_face(ui: &mut UiBuilder, cx: f32, cy: f32, r: f32, rgba: [u8; 4]) { + let step = 2.0 * r / FACE_ROWS as f32; + for row in 0..FACE_ROWS { + let top = cy - r + row as f32 * step; + let t = (top + step * 0.5 - cy) / r; + let half = r * (1.0 - t * t).max(0.0).sqrt(); + if half > 0.4 { + ui.rect(cx - half, top, half * 2.0, step + 0.6, rgba); + } + } +} + +/// Scope square for a pane rect: the largest centred circle the pane can hold +/// above its coordinate rail. +fn scope_of(rect: [f32; 4]) -> (f32, f32, f32) { + let [x, y, w, h] = rect; + // The scope is inset by `SCOPE_Y` at the top and must still clear the + // coordinate rail below, so both come out of the available height before + // the square is sized. + let size = w.min(h - COORD_RAIL - SCOPE_Y).max(16.0); + (x + (w - size) * 0.5, y + SCOPE_Y, size) +} + +/// True when a scope-local point lies inside the visible circle. +pub fn point_in_scope(x: f32, y: f32, center: f32, rim: f32) -> bool { + let dx = x - center; + let dy = y - center; + dx * dx + dy * dy <= rim * rim +} + +/// A classified, projected contact: scope-local plot offsets plus the raw +/// distance. Rim clamping preserves bearing. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PlottedContact { + pub sx: f32, + pub sy: f32, + pub d_cells: f32, + pub clamped: bool, +} + +/// Project raw north-up world deltas into scope-local px offsets, clamping to +/// the rim while preserving the exact bearing. +pub fn plot_contact_at(dx_cells: f32, dy_cells: f32, scope_px: f32) -> PlottedContact { + let d_cells = (dx_cells * dx_cells + dy_cells * dy_cells).sqrt(); + let heading = camera_heading(); + let (sin_h, cos_h) = heading.sin_cos(); + let rx_cells = dx_cells * cos_h - dy_cells * sin_h; + let ry_cells = dx_cells * sin_h + dy_cells * cos_h; + + let scale = scale_for(scope_px); + let mut sx = rx_cells * scale; + let mut sy = ry_cells * scale; + let r = (sx * sx + sy * sy).sqrt(); + let max_r = (scope_px / 2.0 - 9.0).max(1.0); + let range_radius = current_range_radius(); + let clamped = d_cells > range_radius + 1e-4 || r > max_r + 1e-4; + if clamped && r > 0.0 { + sx = sx / r * max_r; + sy = sy / r * max_r; + } + PlottedContact { + sx, + sy, + d_cells, + clamped, + } +} + +/// Plot against the default scope size. +pub fn plot_contact(dx_cells: f32, dy_cells: f32) -> PlottedContact { + plot_contact_at(dx_cells, dy_cells, SIZE_PX) +} + +/// Resolve a scope click (scope-local px). Dot hit takes priority; ground +/// clicks inside the rim become relative move requests. +pub fn click_action_at( + st: &HudState, + click_x: f32, + click_y: f32, + scope_px: f32, +) -> Option { + let center = scope_px / 2.0; + if !point_in_scope(click_x, click_y, center, center - 1.5) { + return None; + } + // Nearest dot within the grab radius wins. + let mut best: Option<(f32, &str)> = None; + for contact in &st.radar_contacts { + let plotted = plot_contact_at(contact.dx_cells, contact.dy_cells, scope_px); + let dx = center + plotted.sx - click_x; + let dy = center + plotted.sy - click_y; + let d2 = dx * dx + dy * dy; + if d2 <= CLICK_GRAB_PX * CLICK_GRAB_PX && best.map(|(bd, _)| d2 < bd).unwrap_or(true) { + best = Some((d2, contact.actor_id.as_str())); + } + } + if let Some((_, id)) = best { + return Some(HudAction::RadarSelect(id.to_string())); + } + let scale = scale_for(scope_px); + let heading = camera_heading(); + let (sin_h, cos_h) = heading.sin_cos(); + let rx_px = click_x - center; + let ry_px = click_y - center; + let rx_cells = rx_px / scale; + let ry_cells = ry_px / scale; + let dx_cells = rx_cells * cos_h + ry_cells * sin_h; + let dy_cells = -rx_cells * sin_h + ry_cells * cos_h; + Some(HudAction::RadarMove { dx_cells, dy_cells }) +} + +/// Resolve a click against the default scope size. +pub fn click_action(st: &HudState, click_x: f32, click_y: f32) -> Option { + click_action_at(st, click_x, click_y, SIZE_PX) +} + +fn class_tint(class: RadarClass, pal: &Palette) -> [u8; 4] { + let rel = match class { + RadarClass::Hostile => super::RelationHud::Hostile, + RadarClass::Passive => super::RelationHud::Attackable, + RadarClass::Civilian => super::RelationHud::Social, + }; + super::plate::hostility_tint(rel, pal) +} + +/// Draw the scope face, contacts, waypoint chevrons and cardinals inside the +/// managed window content; route clicks unless the pointer is captured +/// elsewhere. +/// +/// `rect` is the pane's live content bounds — the scope grows and shrinks with +/// the frame instead of clipping at a fixed square. +pub fn draw_radar( + ui: &mut UiBuilder, + pal: &Palette, + st: &HudState, + rect: [f32; 4], + captured: bool, + out: &mut Vec, +) { + let [x, y, pane_w, pane_h] = rect; + let (scope_x, scope_y, scope) = scope_of(rect); + let c = scope * 0.5; + let cx = scope_x + c; + let cy = scope_y + c; + let rim = c - 1.5; + let grid_color = [pal.hairline[0], pal.hairline[1], pal.hairline[2], 90]; + let range_radius = current_range_radius(); + let scale = scale_for(scope); + let heading = camera_heading(); + let (sin_h, cos_h) = heading.sin_cos(); + + // 1. Terrain preview backdrop clipped to scope circle rim + if let Some((player_x, player_z)) = st.position { + let colors = get_or_update_terrain_cache(player_x, player_z, st.world_seed, st.biome, pal); + let rows = FACE_ROWS as usize; + let step_y = 2.0 * rim / rows as f32; + let step_x = 2.0 * rim / rows as f32; + + for row in 0..rows { + let top = cy - rim + row as f32 * step_y; + let cy_row = top + step_y * 0.5; + let dy_rim = (cy_row - cy) / rim; + if dy_rim.abs() >= 1.0 { + continue; + } + let half_w = rim * (1.0 - dy_rim * dy_rim).max(0.0).sqrt(); + if half_w <= 0.4 { + continue; + } + + for col in 0..rows { + let left = cx - rim + col as f32 * step_x; + let right = left + step_x; + let r_left = left.max(cx - half_w); + let r_right = right.min(cx + half_w); + if r_right <= r_left { + continue; + } + let w = r_right - r_left; + let cx_col = (r_left + r_right) * 0.5; + + let rx_px = cx_col - cx; + let ry_px = cy_row - cy; + let rx_cells = rx_px / scale; + let ry_cells = ry_px / scale; + + let dx_cells = rx_cells * cos_h + ry_cells * sin_h; + let dy_cells = -rx_cells * sin_h + ry_cells * cos_h; + + let gx = (((dx_cells + range_radius) / (2.0 * range_radius) + * (PREVIEW_GRID_SIZE as f32)) + .floor() as i32) + .clamp(0, (PREVIEW_GRID_SIZE - 1) as i32) as usize; + let gy = (((dy_cells + range_radius) / (2.0 * range_radius) + * (PREVIEW_GRID_SIZE as f32)) + .floor() as i32) + .clamp(0, (PREVIEW_GRID_SIZE - 1) as i32) as usize; + + let color = colors[gy * PREVIEW_GRID_SIZE + gx]; + ui.rect(r_left, top, w + 0.5, step_y + 0.6, color); + } + } + } else { + fill_scope_face(ui, cx, cy, rim, pal.bg_cell); + } + + // 2. World cell grid (aligned to world cells, sliding with position) + if let Some((player_x, player_z)) = st.position { + let grid_step = (range_radius / 4.0).max(8.0); + let min_kx = ((player_x - range_radius) / grid_step).floor() as i32; + let max_kx = ((player_x + range_radius) / grid_step).ceil() as i32; + for k in min_kx..=max_kx { + let wx = k as f32 * grid_step; + let dx_cells = wx - player_x; + let r_cell = dx_cells * scale; + if r_cell.abs() < rim { + let h = (rim * rim - r_cell * r_cell).max(0.0).sqrt(); + let x1 = cx + r_cell * cos_h - h * sin_h; + let y1 = cy + r_cell * sin_h + h * cos_h; + let x2 = cx + r_cell * cos_h + h * sin_h; + let y2 = cy + r_cell * sin_h - h * cos_h; + ui.line(x1, y1, x2, y2, 0.8, grid_color); + } + } + + let min_kz = ((player_z - range_radius) / grid_step).floor() as i32; + let max_kz = ((player_z + range_radius) / grid_step).ceil() as i32; + for k in min_kz..=max_kz { + let wz = k as f32 * grid_step; + let dy_cells = wz - player_z; + let r_cell = dy_cells * scale; + if r_cell.abs() < rim { + let h = (rim * rim - r_cell * r_cell).max(0.0).sqrt(); + let x1 = cx - r_cell * sin_h - h * cos_h; + let y1 = cy + r_cell * cos_h - h * sin_h; + let x2 = cx - r_cell * sin_h + h * cos_h; + let y2 = cy + r_cell * cos_h + h * sin_h; + ui.line(x1, y1, x2, y2, 0.8, grid_color); + } + } + } + + // Brighter N-S/E-W axis lines on top of grid + let axis_color = [pal.hairline[0], pal.hairline[1], pal.hairline[2], 180]; + ui.line(cx - rim, cy, cx + rim, cy, 1.0, axis_color); + ui.line(cx, cy - rim, cx, cy + rim, 1.0, axis_color); + + // Perimeter rim ring + ui.ring(cx, cy, rim, 72, 1.2, pal.hairline); + ui.ring(cx, cy, rim - 3.0, 72, 0.6, grid_color); + + // 3. Range rings labelled in metres + let rings = range_rings_for_radius(range_radius); + let (diag_sin, diag_cos) = (heading + core::f32::consts::FRAC_PI_4).sin_cos(); + for &r_cells in rings { + let r_px = r_cells * scale; + if r_px <= rim - 3.0 { + ui.ring(cx, cy, r_px, 56, 0.6, grid_color); + + let mut buf = TextBuffer::<48>::new(); + let _ = write!(&mut buf, "{:.0}m", r_cells); + let label_str = buf.as_str(); + let label_w = ui.measure_text(label_str, 0.95); + + let lx = cx + diag_sin * r_px; + let ly = cy - diag_cos * r_px; + + ui.text(label_str, lx - label_w * 0.5, ly - 3.5, 0.95, pal.ink_dim); + } + } + // Cardinals: mark directions with a rim tick plus glyph, rotating with + // camera heading to track orientation. + let heading = camera_heading(); + let (sin_h, cos_h) = heading.sin_cos(); + let cardinals: [(&str, f32, f32, bool); 4] = [ + ("N", sin_h, -cos_h, true), + ("E", cos_h, sin_h, false), + ("S", -sin_h, cos_h, false), + ("W", -cos_h, -sin_h, false), + ]; + for (glyph, ux, uy, primary) in cardinals { + let (ink, tick, weight) = if primary { + (pal.accent, pal.accent, 1.4) + } else { + (pal.ink, pal.ink_dim, 1.0) + }; + ui.line( + cx + ux * (rim - CARDINAL_TICK), + cy + uy * (rim - CARDINAL_TICK), + cx + ux * (rim - 0.5), + cy + uy * (rim - 0.5), + weight, + tick, + ); + let label_r = rim - CARDINAL_INSET; + let glyph_w = ui.measure_text(glyph, CARDINAL_PX); + ui.text( + glyph, + cx + ux * label_r - glyph_w * 0.5, + cy + uy * label_r - CARDINAL_PX * 3.5, + CARDINAL_PX, + ink, + ); + } + + for wp in &st.radar_waypoints { + let plotted = plot_contact_at(wp.dx_cells, wp.dy_cells, scope); + let px = cx + plotted.sx; + let py = cy + plotted.sy; + ui.line(px - 3.0, py + 2.0, px, py - 2.0, 1.4, [232, 168, 74, 255]); + ui.line(px, py - 2.0, px + 3.0, py + 2.0, 1.4, [232, 168, 74, 255]); + } + + let target_id = st.target.as_ref().map(|t| t.actor_id.as_str()); + let target_dead_or_down = st + .target + .as_ref() + .is_some_and(|t| !t.alive || t.stamp.is_some()); + + for contact in &st.radar_contacts { + let plotted = plot_contact_at(contact.dx_cells, contact.dy_cells, scope); + let is_selected = target_id == Some(contact.actor_id.as_str()); + let is_dead = is_selected && target_dead_or_down; + let px = cx + plotted.sx; + let py = cy + plotted.sy; + + if is_dead { + let dim_tint = super::plate::readable_dim(pal); + ui.line(px - 3.0, py - 3.0, px + 3.0, py + 3.0, 1.2, dim_tint); + ui.line(px - 3.0, py + 3.0, px + 3.0, py - 3.0, 1.2, dim_tint); + } else { + let tint = class_tint(contact.class, pal); + if plotted.clamped { + ui.ring(px, py, 2.0, 12, 1.0, tint); + } else { + let mut glow = tint; + glow[3] = 90; + ui.ring(px, py, 4.0, 16, 2.0, glow); + ui.rect(px - 2.0, py - 2.0, 4.0, 4.0, tint); + } + } + + if is_selected { + let accent = pal.accent; + ui.ring(px, py, 7.0, 16, 1.6, accent); + } + } + + ui.ring(cx, cy, 5.0, 20, 2.0, [42, 225, 231, 80]); + ui.rect(cx - 2.0, cy - 2.0, 4.0, 4.0, pal.accent); + let mut coords = TextBuffer::<48>::new(); + if let Some((east, north)) = st.position { + let _ = write!(&mut coords, "E {:.0} / N {:.0}", east, north); + } else { + let _ = coords.write_str("E -- / N --"); + } + let coord_w = ui.measure_text(coords.as_str(), COORD_PX); + // The readout hugs the instrument it describes rather than the pane floor, + // so a pane taller than its scope carries no dead band between the two. The + // clamp keeps it inside the rail when the pane is exactly scope-sized, and + // above the floor when the scope has bottomed out at MIN_SCOPE_PX. + let coord_y = (scope_y + scope + COORD_GAP).min(y + pane_h - COORD_RAIL + COORD_GAP); + ui.text( + coords.as_str(), + x + (pane_w - coord_w) * 0.5, + coord_y, + COORD_PX, + pal.ink_dim, + ); + // Compact range control pair in top-right of pane. + let btn_y = y + 2.0; + let btn_out_x = x + pane_w - 52.0; + let btn_in_x = x + pane_w - 18.0; + + ui.rect(btn_out_x, btn_y, 14.0, 14.0, pal.bg_cell); + ui.border(btn_out_x, btn_y, 14.0, 14.0, 1.0, pal.hairline); + ui.text("-", btn_out_x + 4.0, btn_y + 1.0, 1.1, pal.ink_dim); + + let range_cur = current_range_radius(); + let mut range_buf = TextBuffer::<48>::new(); + let _ = write!(&mut range_buf, "{:.0}m", range_cur); + let range_w = ui.measure_text(range_buf.as_str(), 1.1); + ui.text( + range_buf.as_str(), + btn_out_x + 16.0 + (18.0 - range_w) * 0.5, + btn_y + 1.0, + 1.1, + pal.ink_dim, + ); + + ui.rect(btn_in_x, btn_y, 14.0, 14.0, pal.bg_cell); + ui.border(btn_in_x, btn_y, 14.0, 14.0, 1.0, pal.hairline); + ui.text("+", btn_in_x + 3.0, btn_y + 1.0, 1.1, pal.ink_dim); + + if !captured { + let resp_out = ui.interact(btn_out_x, btn_y, 14.0, 14.0); + let resp_in = ui.interact(btn_in_x, btn_y, 14.0, 14.0); + if resp_out.clicked { + step_range_out(); + } else if resp_in.clicked { + step_range_in(); + } else { + let resp = ui.interact(scope_x, scope_y, scope, scope); + if resp.clicked { + let (mx, my) = ui.mouse(); + if let Some(action) = click_action_at(st, mx - scope_x, my - scope_y, scope) { + out.push(action); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hud::{RadarContactHud, RadarWaypointHud}; + + #[test] + fn plot_preserves_bearing_when_clamped() { + // A contact far east: clamps to the rim, still due east. + let p = plot_contact(500.0, 0.0); + assert!(p.clamped); + assert!(p.sx > 0.0); + assert!(p.sy.abs() < 1e-4); + assert!((p.sx - (SIZE_PX / 2.0 - 9.0)).abs() < 1e-3); + assert!((p.d_cells - 500.0).abs() < 1e-3); + // Bearing preserved on a diagonal clamp. + let q = plot_contact(300.0, 300.0); + assert!(q.clamped); + assert!((q.sx - q.sy).abs() < 1e-3); + } + + #[test] + fn plot_inside_radius_is_linear() { + let p = plot_contact(48.0, -24.0); + assert!(!p.clamped); + assert!((p.sx - 48.0 * SCALE).abs() < 1e-4); + assert!((p.sy + 24.0 * SCALE).abs() < 1e-4); + } + + #[test] + fn click_prefers_dot_over_ground() { + let mut st = HudState::default(); + st.radar_contacts.push(RadarContactHud { + actor_id: "bandit".into(), + dx_cells: 10.0, + dy_cells: 0.0, + class: RadarClass::Hostile, + }); + let c = SIZE_PX / 2.0; + let dot_x = c + 10.0 * SCALE; + // Click within the grab radius of the dot → select. + match click_action(&st, dot_x + 4.0, c + 2.0) { + Some(HudAction::RadarSelect(id)) => assert_eq!(id, "bandit"), + other => panic!("expected select, got {other:?}"), + } + // Ground click away from any dot → relative move with correct cells. + match click_action(&st, c + 40.0, c) { + Some(HudAction::RadarMove { dx_cells, dy_cells }) => { + assert!((dx_cells - 40.0 / SCALE).abs() < 1e-3); + assert!(dy_cells.abs() < 1e-3); + } + other => panic!("expected move, got {other:?}"), + } + // Outside the scope circle → the world owns the click. + assert_eq!(click_action(&st, 1.0, 1.0), None); + } + + #[test] + fn scope_fits_every_pane_size_it_is_given() { + assert_eq!(PANEL_W, crate::hud::layout::RADAR_SIZE); + assert_eq!(PANEL_H, PANEL_W); + for pane in [ + [0.0, 0.0, PANEL_W, PANEL_H], + [10.0, 20.0, 96.0, 116.0], + [0.0, 0.0, 320.0, 240.0], + ] { + let (scope_x, scope_y, scope) = scope_of(pane); + assert!(scope >= MIN_SCOPE_PX, "scope {scope} under the floor"); + assert!(scope_x >= pane[0] - 1e-3, "scope left of its pane"); + assert!( + scope_x + scope <= pane[0] + pane[2].max(scope) + 1e-3, + "scope wider than its pane" + ); + assert!((scope_y - (pane[1] + SCOPE_Y)).abs() < 1e-3); + // The coordinate rail always has room under the scope. + assert!(scope + COORD_RAIL >= pane[3].min(scope + COORD_RAIL)); + } + } + + #[test] + fn draw_radar_renders_waypoints_and_contacts() { + let icons = crate::hud::Icons::load(); + let mut ui = successor_engine_render::ui::UiBuilder::new(icons.meta); + ui.begin(1280, 720); + let mut st = HudState::default(); + let mut out = Vec::new(); + draw_radar( + &mut ui, + &crate::hud::palette(0), + &st, + [1100.0, 16.0, PANEL_W, PANEL_H], + false, + &mut out, + ); + let base = ui.quads; + st.radar_contacts.push(RadarContactHud { + actor_id: "a".into(), + dx_cells: 5.0, + dy_cells: 5.0, + class: RadarClass::Passive, + }); + st.radar_waypoints.push(RadarWaypointHud { + id: 1, + dx_cells: -30.0, + dy_cells: 12.0, + }); + ui.begin(1280, 720); + draw_radar( + &mut ui, + &crate::hud::palette(0), + &st, + [1100.0, 16.0, PANEL_W, PANEL_H], + false, + &mut out, + ); + assert!(ui.quads > base); + assert!(out.is_empty()); + } + + #[test] + fn scope_face_is_opaque_so_the_chromeless_radar_is_readable() { + // The radar also renders without managed-window chrome. Without its own + // face the grid and contacts sit on bare world pixels, so the face must + // be a filled disc, not a ring. + let mut ui = UiBuilder::new(crate::hud::Icons::load().meta); + let st = HudState::default(); + let mut out = Vec::new(); + ui.begin(1280, 720); + draw_radar( + &mut ui, + &crate::hud::palette(0), + &st, + [0.0, 560.0, PANEL_W, PANEL_H], + false, + &mut out, + ); + assert!( + ui.quads >= FACE_ROWS, + "scope face missing: {} quads for {FACE_ROWS} span rows", + ui.quads + ); + } + #[test] + fn blips_clamp_to_radar_disc_instead_of_escaping_it() { + set_range_radius(96.0); + set_camera_heading(0.0); + let scope = 128.0; + let c = scope * 0.5; + let rim = c - 1.5; + let max_r = c - 9.0; + for (dx, dy) in [(500.0, 0.0), (-300.0, 300.0), (0.0, -1000.0)] { + let p = plot_contact_at(dx, dy, scope); + assert!(p.clamped, "far contact ({dx},{dy}) must clamp"); + let r = (p.sx * p.sx + p.sy * p.sy).sqrt(); + assert!( + (r - max_r).abs() < 1e-3, + "clamped dist {r} must equal max_r {max_r}" + ); + assert!( + r + 4.0 <= rim + 1e-3, + "blip boundary {r}+4.0 escapes scope rim {rim}" + ); + } + } + + #[test] + fn contact_at_range_edge_handled_without_flicker() { + set_range_radius(96.0); + set_camera_heading(0.0); + let range = current_range_radius(); + let p_exact = plot_contact_at(range, 0.0, 128.0); + assert!( + !p_exact.clamped, + "contact at exact range edge must NOT clamp" + ); + assert!((p_exact.sx - (128.0 / 2.0 - 9.0)).abs() < 1e-3); + + let p_inside = plot_contact_at(range - 0.001, 0.0, 128.0); + assert!(!p_inside.clamped, "contact inside edge must NOT clamp"); + + let p_outside = plot_contact_at(range + 0.1, 0.0, 128.0); + assert!(p_outside.clamped, "contact outside edge must clamp"); + } + + #[test] + fn pane_resizing_keeps_all_metrics_correct() { + set_range_radius(96.0); + set_camera_heading(0.0); + for pane in [ + [0.0, 0.0, 80.0, 80.0], + [10.0, 20.0, 128.0, 128.0], + [0.0, 0.0, 320.0, 240.0], + ] { + let (sx, sy, scope) = scope_of(pane); + assert!(sx >= pane[0] - 1e-3, "scope left inside pane bounds"); + assert!( + sx + scope <= pane[0] + pane[2] + 1e-3, + "scope right inside pane bounds" + ); + assert!(sy >= pane[1] - 1e-3, "scope top inside pane bounds"); + assert!( + sy + scope + COORD_RAIL <= pane[1] + pane[3] + 1e-3, + "scope floor inside pane bounds" + ); + + let scale = scale_for(scope); + assert!(scale > 0.0, "scale must be positive"); + let max_r = (scope / 2.0 - 9.0).max(1.0); + assert!( + (scale - max_r / 96.0).abs() < 1e-4, + "scale must derive from scope max_r" + ); + } + } + + #[test] + fn north_indicator_tracks_camera() { + set_range_radius(96.0); + set_camera_heading(0.0); + let p_north = plot_contact_at(0.0, -50.0, 128.0); + assert!(p_north.sx.abs() < 1e-3); + assert!( + p_north.sy < 0.0, + "North is screen-up when camera heading is 0" + ); + + set_camera_heading(std::f32::consts::FRAC_PI_2); + let p_north_rot = plot_contact_at(0.0, -50.0, 128.0); + assert!( + p_north_rot.sx > 0.0, + "North rotates to screen-right when camera looks East" + ); + assert!(p_north_rot.sy.abs() < 1e-3); + + set_camera_heading(0.0); + } + + #[test] + fn click_maps_to_correct_world_cell_in_both_directions() { + set_range_radius(96.0); + set_camera_heading(0.0); + let st = HudState::default(); + let scope = 128.0; + let c = scope * 0.5; + + let dx_init = 40.0f32; + let dy_init = -20.0f32; + let plotted = plot_contact_at(dx_init, dy_init, scope); + let click_x = c + plotted.sx; + let click_y = c + plotted.sy; + match click_action_at(&st, click_x, click_y, scope) { + Some(HudAction::RadarMove { dx_cells, dy_cells }) => { + assert!( + (dx_cells - dx_init).abs() < 1e-3, + "round trip dx mismatch: {dx_cells} vs {dx_init}" + ); + assert!( + (dy_cells - dy_init).abs() < 1e-3, + "round trip dy mismatch: {dy_cells} vs {dy_init}" + ); + } + other => panic!("expected move action, got {other:?}"), + } + + let test_click_x = c + 25.0; + let test_click_y = c - 15.0; + if let Some(HudAction::RadarMove { dx_cells, dy_cells }) = + click_action_at(&st, test_click_x, test_click_y, scope) + { + let replotted = plot_contact_at(dx_cells, dy_cells, scope); + assert!((c + replotted.sx - test_click_x).abs() < 1e-3); + assert!((c + replotted.sy - test_click_y).abs() < 1e-3); + } else { + panic!("expected move action"); + } + } + + #[test] + fn selection_is_visually_distinct() { + let icons = crate::hud::Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let mut st = HudState::default(); + st.radar_contacts.push(RadarContactHud { + actor_id: "target_alpha".into(), + dx_cells: 20.0, + dy_cells: 10.0, + class: RadarClass::Hostile, + }); + let mut out = Vec::new(); + + ui.begin(1280, 720); + draw_radar( + &mut ui, + &crate::hud::palette(0), + &st, + [0.0, 0.0, 128.0, 128.0], + false, + &mut out, + ); + let quads_unselected = ui.quads; + + st.target = Some(crate::hud::TargetHud { + actor_id: "target_alpha".into(), + name: "Target Alpha".into(), + alive: true, + ..Default::default() + }); + ui.begin(1280, 720); + draw_radar( + &mut ui, + &crate::hud::palette(0), + &st, + [0.0, 0.0, 128.0, 128.0], + false, + &mut out, + ); + let quads_selected = ui.quads; + + assert!( + quads_selected > quads_unselected, + "selected target must emit extra quads for selection reticle" + ); + } + + #[test] + fn dead_or_downed_contacts_are_distinguishable_from_live_ones() { + let icons = crate::hud::Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let mut st = HudState::default(); + st.radar_contacts.push(RadarContactHud { + actor_id: "target_alpha".into(), + dx_cells: 20.0, + dy_cells: 10.0, + class: RadarClass::Hostile, + }); + let mut out = Vec::new(); + + st.target = Some(crate::hud::TargetHud { + actor_id: "target_alpha".into(), + alive: true, + ..Default::default() + }); + ui.begin(1280, 720); + draw_radar( + &mut ui, + &crate::hud::palette(0), + &st, + [0.0, 0.0, 128.0, 128.0], + false, + &mut out, + ); + + st.target = Some(crate::hud::TargetHud { + actor_id: "target_alpha".into(), + alive: false, + stamp: Some("DEAD"), + ..Default::default() + }); + let mut ui_dead = UiBuilder::new(icons.meta); + ui_dead.begin(1280, 720); + draw_radar( + &mut ui_dead, + &crate::hud::palette(0), + &st, + [0.0, 0.0, 128.0, 128.0], + false, + &mut out, + ); + + assert_ne!( + ui.quads, ui_dead.quads, + "dead contact rendering must differ from live contact" + ); + } + + #[test] + fn range_stepping_ladder() { + assert_eq!(RANGE_LADDER_CELLS, &[32.0, 64.0, 96.0, 128.0, 192.0]); + set_range_radius(96.0); + assert_eq!(current_range_radius(), 96.0); + + assert_eq!(step_range_in(), 64.0); + assert_eq!(current_range_radius(), 64.0); + assert_eq!(step_range_in(), 32.0); + assert_eq!(step_range_in(), 32.0); + + assert_eq!(step_range_out(), 64.0); + assert_eq!(step_range_out(), 96.0); + assert_eq!(step_range_out(), 128.0); + assert_eq!(step_range_out(), 192.0); + assert_eq!(step_range_out(), 192.0); + + set_range_radius(100.0); + assert_eq!(current_range_radius(), 96.0); + } + + #[test] + fn preview_cache_not_resampling_when_unmoved() { + let pal = crate::hud::palette(0); + let count_start = terrain_cache_resample_count(); + let _ = get_or_update_terrain_cache(100.1, 200.1, 42, Biome::Desert, &pal); + let count_after_first = terrain_cache_resample_count(); + assert!(count_after_first > count_start); + + // Sub-cell movement inside same cell (100, 200) -> NO resample! + let _ = get_or_update_terrain_cache(100.4, 200.4, 42, Biome::Desert, &pal); + let count_after_subcell = terrain_cache_resample_count(); + assert_eq!( + count_after_subcell, count_after_first, + "must not resample when player stayed in cell (100, 200)" + ); + + // Crossing cell boundary to (101, 200) -> resample! + let _ = get_or_update_terrain_cache(101.2, 200.1, 42, Biome::Desert, &pal); + let count_after_move = terrain_cache_resample_count(); + assert!( + count_after_move > count_after_subcell, + "must resample when crossing cell boundary" + ); + } + + #[test] + fn preview_staying_inside_scope_circle() { + let icons = crate::hud::Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let mut st = HudState::default(); + st.position = Some((100.0, 200.0)); + st.world_seed = 42; + st.biome = Biome::Desert; + let mut out = Vec::new(); + + let pane = [0.0, 0.0, 128.0, 128.0]; + let (scope_x, scope_y, scope) = scope_of(pane); + let c = scope * 0.5; + let cx = scope_x + c; + let cy = scope_y + c; + let rim = c - 1.5; + + ui.begin(1280, 720); + draw_radar(&mut ui, &crate::hud::palette(0), &st, pane, false, &mut out); + + // Verify rects generated for terrain preview face do not exceed scope rim + let rows = FACE_ROWS as usize; + let step_y = 2.0 * rim / rows as f32; + let step_x = 2.0 * rim / rows as f32; + + for row in 0..rows { + let top = cy - rim + row as f32 * step_y; + let cy_row = top + step_y * 0.5; + let dy_rim = (cy_row - cy) / rim; + if dy_rim.abs() >= 1.0 { + continue; + } + let half_w = rim * (1.0 - dy_rim * dy_rim).max(0.0).sqrt(); + + for col in 0..rows { + let left = cx - rim + col as f32 * step_x; + let right = left + step_x; + let r_left = left.max(cx - half_w); + let r_right = right.min(cx + half_w); + if r_right <= r_left { + continue; + } + assert!(r_left >= cx - half_w - 1e-3, "r_left outside circle"); + assert!(r_right <= cx + half_w + 1e-3, "r_right outside circle"); + } + } + } + + #[test] + fn ring_labels_distinct_and_ascii_at_every_ladder_step() { + for &radius in RANGE_LADDER_CELLS { + let rings = range_rings_for_radius(radius); + assert!(!rings.is_empty()); + + let mut labels = Vec::new(); + for &r in rings { + let label = format!("{:.0}m", r); + for ch in label.chars() { + assert!( + ch.is_ascii() && (ch as u8) >= 32 && (ch as u8) <= 126, + "label '{label}' must be ASCII 32..=126" + ); + } + assert!( + !labels.contains(&label), + "duplicate ring label '{label}' at radius {radius}" + ); + labels.push(label); + } + + if (radius - 32.0).abs() < 1e-3 { + assert_eq!( + rings.len(), + 2, + "32m step must drop intermediate rings to avoid crowding" + ); + } + } + } + + #[test] + fn grid_shifting_when_player_position_changes() { + let range_radius = 96.0; + set_range_radius(range_radius); + let scope = 128.0; + let scale = scale_for(scope); + let grid_step = (range_radius / 4.0).max(8.0); + + let p1_x = 100.0f32; + let p2_x = 100.5f32; + + let k = (p1_x / grid_step).floor() as i32 + 1; + let wx = k as f32 * grid_step; + + let dx1 = wx - p1_x; + let dx2 = wx - p2_x; + + let r_cell1 = dx1 * scale; + let r_cell2 = dx2 * scale; + + let shift = (r_cell1 - r_cell2).abs(); + let expected_shift = (p2_x - p1_x) * scale; + + assert!( + (shift - expected_shift).abs() < 1e-3, + "grid shift {shift} must match player movement {expected_shift}" + ); + } +} diff --git a/client-rust/source/app/src/hud/toolbar.rs b/client-rust/source/app/src/hud/toolbar.rs new file mode 100644 index 00000000..07dd0b7d --- /dev/null +++ b/client-rust/source/app/src/hud/toolbar.rs @@ -0,0 +1,835 @@ +//! TOOLBAR + DOCK — 12 icon slots (three groups of four) bottom-center and +//! the right-edge window rail (ports of `ui/hud/toolbar.ts`, +//! `ui/hud/toolbarActions.ts`, `ui/hud/toolbarStore.ts`, `ui/windows/dock.ts`). +//! +//! Persistence: one schema-3 doc (`{ schema: 3, slots: [...12], binds: [...12] }`) +//! under the Local scope — device muscle memory, not character data. Slots are +//! BLANK BY DEFAULT; assignments come from the Action Browser. Invalid action +//! ids are stripped on load (the Aim-removal migration gate). Binds default to +//! the number row `Digit1..Digit9, Digit0, Minus, Equal` and are rebindable +//! from OPTIONS via pending-capture. + +use serde_json::{json, Value}; +use successor_engine_render::ui::UiBuilder; + +use super::{code_glyph, HudAction, HudState, Icons, LifeHud, Palette, PERMANENT_WINDOWS}; + +pub const SLOT_COUNT: usize = 12; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PressResult { + /// Hotkey matched a toolbar slot and enqueued a HUD action. + Used, + /// Hotkey matched a toolbar slot, but the slot was empty or ineligible. + Ineligible, + /// Hotkey did not match any toolbar slot bind. + Passthrough, +} + +impl PressResult { + pub fn is_consumed(self) -> bool { + matches!(self, Self::Used | Self::Ineligible) + } +} + +pub const SLOT_PX: f32 = 46.0; +const SLOT_GAP: f32 = 6.0; +const GROUP_GAP: f32 = 14.0; +/// Band under each slot carrying its hotkey label, as the original prints +/// `F1`..`F12` beneath the bar rather than inside the slot face. +const KEY_LABEL_H: f32 = 12.0; +const FLASH_MS: u64 = 1600; + +pub const DEFAULT_BINDS: [&str; SLOT_COUNT] = [ + "Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9", + "Digit0", "Minus", "Equal", +]; + +// ── Action registry (port of TOOLBAR_ACTIONS) ─────────────────────────────── + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ActionKind { + /// A gameplay verb — the host routes it through `game::actions`. + Verb, + /// A window shortcut (permanent dock destinations only). + Window(&'static str), +} + +#[derive(Clone, Copy, Debug)] +pub struct ToolbarAction { + pub id: &'static str, + /// Full label — slot tooltip + Action Browser name. + pub label: &'static str, + /// Atlas icon id. + pub icon: &'static str, + /// One-line Action Browser description. + pub description: &'static str, + pub kind: ActionKind, +} + +/// The bindable verb set for the 12-slot bar. Window shortcuts cover the +/// PERMANENT dock destinations only. +pub const TOOLBAR_ACTIONS: [ToolbarAction; 15] = [ + ToolbarAction { + id: "attack", + label: "ATTACK", + icon: "crosshair", + description: "STRIKE THE CURRENT TARGET.", + kind: ActionKind::Verb, + }, + ToolbarAction { + id: "aimed", + label: "AIMED SHOT", + icon: "examine", + description: "SPEND ACTION ON A PRECISE SHOT.", + kind: ActionKind::Verb, + }, + ToolbarAction { + id: "kneel", + label: "KNEEL", + icon: "kneel", + description: "DROP TO A KNEEL.", + kind: ActionKind::Verb, + }, + ToolbarAction { + id: "stand", + label: "STAND", + icon: "stand", + description: "RETURN TO STANDING.", + kind: ActionKind::Verb, + }, + ToolbarAction { + id: "survey", + label: "TOOL SURVEY", + icon: "survey", + description: "CHOOSE A RESOURCE FAMILY.", + kind: ActionKind::Window("survey"), + }, + ToolbarAction { + id: "sample", + label: "HAND SAMPLE", + icon: "sample", + description: "TAKE A SMALL RESOURCE SAMPLE.", + kind: ActionKind::Window("survey"), + }, + ToolbarAction { + id: "reload", + label: "RELOAD", + icon: "reload", + description: "RELOAD THE EQUIPPED WEAPON.", + kind: ActionKind::Verb, + }, + ToolbarAction { + id: "peace", + label: "STAND DOWN", + icon: "peace", + description: "CEASE FIRE AND DISENGAGE.", + kind: ActionKind::Verb, + }, + ToolbarAction { + id: "clone", + label: "ACTIVATE CLONE", + icon: "clone", + description: "RESPAWN AT NEAREST CLONE.", + kind: ActionKind::Verb, + }, + ToolbarAction { + id: "window:inventory", + label: "INVENTORY", + icon: "inventory", + description: "OPEN YOUR FIELD KIT.", + kind: ActionKind::Window("inventory"), + }, + ToolbarAction { + id: "window:character", + label: "CHARACTER", + icon: "character", + description: "OPEN YOUR CHARACTER SHEET.", + kind: ActionKind::Window("character"), + }, + ToolbarAction { + id: "window:skills", + label: "SKILLS", + icon: "skills", + description: "OPEN THE PROFESSION SKILL TREE.", + kind: ActionKind::Window("skills"), + }, + ToolbarAction { + id: "window:datapad", + label: "DATAPAD", + icon: "datapad", + description: "OPEN THE FIELD DATAPAD.", + kind: ActionKind::Window("datapad"), + }, + ToolbarAction { + id: "window:macros", + label: "MACROS", + icon: "macro", + description: "AUTHOR AND RUN COMMAND SCRIPTS.", + kind: ActionKind::Window("macros"), + }, + ToolbarAction { + id: "window:options", + label: "OPTIONS", + icon: "options", + description: "OPEN DISPLAY + INPUT OPTIONS.", + kind: ActionKind::Window("options"), + }, +]; + +pub fn action_by_id(id: &str) -> Option<&'static ToolbarAction> { + TOOLBAR_ACTIONS.iter().find(|a| a.id == id) +} + +/// Whether `id` is a registered toolbar action — the migration gate for +/// stale persisted slots (removed verbs fall out on load). +pub fn is_valid_action_id(id: &str) -> bool { + action_by_id(id).is_some() +} + +// ── Persisted doc (schema 3) ──────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +pub enum SlotRef { + Action(String), + Item(String), +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ToolbarDoc { + pub slots: Vec>, + pub binds: Vec, +} + +impl ToolbarDoc { + /// Blank default: all-empty slots, number-row binds. + pub fn blank() -> Self { + Self { + slots: vec![None; SLOT_COUNT], + binds: DEFAULT_BINDS.iter().map(|s| s.to_string()).collect(), + } + } + + /// Fresh-install loadout: the two combat verbs live on `1` and `2`, the + /// original's number-row grammar. Persisted docs never pass through this — + /// `from_json` overlays a `blank()` base, so an emptied slot stays empty. + pub fn default_loadout() -> Self { + let mut doc = Self::blank(); + doc.slots[0] = Some(SlotRef::Action("attack".to_string())); + doc.slots[1] = Some(SlotRef::Action("aimed".to_string())); + doc + } + + /// Read + migrate a stored doc section. Corrupt/missing → blank default; + /// invalid action ids become empty slots; binds fall back per slot. + pub fn load(section: Option<&Value>) -> Self { + let mut doc = Self::blank(); + // No persisted section at all = a fresh install: ship the number-row + // combat loadout. A present-but-empty doc is a player's own emptied + // bar and stays empty. + let Some(v) = section else { + return Self::default_loadout(); + }; + if let Some(slots) = v.get("slots").and_then(|s| s.as_array()) { + for (i, raw) in slots.iter().take(SLOT_COUNT).enumerate() { + doc.slots[i] = migrate_slot(raw); + } + } + if let Some(binds) = v.get("binds").and_then(|s| s.as_array()) { + for (i, raw) in binds.iter().take(SLOT_COUNT).enumerate() { + if let Some(code) = raw.as_str() { + if !code.is_empty() { + doc.binds[i] = code.to_string(); + } + } + } + } + doc + } + + /// Serialize to the schema-3 section value. + pub fn save(&self) -> Value { + let slots: Vec = self + .slots + .iter() + .map(|s| match s { + Some(SlotRef::Action(id)) => json!({"kind": "action", "id": id}), + Some(SlotRef::Item(item_id)) => json!({"kind": "item", "itemId": item_id}), + None => Value::Null, + }) + .collect(); + json!({"schema": 3, "slots": slots, "binds": self.binds}) + } + + /// Assign a ref to a slot (browser/inventory → slot drop). + pub fn assign(&mut self, slot: usize, slot_ref: SlotRef) { + if slot < self.slots.len() { + self.slots[slot] = Some(slot_ref); + } + } + + /// Move a ref between slots, swapping if the target is occupied. + pub fn move_or_swap(&mut self, from: usize, to: usize) { + if from == to || from >= self.slots.len() || to >= self.slots.len() { + return; + } + if self.slots[from].is_none() { + return; + } + self.slots.swap(from, to); + } + + pub fn clear(&mut self, slot: usize) { + if slot < self.slots.len() { + self.slots[slot] = None; + } + } +} + +fn migrate_slot(raw: &Value) -> Option { + if let Some(id) = raw.as_str() { + // Legacy v1 shape: a bare action id string. + return is_valid_action_id(id).then(|| SlotRef::Action(id.to_string())); + } + let kind = raw.get("kind")?.as_str()?; + match kind { + "action" => { + let id = raw.get("id")?.as_str()?; + is_valid_action_id(id).then(|| SlotRef::Action(id.to_string())) + } + "item" => { + let item_id = raw.get("itemId")?.as_str()?; + (!item_id.is_empty()).then(|| SlotRef::Item(item_id.to_string())) + } + _ => None, + } +} + +// ── Live toolbar state ────────────────────────────────────────────────────── + +/// Transient interaction state layered over the persisted doc. +pub struct Toolbar { + pub doc: ToolbarDoc, + /// Slot index a drag started from (move/swap/clear on release). + drag_from: Option, + drag_started: bool, + /// OPTIONS-initiated pending key capture for a slot bind. + pub rebind_slot: Option, + /// Action Browser pending assignment: next slot click assigns this id. + pub pending_assign: Option, + /// Receipt flash line above the bar. + flash_text: String, + flash_bad: bool, + flash_until_ms: u64, + /// Last drawn slot rects (for release routing). + slot_rects: [[f32; 4]; SLOT_COUNT], + bar_rect: [f32; 4], +} + +impl Toolbar { + pub fn new(doc: ToolbarDoc) -> Self { + Self { + doc, + drag_from: None, + drag_started: false, + rebind_slot: None, + pending_assign: None, + flash_text: String::new(), + flash_bad: false, + flash_until_ms: 0, + slot_rects: [[0.0; 4]; SLOT_COUNT], + bar_rect: [0.0; 4], + } + } + + /// Flash a receipt line above the bar (`ATTACK QUEUED`, `DENIED · RANGE`). + pub fn flash(&mut self, text: &str, bad: bool, now_ms: u64) { + self.flash_text.clear(); + self.flash_text.push_str(text); + self.flash_bad = bad; + self.flash_until_ms = now_ms + FLASH_MS; + } + + /// OPTIONS window: begin capture for a slot bind (Esc cancels). + pub fn begin_rebind(&mut self, slot: usize) { + if slot < SLOT_COUNT { + self.rebind_slot = Some(slot); + } + } + + /// Feed a key code while a rebind capture is pending. Returns true when + /// the code was consumed (capture ends). `Escape` cancels. + pub fn feed_rebind_code(&mut self, code: &str) -> bool { + let Some(slot) = self.rebind_slot else { + return false; + }; + self.rebind_slot = None; + if code == "Escape" { + return true; + } + if slot < self.doc.binds.len() { + self.doc.binds[slot] = code.to_string(); + } + true + } + + /// Hotkey path: execute the slot bound to `code`. Returns `PressResult` + /// indicating whether the slot was used, ineligible (empty), or unmapped. + pub fn press_code(&mut self, code: &str, out: &mut Vec) -> bool { + self.press_code_result(code, out).is_consumed() + } + + pub fn press_code_result(&mut self, code: &str, out: &mut Vec) -> PressResult { + if self.rebind_slot.is_some() { + let consumed = self.feed_rebind_code(code); + if consumed { + out.push(HudAction::ToolbarChanged); + return PressResult::Used; + } + return PressResult::Passthrough; + } + let Some(slot) = self.doc.binds.iter().position(|b| b == code) else { + return PressResult::Passthrough; + }; + if self.activate(slot, out) { + PressResult::Used + } else { + PressResult::Ineligible + } + } + pub fn activate(&self, slot: usize, out: &mut Vec) -> bool { + match self.doc.slots.get(slot).and_then(|s| s.as_ref()) { + Some(SlotRef::Action(id)) => match action_by_id(id) { + Some(action) => { + match action.kind { + ActionKind::Verb => out.push(HudAction::RunVerb(action.id)), + ActionKind::Window(window_id) => { + out.push(HudAction::ToggleWindow(window_id)) + } + } + true + } + None => false, + }, + Some(SlotRef::Item(item_id)) => { + out.push(HudAction::UseToolbarItem(item_id.clone())); + true + } + None => false, + } + } +} + +// ── Drawing ───────────────────────────────────────────────────────────────── + +/// Right-edge dock rail: one button per PERMANENT window with a hotkey glyph +/// beneath, plus the theme-cycle swatch at the rail bottom. +#[allow(clippy::too_many_arguments)] +pub fn draw_dock( + ui: &mut UiBuilder, + icons: &Icons, + pal: &Palette, + _toolbar: &Toolbar, + rail: [f32; 4], + captured: bool, + out: &mut Vec, +) { + let btn = rail[2]; + let count = PERMANENT_WINDOWS.len() as f32; + // Buttons plus the theme swatch share the rail height, so the rail keeps a + // stable footprint at every framebuffer. + let step = ((rail[3] - btn * 0.5) / (count + 0.5)).max(btn + 2.0); + let x = rail[0]; + let mut y = rail[1]; + let gap = (step - btn).max(2.0); + for (id, title, icon, hotkey) in PERMANENT_WINDOWS.iter() { + let resp = ui.interact(x, y, btn, btn); + let fill = if resp.hovered { + pal.accent_soft + } else { + pal.bg_panel + }; + ui.rect(x, y, btn, btn, fill); + + if let Some((col, row)) = icons.cell(icon) { + ui.icon(col, row, x + 6.0, y + 6.0, btn - 12.0, btn - 12.0, pal.ink); + } + ui.text( + code_glyph(hotkey), + x + btn * 0.5 - 3.0, + y + btn + 1.0, + 1.2, + pal.ink_dim, + ); + if resp.hovered { + // Tooltip: title to the left of the rail. + let tw = ui.measure_text(title, 1.5); + ui.rect(x - tw - 14.0, y + 8.0, tw + 10.0, 14.0, pal.bg_panel); + ui.text(title, x - tw - 9.0, y + 11.0, 1.5, pal.ink); + } + if resp.clicked && !captured { + out.push(HudAction::ToggleWindow(id)); + } + y += btn + gap; + } + // Theme-cycle swatch (OPTIONS keeps the full named picker). + let resp = ui.interact(x, y + gap, btn, btn * 0.5); + ui.rect(x, y + gap, btn, btn * 0.5, pal.accent); + + if resp.clicked && !captured { + out.push(HudAction::CycleTheme); + } +} + +/// The 12-slot toolbar. Handles click activation, drag move/swap/clear, +/// right-click clear, pending Action-Browser assignment, hotkey badges, +/// unavailable overlays and the receipt flash line. +#[allow(clippy::too_many_arguments)] +pub fn draw_toolbar( + ui: &mut UiBuilder, + icons: &Icons, + pal: &Palette, + toolbar: &mut Toolbar, + st: &HudState, + bar: [f32; 4], + captured: bool, + right_pressed: bool, + now_ms: u64, + out: &mut Vec, +) { + let groups = 3usize; + let per_group = 4usize; + let nominal = (SLOT_COUNT as f32) * SLOT_PX + + ((SLOT_COUNT - groups) as f32) * SLOT_GAP + + ((groups - 1) as f32) * GROUP_GAP; + // The bar keeps a stable slot footprint; it only shifts, never restyles. + let x0 = bar[0] + (bar[2] - nominal) * 0.5; + // The slot row and its key labels are one block, centred in the bar rect. + let y = bar[1] + (bar[3] - (SLOT_PX + KEY_LABEL_H)) * 0.5; + toolbar.bar_rect = [bar[0], bar[1], bar[2], bar[3]]; + + let (mx, my) = ui.mouse(); + let verbs_locked = st.life != LifeHud::Alive; + + let mut slot_x = x0; + for slot in 0..SLOT_COUNT { + if slot > 0 { + slot_x += SLOT_PX + + if slot % per_group == 0 { + GROUP_GAP + } else { + SLOT_GAP + }; + } + toolbar.slot_rects[slot] = [slot_x, y, SLOT_PX, SLOT_PX]; + let resp = ui.interact(slot_x, y, SLOT_PX, SLOT_PX); + let occupied = toolbar.doc.slots[slot].is_some(); + let assigning = toolbar.pending_assign.is_some(); + // Every slot keeps a visible cell, as the original bar does: the twelve + // cells are the bar. An empty cell is just a quieter seat. + let mut empty_seat = pal.bg_panel; + empty_seat[3] = 205; + let fill = if resp.hovered && (occupied || assigning) { + pal.accent_soft + } else if occupied { + pal.bg_panel + } else { + empty_seat + }; + ui.rect(slot_x, y, SLOT_PX, SLOT_PX, fill); + ui.border(slot_x, y, SLOT_PX, SLOT_PX, 1.0, pal.hairline); + if assigning && resp.hovered { + ui.rect(slot_x, y + SLOT_PX - 2.0, SLOT_PX, 2.0, pal.accent); + } + + // Glyph. + match toolbar.doc.slots[slot].as_ref() { + Some(SlotRef::Action(id)) => { + if let Some(action) = action_by_id(id) { + if let Some((col, row)) = icons.cell(action.icon) { + let tint = if verbs_locked && action.kind == ActionKind::Verb { + pal.ink_dim + } else { + pal.ink + }; + ui.icon( + col, + row, + slot_x + 8.0, + y + 8.0, + SLOT_PX - 16.0, + SLOT_PX - 16.0, + tint, + ); + } + if resp.hovered && toolbar.drag_from.is_none() { + let tw = ui.measure_text(action.label, 1.5); + ui.rect(mx - tw * 0.5 - 5.0, y - 22.0, tw + 10.0, 15.0, pal.bg_panel); + ui.text(action.label, mx - tw * 0.5, y - 19.0, 1.5, pal.ink); + } + } + } + Some(SlotRef::Item(_)) => { + if let Some((col, row)) = icons.cell("item-item") { + ui.icon( + col, + row, + slot_x + 8.0, + y + 8.0, + SLOT_PX - 16.0, + SLOT_PX - 16.0, + pal.ink, + ); + } + } + None => {} + } + + // Hotkey label under the slot, as the original prints F1..F12 beneath + // the bar. No seat behind it: twelve dark chips in a row read as a + // second, broken bar under the real one. The glyph carries the accent, + // which clears the terrain on its own. + let key = code_glyph(&toolbar.doc.binds[slot]); + let key_w = ui.measure_text(key, 1.3); + let key_x = slot_x + (SLOT_PX - key_w) * 0.5; + ui.text(key, key_x, y + SLOT_PX + 3.0, 1.3, pal.accent); + + // Unavailable overlay for verbs while down/dead. + if verbs_locked { + if let Some(SlotRef::Action(id)) = toolbar.doc.slots[slot].as_ref() { + if action_by_id(id).map(|a| a.kind == ActionKind::Verb) == Some(true) { + ui.rect(slot_x, y, SLOT_PX, SLOT_PX, [10, 10, 10, 140]); + } + } + } + + if captured { + continue; + } + + // Right-click clears a filled slot. + if right_pressed && resp.hovered && occupied { + toolbar.doc.clear(slot); + out.push(HudAction::ToolbarChanged); + continue; + } + + // Press starts a potential drag from a filled slot. + if resp.pressed && occupied && toolbar.pending_assign.is_none() { + toolbar.drag_from = Some(slot); + toolbar.drag_started = false; + } + } + + // Drag tracking: any movement past a threshold turns the press into a drag. + if let Some(from) = toolbar.drag_from { + let [fx, fy, _, _] = toolbar.slot_rects[from]; + if !toolbar.drag_started + && ((mx - fx - SLOT_PX * 0.5).abs() > 10.0 || (my - fy - SLOT_PX * 0.5).abs() > 10.0) + { + toolbar.drag_started = true; + } + if toolbar.drag_started { + // Ghost glyph under the cursor. + ui.rect(mx - 12.0, my - 12.0, 24.0, 24.0, pal.accent_soft); + } + } + + // Release routing: click-activate, drop move/swap, drop-off clear, assign. + // Screen-wide release: a drop anywhere resolves the drag, including a drop + // off the bar (which clears the source slot). + let released = ui + .interact(f32::MIN * 0.5, f32::MIN * 0.5, f32::MAX, f32::MAX) + .released; + if released && !captured { + let target_slot = (0..SLOT_COUNT).find(|&i| { + let [rx, ry, rw, rh] = toolbar.slot_rects[i]; + UiBuilder::hit(rx, ry, rw, rh, mx, my) + }); + if let Some(assign_id) = toolbar.pending_assign.clone() { + if let Some(slot) = target_slot { + toolbar.doc.assign(slot, SlotRef::Action(assign_id)); + toolbar.pending_assign = None; + out.push(HudAction::ToolbarChanged); + } + } else if let Some(from) = toolbar.drag_from.take() { + if toolbar.drag_started { + match target_slot { + Some(to) if to != from => { + toolbar.doc.move_or_swap(from, to); + out.push(HudAction::ToolbarChanged); + } + Some(_) => {} + None => { + // Dropped off the bar → clear the source slot. + toolbar.doc.clear(from); + out.push(HudAction::ToolbarChanged); + } + } + toolbar.drag_started = false; + } else if target_slot == Some(from) { + // Plain click: activate. + toolbar.activate(from, out); + } + } + } + + // Receipt flash line (drawn above the bar). + if !toolbar.flash_text.is_empty() && toolbar.flash_until_ms > now_ms { + let tint = if toolbar.flash_bad { + pal.danger + } else { + pal.accent + }; + let tw = ui.measure_text(&toolbar.flash_text, 1.6); + ui.text( + &toolbar.flash_text, + bar[0] + (bar[2] - tw) * 0.5, + y - 20.0, + 1.6, + tint, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blank_doc_matches_owner_spec() { + let doc = ToolbarDoc::blank(); + assert_eq!(doc.slots.len(), SLOT_COUNT); + assert!(doc.slots.iter().all(|s| s.is_none())); + assert_eq!(doc.binds[0], "Digit1"); + assert_eq!(doc.binds[9], "Digit0"); + assert_eq!(doc.binds[10], "Minus"); + assert_eq!(doc.binds[11], "Equal"); + } + + #[test] + fn load_strips_invalid_action_ids_and_keeps_items() { + let section = json!({ + "schema": 3, + "slots": [ + {"kind": "action", "id": "attack"}, + {"kind": "action", "id": "aimed_shot"}, + {"kind": "item", "itemId": "medkit-4"}, + "reload", + "removed_verb", + null + ], + "binds": ["KeyQ", "", 7] + }); + let doc = ToolbarDoc::load(Some(§ion)); + assert_eq!(doc.slots[0], Some(SlotRef::Action("attack".into()))); + assert_eq!(doc.slots[1], None, "removed verbs are stripped on load"); + assert_eq!(doc.slots[2], Some(SlotRef::Item("medkit-4".into()))); + assert_eq!( + doc.slots[3], + Some(SlotRef::Action("reload".into())), + "legacy string slots promote" + ); + assert_eq!(doc.slots[4], None); + assert_eq!(doc.binds[0], "KeyQ"); + assert_eq!(doc.binds[1], "Digit2", "empty bind falls back to default"); + assert_eq!(doc.binds[2], "Digit3", "non-string bind falls back"); + } + + #[test] + fn save_round_trips() { + let mut doc = ToolbarDoc::blank(); + doc.assign(0, SlotRef::Action("peace".into())); + doc.assign(5, SlotRef::Item("chip-9".into())); + doc.binds[3] = "KeyT".into(); + let reloaded = ToolbarDoc::load(Some(&doc.save())); + assert_eq!(reloaded, doc); + assert_eq!(doc.save()["schema"], 3); + } + + #[test] + fn move_or_swap_rules() { + let mut doc = ToolbarDoc::blank(); + doc.assign(0, SlotRef::Action("attack".into())); + doc.assign(1, SlotRef::Action("peace".into())); + // Occupied target → swap. + doc.move_or_swap(0, 1); + assert_eq!(doc.slots[0], Some(SlotRef::Action("peace".into()))); + assert_eq!(doc.slots[1], Some(SlotRef::Action("attack".into()))); + // Empty target → move. + doc.move_or_swap(1, 4); + assert_eq!(doc.slots[1], None); + assert_eq!(doc.slots[4], Some(SlotRef::Action("attack".into()))); + // Empty source / out-of-range → no-op. + doc.move_or_swap(7, 8); + doc.move_or_swap(0, 99); + assert_eq!(doc.slots[0], Some(SlotRef::Action("peace".into()))); + } + + #[test] + fn press_code_activates_and_rebind_captures() { + let mut doc = ToolbarDoc::blank(); + doc.assign(0, SlotRef::Action("attack".into())); + doc.assign(1, SlotRef::Action("window:options".into())); + doc.assign(2, SlotRef::Item("stim-1".into())); + let mut tb = Toolbar::new(doc); + let mut out = Vec::new(); + assert!(tb.press_code("Digit1", &mut out)); + assert!(tb.press_code("Digit2", &mut out)); + assert!(tb.press_code("Digit3", &mut out)); + // A bound key over an empty slot belongs to the toolbar: it reports + // `Ineligible` so the host can sound `ui_toolbar_ineligible`, and it + // consumes the press rather than letting a bound digit fall through to + // a gameplay verb. + assert_eq!( + tb.press_code_result("Digit4", &mut out), + PressResult::Ineligible + ); + assert!(tb.press_code("Digit4", &mut out)); + assert_eq!( + tb.press_code_result("KeyZ", &mut out), + PressResult::Passthrough, + "unbound code passes through" + ); + assert!(!tb.press_code("KeyZ", &mut out)); + assert_eq!( + out, + vec![ + HudAction::RunVerb("attack"), + HudAction::ToggleWindow("options"), + HudAction::UseToolbarItem("stim-1".into()), + ] + ); + // Rebind capture: next code lands in the bind; Escape cancels. + out.clear(); + tb.begin_rebind(4); + assert!(tb.press_code("KeyH", &mut out)); + assert_eq!(tb.doc.binds[4], "KeyH"); + assert_eq!(out, vec![HudAction::ToolbarChanged]); + tb.begin_rebind(5); + assert!(tb.feed_rebind_code("Escape")); + assert_eq!(tb.doc.binds[5], "Digit6", "escape keeps the old bind"); + } + + #[test] + fn registry_covers_reference_set() { + for id in [ + "attack", + "kneel", + "stand", + "survey", + "sample", + "reload", + "peace", + "clone", + "window:inventory", + "window:character", + "window:skills", + "window:datapad", + "window:macros", + "window:options", + ] { + assert!(is_valid_action_id(id), "missing registry action {id}"); + } + assert!(!is_valid_action_id("aimed_shot"), "Aim stays removed"); + } +} diff --git a/client-rust/source/app/src/hud/waypoints.rs b/client-rust/source/app/src/hud/waypoints.rs new file mode 100644 index 00000000..8c753b4e --- /dev/null +++ b/client-rust/source/app/src/hud/waypoints.rs @@ -0,0 +1,351 @@ +//! WAYPOINT store — client-side, per-character navigation marks (port of +//! `ui/waypoints/store.ts`). +//! +//! Persistence is local and immediate: every mutation rewrites the small +//! (≤100 rows) character-scoped section (`persist.rs`, key `waypoints`, +//! schema `successor3d.waypoints.v1`). Readers (datapad, radar, world beams, +//! slash command) share one in-memory list through a monotonic version +//! counter instead of subscriptions. + +use serde_json::{json, Value}; + +pub const MAX_WAYPOINTS: usize = 100; +pub const NAME_MAX: usize = 48; +const COORD_PRECISION: f32 = 100.0; +pub const STORAGE_SCHEMA: &str = "successor3d.waypoints.v1"; + +#[derive(Clone, Debug, PartialEq)] +pub struct Waypoint { + pub id: u32, + pub name: String, + pub x: f32, + pub y: f32, + pub area_id: String, + pub active: bool, + pub created_at_ms: u64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct MutationResult { + pub ok: bool, + pub status: String, +} + +fn round_coord(v: f32) -> f32 { + (v * COORD_PRECISION).round() / COORD_PRECISION +} + +fn normalize_name(value: Option<&str>, fallback: &str) -> String { + let name = super::sanitize_text(value.unwrap_or(""), NAME_MAX); + if name.is_empty() { + fallback.to_string() + } else { + name + } +} + +#[derive(Default)] +pub struct WaypointStore { + list: Vec, + version: u64, + id_seq: u32, + dirty: bool, +} + +impl WaypointStore { + pub fn new() -> Self { + Self::default() + } + + /// Monotonic change counter — cheap poll for radar/map/beam readers. + pub fn version(&self) -> u64 { + self.version + } + + pub fn waypoints(&self) -> &[Waypoint] { + &self.list + } + + pub fn count(&self) -> usize { + self.list.len() + } + + /// Active waypoints inside `area_id` (the beam/radar filter). + pub fn active_in_area<'a>(&'a self, area_id: &'a str) -> impl Iterator { + self.list + .iter() + .filter(move |w| w.active && w.area_id == area_id) + } + + /// Whether a mutation happened since the last `mark_saved` — the host + /// persists the section and clears the flag. + pub fn dirty(&self) -> bool { + self.dirty + } + pub fn mark_saved(&mut self) { + self.dirty = false; + } + + /// First unused `Waypoint N` name. + pub fn default_name(&self) -> String { + for i in 1..=(MAX_WAYPOINTS + 1) { + let candidate = format!("Waypoint {i}"); + if !self.list.iter().any(|w| w.name == candidate) { + return candidate; + } + } + format!("Waypoint {}", self.list.len() + 1) + } + + pub fn create( + &mut self, + name: Option<&str>, + x: f32, + y: f32, + area_id: &str, + active: bool, + now_ms: u64, + ) -> MutationResult { + if self.list.len() >= MAX_WAYPOINTS { + return MutationResult { + ok: false, + status: format!("WAYPOINT CAP {MAX_WAYPOINTS}/{MAX_WAYPOINTS} - DELETE ONE FIRST"), + }; + } + if !x.is_finite() || !y.is_finite() || area_id.trim().is_empty() { + return MutationResult { + ok: false, + status: "WAYPOINT DENIED - BAD LOCATION".into(), + }; + } + self.id_seq += 1; + let fallback = self.default_name(); + let wp = Waypoint { + id: self.id_seq, + name: normalize_name(name, &fallback), + x: round_coord(x), + y: round_coord(y), + area_id: area_id.trim().to_string(), + active, + created_at_ms: now_ms, + }; + let status = format!("{} CREATED", wp.name.to_uppercase()); + self.list.push(wp); + self.mutated(); + MutationResult { ok: true, status } + } + + pub fn rename(&mut self, id: u32, next_name: &str) -> MutationResult { + let Some(wp) = self.list.iter_mut().find(|w| w.id == id) else { + return gone(); + }; + let normalized = super::sanitize_text(next_name, NAME_MAX); + if normalized.is_empty() { + return MutationResult { + ok: false, + status: "WAYPOINT NAME REQUIRED".into(), + }; + } + if wp.name == normalized { + return MutationResult { + ok: true, + status: format!("{} UNCHANGED", wp.name.to_uppercase()), + }; + } + wp.name = normalized; + let status = format!("{} RENAMED", wp.name.to_uppercase()); + self.mutated(); + MutationResult { ok: true, status } + } + + pub fn set_active(&mut self, id: u32, active: bool) -> MutationResult { + let Some(wp) = self.list.iter_mut().find(|w| w.id == id) else { + return gone(); + }; + let label = if active { "ACTIVE" } else { "INACTIVE" }; + let status = format!("{} {label}", wp.name.to_uppercase()); + if wp.active != active { + wp.active = active; + self.mutated(); + } + MutationResult { ok: true, status } + } + + pub fn delete(&mut self, id: u32) -> MutationResult { + let Some(index) = self.list.iter().position(|w| w.id == id) else { + return gone(); + }; + let wp = self.list.remove(index); + self.mutated(); + MutationResult { + ok: true, + status: format!("{} DELETED", wp.name.to_uppercase()), + } + } + + fn mutated(&mut self) { + self.version += 1; + self.dirty = true; + } + + // ── Persistence (section value under the Character scope) ──────────── + + pub fn save(&self) -> Value { + let rows: Vec = self + .list + .iter() + .map(|w| { + json!({ + "id": w.id, + "name": w.name, + "x": w.x, + "y": w.y, + "areaId": w.area_id, + "active": w.active, + "createdAtMs": w.created_at_ms, + }) + }) + .collect(); + json!({"schema": STORAGE_SCHEMA, "waypoints": rows}) + } + + /// Load from a persisted section; malformed rows are dropped, a schema + /// mismatch resets to empty (per-field reset policy). + pub fn load(section: Option<&Value>) -> Self { + let mut store = Self::new(); + let Some(v) = section else { return store }; + if v.get("schema").and_then(|s| s.as_str()) != Some(STORAGE_SCHEMA) { + return store; + } + let Some(rows) = v.get("waypoints").and_then(|r| r.as_array()) else { + return store; + }; + for row in rows.iter().take(MAX_WAYPOINTS) { + let (Some(x), Some(y)) = ( + row.get("x").and_then(|v| v.as_f64()), + row.get("y").and_then(|v| v.as_f64()), + ) else { + continue; + }; + let Some(area_id) = row.get("areaId").and_then(|v| v.as_str()) else { + continue; + }; + if area_id.trim().is_empty() || !x.is_finite() || !y.is_finite() { + continue; + } + let id = row.get("id").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + store.id_seq = store.id_seq.max(id); + let fallback = store.default_name(); + store.list.push(Waypoint { + id: if id == 0 { + store.id_seq += 1; + store.id_seq + } else { + id + }, + name: normalize_name(row.get("name").and_then(|v| v.as_str()), &fallback), + x: round_coord(x as f32), + y: round_coord(y as f32), + area_id: area_id.trim().to_string(), + active: row.get("active").and_then(|v| v.as_bool()).unwrap_or(false), + created_at_ms: row.get("createdAtMs").and_then(|v| v.as_u64()).unwrap_or(0), + }); + } + store + } +} + +fn gone() -> MutationResult { + MutationResult { + ok: false, + status: "WAYPOINT GONE".into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_rename_toggle_delete_round_trip() { + let mut s = WaypointStore::new(); + let r = s.create(None, 12.345, -7.891, "open-desert", true, 5); + assert!(r.ok); + assert_eq!(r.status, "WAYPOINT 1 CREATED"); + let wp = &s.waypoints()[0]; + assert_eq!(wp.x, 12.35, "coords round to 1/100 cell"); + assert_eq!(wp.y, -7.89); + let id = wp.id; + assert!(s.rename(id, "The Spire").ok); + assert_eq!(s.waypoints()[0].name, "The Spire"); + assert!(s.set_active(id, false).ok); + assert!(!s.waypoints()[0].active); + assert!(s.delete(id).ok); + assert_eq!(s.count(), 0); + assert!(!s.delete(id).ok, "double delete reports GONE"); + } + + #[test] + fn cap_and_bad_location_are_denied() { + let mut s = WaypointStore::new(); + for i in 0..MAX_WAYPOINTS { + assert!(s.create(None, i as f32, 0.0, "area", false, 0).ok); + } + let r = s.create(None, 1.0, 1.0, "area", false, 0); + assert!(!r.ok); + assert!(r.status.contains("CAP")); + let mut s2 = WaypointStore::new(); + assert!(!s2.create(None, f32::NAN, 0.0, "area", false, 0).ok); + assert!(!s2.create(None, 0.0, 0.0, " ", false, 0).ok); + } + + #[test] + fn names_are_sanitized_and_bounded() { + let mut s = WaypointStore::new(); + let long = "x".repeat(NAME_MAX + 20); + s.create(Some(&long), 0.0, 0.0, "area", false, 0); + assert_eq!(s.waypoints()[0].name.chars().count(), NAME_MAX); + s.create(Some(" ctrl\u{7}chars "), 1.0, 1.0, "area", false, 0); + assert_eq!(s.waypoints()[1].name, "ctrlchars"); + } + + #[test] + fn persistence_round_trips_and_rejects_wrong_schema() { + let mut s = WaypointStore::new(); + s.create(Some("Alpha"), 3.0, 4.0, "forest", true, 9); + s.create(None, -1.0, 2.0, "open-desert", false, 10); + let saved = s.save(); + let loaded = WaypointStore::load(Some(&saved)); + assert_eq!(loaded.waypoints(), s.waypoints()); + assert_eq!( + loaded.active_in_area("forest").count(), + 1, + "active-area filter" + ); + let wrong = json!({"schema": "other.v9", "waypoints": []}); + assert_eq!(WaypointStore::load(Some(&wrong)).count(), 0); + // Malformed rows drop; valid rows survive. + let mixed = json!({"schema": STORAGE_SCHEMA, "waypoints": [ + {"x": 1.0, "y": 2.0, "areaId": "a", "id": 7, "name": "Keep"}, + {"x": "bad"}, + {"y": 2.0, "areaId": ""} + ]}); + let m = WaypointStore::load(Some(&mixed)); + assert_eq!(m.count(), 1); + assert_eq!(m.waypoints()[0].name, "Keep"); + } + + #[test] + fn dirty_flag_gates_persistence() { + let mut s = WaypointStore::new(); + assert!(!s.dirty()); + s.create(None, 0.0, 0.0, "area", false, 0); + assert!(s.dirty()); + s.mark_saved(); + assert!(!s.dirty()); + // Idempotent set_active does not re-dirty. + let id = s.waypoints()[0].id; + s.set_active(id, false); + assert!(!s.dirty()); + } +} diff --git a/client-rust/source/app/src/item_preview.rs b/client-rust/source/app/src/item_preview.rs new file mode 100644 index 00000000..eb4fc189 --- /dev/null +++ b/client-rust/source/app/src/item_preview.rs @@ -0,0 +1,683 @@ +//! Live inventory/examine GLB previews for the native client. +//! +//! Each visible item gets a small forward-rendered target, a viewport-isolated +//! model, and a screen composite. The model registry is the same checked-in +//! registry used by `client-3d`; rigid node animation and skinned animation are +//! sampled through the engine animation runtime before the item turntable yaw. + +use std::collections::BTreeMap; + +use successor_engine_core::anim::{apply_animation, JointTransform, Skeleton}; +use successor_engine_core::ecs::{Entity, WorldOps}; +use successor_engine_core::glb::{self, GlbAnimation, GlbDocument}; +use successor_engine_core::math::{vec3, Mat4, Quat, Vec3}; +use successor_engine_render::components::{ + CamTarget, Camera, CompositeQuad, MaterialId, MeshId, MeshRenderer, PointLight, Projection, + RectNorm, SkinRef, Transform, +}; +use successor_engine_render::gpu::{ClearSpec, Filter, Gpu, RenderTargetDesc, RenderTargetId}; +use successor_engine_render::model::upload_glb; +use successor_engine_render::renderer::Renderer; +use successor_engine_render::window::{WindowManager, TITLE_H}; + +use successor_platform::Platform; + +use crate::assets::stream::{AssetStreamer, ByteSource, Streamed}; +use crate::windows::{InventoryRow, WindowModel}; +use crate::GameWorld; + +const REGISTRY_JSON: &str = include_str!("../../../../client-3d/src/ui/inventory/itemModels.json"); +const UNKNOWN_MODEL: &str = "assets/world-items/supply_cache.glb"; +/// Live 3D icon lanes for the held grid. The original renders every inventory +/// item as a 3D icon rather than a flat picture, so this covers a full default +/// page; cards past it fall back to the atlas glyph. +pub const INVENTORY_LANES: usize = 24; +const EXAMINE_LANE: usize = INVENTORY_LANES; +const LANE_COUNT: usize = INVENTORY_LANES + 1; +/// Viewport 0 is the world and 1..=4 are the character viewers, so item icon +/// lanes start after those. 25 lanes then reach 29, inside the 32-slot mask. +const FIRST_VIEWPORT: u8 = 5; +/// Grid icons are small on screen; this is already supersampled against a +/// ~48 px card. The examine viewer is the one that needs real resolution. +const GRID_TARGET_SIZE: u32 = 96; +const EXAMINE_TARGET_SIZE: u32 = 256; + +#[derive(Clone, Copy)] +struct ModelPart { + mesh: MeshId, + material: MaterialId, + node: usize, + skin: Option, + skinned: bool, +} + +#[derive(Clone)] +struct CachedModel { + parts: Vec, + rest: Vec, + parent: Vec>, + order: Vec, + animations: Vec, + skeletons: Vec>, + center: Vec3, + fit_scale: f32, +} + +struct ActiveModel { + source: CachedModel, + pose: Vec, + globals: Vec, + skin_refs: Vec, + palette: Vec<[f32; 16]>, +} + +impl ActiveModel { + fn new(source: CachedModel) -> Self { + let node_count = source.rest.len(); + let skin_count = source.skeletons.len(); + Self { + pose: source.rest.clone(), + globals: vec![Mat4::IDENTITY; node_count], + skin_refs: vec![SkinRef::NONE; skin_count], + palette: Vec::with_capacity(64), + source, + } + } +} + +struct PreviewLane { + camera: Entity, + quad: Entity, + target: RenderTargetId, + entities: Vec, + requested: String, + item_id: Option, + item_key: String, + /// Path whose streamed load has not settled yet. `assign_model` clears it; + /// while set, `changed_path` keeps re-requesting so a lane that first saw + /// `ByteSource::Pending` still picks the model up when the fetch lands. + pending: Option, + model: Option, +} + +pub struct ItemPreviewRenderer { + registry: BTreeMap, + cache: BTreeMap, + lanes: Vec, +} + +impl ItemPreviewRenderer { + pub fn new(gpu: &mut G, world: &mut GameWorld) -> Self { + let registry = serde_json::from_str::>(REGISTRY_JSON) + .unwrap_or_default() + .into_iter() + .filter_map(|(key, value)| { + let item_id = key.parse::().ok()?; + let path = value.as_str()?.trim_start_matches('/').to_string(); + Some((item_id, path)) + }) + .collect(); + let key_light = world.spawn(); + world.set_component( + key_light, + Transform { + pos: vec3(1.8, 2.2, 2.4), + rot: Quat::IDENTITY, + scale: Vec3::ONE, + }, + ); + world.set_component( + key_light, + PointLight { + color: [1.0, 0.94, 0.84], + intensity: 12.0, + radius: 12.0, + }, + ); + let fill_light = world.spawn(); + world.set_component( + fill_light, + Transform { + pos: vec3(-1.8, -1.2, -1.8), + rot: Quat::IDENTITY, + scale: Vec3::ONE, + }, + ); + world.set_component( + fill_light, + PointLight { + color: [0.72, 0.85, 0.96], + intensity: 6.0, + radius: 12.0, + }, + ); + let mut lanes = Vec::with_capacity(LANE_COUNT); + for lane_index in 0..LANE_COUNT { + let size = if lane_index == EXAMINE_LANE { + EXAMINE_TARGET_SIZE + } else { + GRID_TARGET_SIZE + }; + let target = gpu.create_render_target(&RenderTargetDesc { + width: size, + height: size, + color: true, + depth: true, + filter: Filter::Linear, + }); + lanes.push(PreviewLane { + camera: world.spawn(), + quad: world.spawn(), + target, + entities: Vec::new(), + requested: String::new(), + item_id: None, + item_key: String::new(), + pending: None, + model: None, + }); + } + Self { + registry, + cache: BTreeMap::new(), + lanes, + } + } + + #[allow(clippy::too_many_arguments)] + pub fn sync( + &mut self, + gpu: &mut G, + renderer: &mut Renderer, + world: &mut GameWorld, + windows: &WindowManager, + model: &WindowModel, + screen_w: u32, + screen_h: u32, + time: f32, + platform: &mut dyn Platform, + streamer: &mut AssetStreamer, + ) { + // Icons belong to the window that hosts them: banding their composites + // by that window's draw rank keeps them under anything stacked above, + // the same ordering the doll viewers use. + let band = |id: &str| -> i16 { + windows.z_rank(id).map_or(0, |rank| { + rank as i16 * crate::game::connected_scene::DOLL_BAND + }) + }; + let mut lane_index = 0usize; + if windows.is_open("inventory") { + let inventory_band = band("inventory"); + if let Some(content) = window_content(windows, "inventory") { + // Draw order, not wire order: the grid filters, sorts, and + // pages its rows, and a lane keyed off `held()` would sit a + // model under someone else's label. + let mut visible = [0usize; INVENTORY_LANES]; + let visible_count = + crate::windows::inventory::copy_visible_held_indices(content, &mut visible); + for (slot, &held_index) in visible.iter().take(visible_count).enumerate() { + if lane_index >= INVENTORY_LANES { + break; + } + let Some(row) = model.inventory.held().nth(held_index) else { + continue; + }; + let Some(preview) = crate::windows::inventory::grid_preview_rect(content, slot) + else { + break; + }; + let path = self.changed_path(lane_index, row); + self.sync_lane( + lane_index, + path.as_deref(), + preview, + inventory_band + 1 + lane_index as i16, + gpu, + renderer, + world, + screen_w, + screen_h, + time, + platform, + streamer, + ); + lane_index += 1; + } + } + } + while lane_index < INVENTORY_LANES { + self.deactivate_lane(lane_index, world); + lane_index += 1; + } + + if windows.is_open("examine") { + if let (Some(row), Some(content)) = + (&model.examine.item, window_content(windows, "examine")) + { + let preview = crate::windows::live::examine_item_preview_rect(content); + let path = self.changed_path(EXAMINE_LANE, row); + self.sync_lane( + EXAMINE_LANE, + path.as_deref(), + preview, + band("examine") + 1, + gpu, + renderer, + world, + screen_w, + screen_h, + time, + platform, + streamer, + ); + } else { + self.deactivate_lane(EXAMINE_LANE, world); + } + } else { + self.deactivate_lane(EXAMINE_LANE, world); + } + } + + fn model_path(&self, row: &InventoryRow) -> String { + if (7301..=7335).contains(&row.item_id) && valid_equipment_key(&row.item) { + return format!("assets/pawn-pack/equipment/Under/{}.glb", row.item); + } + self.registry + .get(&row.item_id) + .cloned() + .unwrap_or_else(|| UNKNOWN_MODEL.to_string()) + } + + fn changed_path(&mut self, index: usize, row: &InventoryRow) -> Option { + let changed = self.lanes[index].item_id != Some(row.item_id) + || self.lanes[index].item_key != row.item; + if changed { + let path = self.model_path(row); + let lane = &mut self.lanes[index]; + lane.item_id = Some(row.item_id); + lane.item_key.clone_from(&row.item); + lane.pending = Some(path.clone()); + return Some(path); + } + // Identity is unchanged but an earlier request never settled + // (`assign_model` was not reached): keep asking, or a stream that + // returned `Pending` on its first frame leaves the lane dark forever. + // `None` in the settled case keeps the steady frame loop allocation-free. + self.lanes[index].pending.clone() + } + + #[allow(clippy::too_many_arguments)] + fn sync_lane( + &mut self, + index: usize, + requested: Option<&str>, + rect: [f32; 4], + order: i16, + gpu: &mut G, + renderer: &mut Renderer, + world: &mut GameWorld, + screen_w: u32, + screen_h: u32, + time: f32, + platform: &mut dyn Platform, + streamer: &mut AssetStreamer, + ) { + if let Some(requested) = requested { + // Streamed preview: keep last frame's lane content while the + // model is in flight, then swap it in complete. Terminal misses + // keep the UNKNOWN_MODEL placeholder behavior. A miss whose + // placeholder fetch is itself still in flight stays unsettled so + // the lane keeps retrying instead of latching dark. + let loaded = match self.load_model(requested, gpu, renderer, platform, streamer) { + Streamed::Pending => None, + Streamed::Ready(Some(model)) => Some(Some(model)), + Streamed::Ready(None) => { + if requested == UNKNOWN_MODEL { + Some(None) + } else { + match self.load_model(UNKNOWN_MODEL, gpu, renderer, platform, streamer) { + Streamed::Ready(fallback) => Some(fallback), + Streamed::Pending => None, + } + } + } + }; + if let Some(loaded) = loaded { + self.assign_model(index, requested, loaded, world); + } + } + let viewport = FIRST_VIEWPORT + index as u8; + let lane = &mut self.lanes[index]; + let Some(active) = lane.model.as_mut() else { + self.deactivate_lane(index, world); + return; + }; + + active.pose.copy_from_slice(&active.source.rest); + for animation in &active.source.animations { + let sample_time = if animation.duration > 0.0 { + time.rem_euclid(animation.duration) + } else { + 0.0 + }; + apply_animation(animation, sample_time, &mut active.pose); + } + compute_globals( + &active.pose, + &active.source.parent, + &active.source.order, + &mut active.globals, + ); + for (skin_index, skeleton) in active.source.skeletons.iter_mut().enumerate() { + let Some(skeleton) = skeleton else { + active.skin_refs[skin_index] = SkinRef::NONE; + continue; + }; + skeleton.compute_palette(&active.pose, &mut active.palette); + let count = active.palette.len() as u32; + let offset = renderer.push_skin_palette(&active.palette); + active.skin_refs[skin_index] = SkinRef { offset, count }; + } + + let yaw = time * 0.62 + index as f32 * 0.73; + let root = Mat4::from_trs( + Vec3::ZERO, + Quat::from_yaw(yaw), + vec3( + active.source.fit_scale, + active.source.fit_scale, + active.source.fit_scale, + ), + ) + .mul(Mat4::from_translation(active.source.center.scale(-1.0))); + for (part_index, part) in active.source.parts.iter().enumerate() { + let local = if part.skinned { + Mat4::IDENTITY + } else { + active + .globals + .get(part.node) + .copied() + .unwrap_or(Mat4::IDENTITY) + }; + let (pos, rot, scale) = root.mul(local).to_trs(); + world.set_component(lane.entities[part_index], Transform { pos, rot, scale }); + world.set_component( + lane.entities[part_index], + MeshRenderer { + mesh: part.mesh, + material: part.material, + viewport_mask: 1u32 << viewport, + skin: part + .skin + .and_then(|skin| active.skin_refs.get(skin).copied()) + .unwrap_or(SkinRef::NONE), + }, + ); + } + + world.set_component( + lane.camera, + Camera { + viewport_id: viewport, + order: -20 + index as i16, + projection: Projection::Perspective { + fovy: 0.58, + near: 0.02, + far: 20.0, + }, + target: CamTarget::Texture(lane.target), + // The original punches the viewer straight through the 2D UI + // and clears depth/stencil only, never colour, so the panel + // shows behind the model. The equivalent here is a fully + // transparent clear: geometry writes its own alpha and the + // composite blends the rest away. + clear: ClearSpec { + color: Some([0.0, 0.0, 0.0, 0.0]), + depth: Some(1.0), + }, + eye: vec3(0.0, 0.12, 3.25), + look_at: Vec3::ZERO, + up: Vec3::Y, + }, + ); + world.set_component( + lane.quad, + CompositeQuad { + source: lane.target, + rect: rect_norm(rect, screen_w, screen_h), + order, + }, + ); + } + + fn assign_model( + &mut self, + index: usize, + requested: &str, + model: Option, + world: &mut GameWorld, + ) { + let lane = &mut self.lanes[index]; + lane.requested.clear(); + lane.requested.push_str(requested); + lane.pending = None; + lane.model = model.map(ActiveModel::new); + let part_count = lane + .model + .as_ref() + .map(|model| model.source.parts.len()) + .unwrap_or(0); + while lane.entities.len() < part_count { + lane.entities.push(world.spawn()); + } + for entity in lane.entities.iter().skip(part_count) { + world.remove_component::(*entity); + } + } + + fn deactivate_lane(&mut self, index: usize, world: &mut GameWorld) { + let lane = &self.lanes[index]; + world.remove_component::(lane.camera); + world.remove_component::(lane.quad); + for entity in &lane.entities { + world.remove_component::(*entity); + } + } + + fn load_model( + &mut self, + path: &str, + gpu: &mut G, + renderer: &mut Renderer, + platform: &mut dyn Platform, + streamer: &mut AssetStreamer, + ) -> Streamed> { + if let Some(model) = self.cache.get(path) { + return Streamed::Ready(Some(model.clone())); + } + let bytes = match streamer.resolve(platform, path) { + ByteSource::Pending => return Streamed::Pending, + ByteSource::Missing => None, + ByteSource::Ready(bytes) => Some(bytes), + }; + let model = bytes + .and_then(|bytes| glb::parse(&bytes).ok()) + .and_then(|doc| build_cached_model(gpu, renderer, &doc)); + if let Some(model) = &model { + self.cache.insert(path.to_string(), model.clone()); + } + Streamed::Ready(model) + } +} + +fn window_content(windows: &WindowManager, id: &str) -> Option<[f32; 4]> { + let [x, y, w, h] = windows.rect(id)?; + Some([x + 6.0, y + TITLE_H + 6.0, w - 12.0, h - TITLE_H - 12.0]) +} + +fn rect_norm(rect: [f32; 4], screen_w: u32, screen_h: u32) -> RectNorm { + RectNorm { + x: rect[0] / screen_w.max(1) as f32, + y: 1.0 - (rect[1] + rect[3]) / screen_h.max(1) as f32, + w: rect[2] / screen_w.max(1) as f32, + h: rect[3] / screen_h.max(1) as f32, + } +} + +fn valid_equipment_key(value: &str) -> bool { + let mut chars = value.chars(); + chars.next().is_some_and(|ch| ch.is_ascii_lowercase()) + && value.contains('_') + && value + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') +} + +fn build_cached_model( + gpu: &mut G, + renderer: &mut Renderer, + doc: &GlbDocument, +) -> Option { + let uploaded = upload_glb(renderer, gpu, doc).ok()?; + let (parent, order) = hierarchy(doc); + let rest: Vec = doc + .nodes + .iter() + .map(|node| JointTransform { + t: node.translation, + r: node.rotation, + s: node.scale, + }) + .collect(); + let mut rest_globals = vec![Mat4::IDENTITY; rest.len()]; + compute_globals(&rest, &parent, &order, &mut rest_globals); + + let mut parts = Vec::new(); + let mut min = vec3(f32::MAX, f32::MAX, f32::MAX); + let mut max = vec3(f32::MIN, f32::MIN, f32::MIN); + for (node_index, node) in doc.nodes.iter().enumerate() { + let Some(mesh_index) = node.mesh else { + continue; + }; + let mesh = doc.meshes.get(mesh_index)?; + for primitive in uploaded + .primitives + .iter() + .filter(|part| part.source_mesh == mesh_index) + { + let source = mesh.primitives.get(primitive.source_primitive)?; + let skinned = !source.joints.is_empty(); + parts.push(ModelPart { + mesh: primitive.mesh, + material: primitive.material, + node: node_index, + skin: node.skin, + skinned, + }); + let node_matrix = if skinned { + Mat4::IDENTITY + } else { + rest_globals[node_index] + }; + for position in &source.positions { + let p = node_matrix.transform_point(vec3(position[0], position[1], position[2])); + min.x = min.x.min(p.x); + min.y = min.y.min(p.y); + min.z = min.z.min(p.z); + max.x = max.x.max(p.x); + max.y = max.y.max(p.y); + max.z = max.z.max(p.z); + } + } + } + if parts.is_empty() || min.x == f32::MAX { + return None; + } + let span = max.sub(min); + let largest = span.x.max(span.y).max(span.z).max(0.001); + let center = min.add(max).scale(0.5); + let animations = preview_animations(doc); + let skeletons = (0..doc.skins.len()) + .map(|skin| Skeleton::from_document(doc, skin)) + .collect(); + Some(CachedModel { + parts, + rest, + parent, + order, + animations, + skeletons, + center, + fit_scale: 1.62 / largest, + }) +} + +fn hierarchy(doc: &GlbDocument) -> (Vec>, Vec) { + let mut parent = vec![None; doc.nodes.len()]; + for (index, node) in doc.nodes.iter().enumerate() { + for &child in &node.children { + if child < parent.len() { + parent[child] = Some(index); + } + } + } + let mut order = Vec::with_capacity(doc.nodes.len()); + let mut queue: Vec = (0..doc.nodes.len()) + .filter(|&index| parent[index].is_none()) + .collect(); + let mut cursor = 0usize; + while cursor < queue.len() { + let index = queue[cursor]; + cursor += 1; + order.push(index); + for &child in &doc.nodes[index].children { + if child < doc.nodes.len() { + queue.push(child); + } + } + } + (parent, order) +} + +fn compute_globals( + pose: &[JointTransform], + parent: &[Option], + order: &[usize], + out: &mut [Mat4], +) { + for &index in order { + let local = pose.get(index).copied().unwrap_or_default().matrix(); + out[index] = parent[index] + .and_then(|parent| out.get(parent).copied()) + .map(|parent| parent.mul(local)) + .unwrap_or(local); + } +} + +fn preview_animations(doc: &GlbDocument) -> Vec { + if let Some(idle) = doc.animations.iter().find(|animation| { + animation + .name + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case("idle")) + }) { + return vec![idle.clone()]; + } + let loops: Vec<_> = doc + .animations + .iter() + .filter(|animation| { + animation + .name + .as_deref() + .is_some_and(|name| name.to_ascii_lowercase().contains("loop")) + }) + .cloned() + .collect(); + if !loops.is_empty() { + return loops; + } + doc.animations.iter().take(2).cloned().collect() +} diff --git a/client-rust/source/app/src/lib.rs b/client-rust/source/app/src/lib.rs new file mode 100644 index 00000000..01351ff4 --- /dev/null +++ b/client-rust/source/app/src/lib.rs @@ -0,0 +1,1216 @@ +//! Successor Rust client — composition root (library side). +//! +//! Binds the engine, renderer, and (native) platform + protocol into the +//! `GameWorld` and the demo/playable runners. The native binary is `main.rs`; +//! the wasm cdylib exports live here behind `target_arch = "wasm32"`. + +#[cfg(all(feature = "alloc-count", not(target_arch = "wasm32")))] +pub mod alloc_trace; +pub mod assets; +pub mod audio; +pub mod demo; +pub mod game; +#[cfg(not(target_arch = "wasm32"))] +pub mod glb_scene; +pub mod graphics_tuning; +pub mod hosted_creator; +pub mod hud; +mod item_preview; +pub mod material_parity; +pub mod net; +pub mod pawn; +pub mod persist; +pub mod render_settings; +pub mod rss; +pub mod screens; +pub mod windows; +pub mod world; +use successor_platform::Platform; + +pub struct App { + pub mode: AppMode, + pub platform: P, + pub settings: RuntimeSettings, + pub fatal_error: Option, +} + +/// Renderer-neutral application state. Native and WebGL2 shells only provide +/// platform services; this state machine owns mode transitions and frame time. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AppMode { + Entry, + CharacterSelect, + Loading, + Connected, + Fatal, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UiTheme { + Signal, + Phosphor, + Amber, + Oxide, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeSettings { + pub mouse_sensitivity: f32, + pub orthographic_zoom_percent: u16, + /// Workspace frame pane opacity. The original exposes window transparency + /// as a player setting; fills fade, type and the bright perimeter do not. + pub window_opacity: f32, + /// HUD pane opacity, separate because the HUD sits over gameplay the whole + /// session while a window is transient. + pub hud_opacity: f32, + pub ui_theme: UiTheme, + pub dust_edge_fog: f32, + pub inventory_split_snap: u32, + pub toolbar_hotkeys: [u16; 12], +} + +impl Default for RuntimeSettings { + fn default() -> Self { + Self { + mouse_sensitivity: 1.0, + orthographic_zoom_percent: 100, + window_opacity: 0.92, + hud_opacity: 0.90, + ui_theme: UiTheme::Signal, + dust_edge_fog: 0.5, + inventory_split_snap: 100, + toolbar_hotkeys: [0; 12], + } + } +} + +/// A persisted opacity outside the usable band is not clamped to the extreme: +/// a value of zero would hide the UI and read as a broken client, so a corrupt +/// row resets to its default. +fn normalized_opacity(value: f32, default: f32) -> f32 { + if value.is_finite() && (hud::MIN_UI_OPACITY..=hud::MAX_UI_OPACITY).contains(&value) { + value + } else { + default + } +} + +impl RuntimeSettings { + /// Normalize fields independently; movement remains available even when + /// persisted data is corrupt or partially missing. + pub fn normalized(mut self) -> Self { + if !self.mouse_sensitivity.is_finite() || self.mouse_sensitivity <= 0.0 { + self.mouse_sensitivity = 1.0; + } + if !(55..=125).contains(&self.orthographic_zoom_percent) { + self.orthographic_zoom_percent = 100; + } + if !matches!(self.inventory_split_snap, 1 | 5 | 10 | 100 | 1000 | 10000) { + self.inventory_split_snap = 100; + } + if !self.dust_edge_fog.is_finite() || !(0.0..=1.0).contains(&self.dust_edge_fog) { + self.dust_edge_fog = 0.5; + } + self.window_opacity = normalized_opacity(self.window_opacity, 0.92); + self.hud_opacity = normalized_opacity(self.hud_opacity, 0.90); + self + } +} + +impl App

{ + pub fn new(platform: P) -> Self { + Self { + mode: AppMode::Entry, + platform, + settings: RuntimeSettings::default(), + fatal_error: None, + } + } + pub fn fail(&mut self, message: impl Into) { + let message = message.into(); + self.mode = AppMode::Fatal; + self.fatal_error = Some(message.clone()); + self.platform.report_fatal(&message); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AssetRequirement { + Required, + Optional, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AssetSpec { + pub stable_id: &'static str, + pub requirement: AssetRequirement, +} + +pub struct AssetCatalog { + pub specs: &'static [AssetSpec], +} + +impl AssetCatalog { + pub fn load(&self, platform: &P) -> Result { + let mut loaded = 0; + for spec in self.specs { + match platform.read_asset(spec.stable_id) { + Ok(_) => loaded += 1, + Err(error) if spec.requirement == AssetRequirement::Required => { + return Err(format!( + "required asset {} unavailable: {error:?}", + spec.stable_id + )) + } + Err(_) => {} + } + } + Ok(loaded) + } +} +use successor_engine_core::world; +use successor_engine_render::components::{ + Camera, CompositeQuad, DirectionalLight, HeightCutaway, MeshRenderer, ModelRef, PointLight, + TextOverlay, Transform, +}; + +// The concrete ECS world: the render component set (Transform/Mesh/Camera/…) +// plus `ModelRef` so asset-key prefabs resolve into `MeshRenderer`s. +world! { pub struct GameWorld { + transform: Transform, + model: ModelRef, + mesh: MeshRenderer, + cutaway: HeightCutaway, + camera: Camera, + light: DirectionalLight, + point_light: PointLight, + composite: CompositeQuad, + text: TextOverlay, +} } + +// --- render quality selection (process-global; set from `--quality`/`?quality=`) --- +use core::sync::atomic::{AtomicU8, Ordering}; +use successor_engine_render::gpu::Gpu; +use successor_engine_render::renderer::{RenderQuality, Renderer, RendererLimits}; + +static RENDER_QUALITY: AtomicU8 = AtomicU8::new(1); // 0=Low, 1=Medium, 2=High + +/// Set the process-wide render quality tier (call before building any scene). +pub fn set_render_quality(q: RenderQuality) { + let v = match q { + RenderQuality::Low => 0, + RenderQuality::Medium => 1, + RenderQuality::High => 2, + }; + RENDER_QUALITY.store(v, Ordering::Relaxed); +} + +/// Parse a quality string (`low`/`medium`/`high`); unknown → Medium. +pub fn parse_quality(s: &str) -> RenderQuality { + match s { + "low" => RenderQuality::Low, + "high" => RenderQuality::High, + _ => RenderQuality::Medium, + } +} + +/// Current render quality tier. +pub fn render_quality() -> RenderQuality { + match RENDER_QUALITY.load(Ordering::Relaxed) { + 0 => RenderQuality::Low, + 2 => RenderQuality::High, + _ => RenderQuality::Medium, + } +} + +pub fn initialize_render_settings() { + render_settings::initialize(); + set_render_quality(render_settings::selected_preset().render_quality()); +} + +fn active_preset() -> render_settings::PresetSettings { + let preset = match render_quality() { + RenderQuality::Low => render_settings::QualityPreset::Low, + RenderQuality::Medium => render_settings::QualityPreset::Medium, + RenderQuality::High => render_settings::QualityPreset::High, + }; + render_settings::document().preset(preset).clone() +} + +/// Renderer limits at the current quality tier (tier-derived shadow size). +pub fn quality_limits() -> RendererLimits { + let quality = render_quality(); + let preset = active_preset(); + RendererLimits { + quality, + shadow_size: preset.shadows.map_size, + shadow_world_radius: preset.shadows.world_radius, + ..RendererLimits::default() + } +} + +/// Construct a renderer with the active preset already applied. Scene-specific +/// fog and time-of-day grading may layer on top; mastering controls remain +/// process-global and data-driven. +pub fn configured_renderer(gpu: &mut G) -> Result { + let preset = active_preset(); + let mut renderer = Renderer::new(gpu, quality_limits()) + .map_err(|error| format!("renderer initialization: {error:?}"))?; + renderer + .apply_settings(gpu, preset.renderer_settings()) + .map_err(|error| format!("apply render settings: {error:?}"))?; + Ok(renderer) +} + +// Allocation-counting global allocator: installed only under `alloc-count`, so +// the `make check-allocs` build proves zero steady-state per-frame allocations +// while normal builds pay nothing. Native installs the tracing variant +// (SUCCESSOR_ALLOC_TRACE=1 names offending call sites; zero-cost when unset). +#[cfg(all(feature = "alloc-count", not(target_arch = "wasm32")))] +#[global_allocator] +static GLOBAL: crate::alloc_trace::TraceAllocator = crate::alloc_trace::TraceAllocator; +#[cfg(all(feature = "alloc-count", target_arch = "wasm32"))] +#[global_allocator] +static GLOBAL: successor_engine_core::rt::alloc::CountingAllocator = + successor_engine_core::rt::alloc::CountingAllocator::new(std::alloc::System); + +// --- wasm runtime ----------------------------------------------------------- +// Exported entry points the JS shim (`web/successor.js`) drives. Keeping the +// render/engine code reachable from these `#[no_mangle]` exports is also what +// gives the wasm module a meaningful (non-DCE'd) size for the size gate. +#[cfg(target_arch = "wasm32")] +mod web_runtime { + use crate::demo::{build_scene, Scene}; + use crate::game::actions; + use crate::game::chat_net::ChatClient; + use crate::game::command_queue::CommandQueue; + use crate::game::connected_scene::{ConnectedScene, CONNECTED_INPUT_KEYS}; + use crate::game::movement; + use crate::hosted_creator::{ + parse_inbound_message, CreateEffect, CreateStartError, CreatorInbound, HostedCreatorFlow, + }; + use crate::net::session::LaunchEnvelope; + use crate::screens::{CharacterScreen, CharacterStage, ScreenAction}; + use serde_json::json; + use successor_client_proto::colyseus; + + use successor_client_proto::session::{ + Session, SessionEvent, SessionOut, SessionState, WsInput, + }; + use successor_engine_core::input::Key; + use successor_engine_core::rt::cell::GlobalCell; + use successor_engine_render::gpu::{ClearSpec, Gpu, PassTarget, RectPx}; + use successor_engine_render::renderer::Renderer; + use successor_engine_render::ui::UiBuilder; + use successor_net::{PlayerId, SessionId}; + use successor_platform::GlGpu; + static GPU: GlobalCell = GlobalCell::new(); + static SCENE: GlobalCell = GlobalCell::new(); + static PARITY_SCENE: GlobalCell = GlobalCell::new(); + static TERRAIN_SCENE: GlobalCell = GlobalCell::new(); + static DEMO_SELECTOR: GlobalCell = GlobalCell::new(); + static FRAME: GlobalCell = GlobalCell::new(); + static SIZE: GlobalCell<(u32, u32)> = GlobalCell::new(); + static LAUNCH: GlobalCell = GlobalCell::new(); + static CONNECTED_SCENE: GlobalCell = GlobalCell::new(); + static CONNECTED_DT: GlobalCell = GlobalCell::new(); + static VIEW_SENT: GlobalCell = GlobalCell::new(); + static MOVEMENT_CONTROLLER: GlobalCell = GlobalCell::new(); + static FATAL: GlobalCell = GlobalCell::new(); + static EXITING: GlobalCell = GlobalCell::new(); + static CREATOR_MODE: GlobalCell = GlobalCell::new(); + static CREATOR_FLOW: GlobalCell = GlobalCell::new(); + static CREATOR_SCREEN: GlobalCell = GlobalCell::new(); + static CREATOR_RENDERER: GlobalCell = GlobalCell::new(); + static CREATOR_UI: GlobalCell = GlobalCell::new(); + static CREATOR_INPUT: GlobalCell = GlobalCell::new(); + + #[derive(Default)] + struct CreatorInput { + tab_was: bool, + enter_was: bool, + backspace_was: bool, + escape_was: bool, + up_was: bool, + down_was: bool, + } + + fn read_web_asset(stable_id: &str) -> Option> { + if stable_id.is_empty() || stable_id.contains("..") || stable_id.starts_with('/') { + return None; + } + let path = if stable_id.starts_with("assets/") { + stable_id.to_string() + } else if let Some(path) = stable_id.strip_prefix("successor-slice/") { + format!("successor-slice/{path}") + } else if let Some(path) = stable_id.strip_prefix("successor-audio/") { + format!("successor-audio/{path}") + } else if let Some(path) = stable_id.strip_prefix("render/") { + format!("render/{path}") + } else { + return None; + }; + successor_platform::http_get(&path).ok() + } + + fn creator_mode() -> bool { + CREATOR_MODE.get_mut().copied().unwrap_or(false) + } + + fn build_creator_surface(gpu: &mut GlGpu) -> Result<(), String> { + let mut renderer = crate::configured_renderer(gpu)?; + let icons = crate::hud::Icons::load(); + renderer.set_ui_atlas(&mut *gpu, icons.meta.width, icons.meta.height, &icons.rgba); + CREATOR_RENDERER.set(renderer); + CREATOR_UI.set(icons.ui_builder()); + Ok(()) + } + + #[no_mangle] + pub extern "C" fn init(demo_selector: u32) { + crate::initialize_render_settings(); + successor_platform::init("Successor", 1280, 720); + DEMO_SELECTOR.set(demo_selector); + let is_creator = demo_selector == 0 && successor_platform::web::creator_mode(); + CREATOR_MODE.set(is_creator); + FATAL.set(false); + if demo_selector == 0 && !is_creator { + let bytes = successor_platform::web::launch_context() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .and_then(|v| { + LaunchEnvelope::from_json(&v, successor_platform::now_ms() as u64).ok() + }); + if let Some(envelope) = bytes { + LAUNCH.set(envelope); + } else { + FATAL.set(true); + successor_engine_core::rt::log::log_str( + "fatal launch: missing or invalid launch context", + ); + } + } + let mut gpu = successor_platform::create_gpu(); + if demo_selector == 1 { + let assets = [ + successor_platform::http_get("parity-assets/commerce_facility.glb") + .expect("commerce asset"), + successor_platform::http_get("parity-assets/lightning_carbine.glb") + .expect("lightning asset"), + successor_platform::http_get("parity-assets/mossmuff_adult.glb") + .expect("mossmuff asset"), + successor_platform::http_get("parity-assets/successor_food_beer_mug.glb") + .expect("beer mug asset"), + successor_platform::http_get("parity-assets/field_cap.glb") + .expect("field cap asset"), + successor_platform::http_get("parity-assets/megalith_brick_hex.glb") + .expect("megalith asset"), + ]; + let scene = + crate::material_parity::build(&mut gpu, &assets).expect("material parity scene"); + PARITY_SCENE.set(scene); + } else if matches!(demo_selector, 2 | 3) { + let biome = if demo_selector == 3 { + crate::world::terrain::Biome::Forest + } else { + crate::world::terrain::Biome::Desert + }; + let mut scene = crate::world::chunks::TerrainScene::build(&mut gpu, biome); + scene.use_material_detail_view(); + TERRAIN_SCENE.set(scene); + } else if is_creator { + match build_creator_surface(&mut gpu) { + Ok(()) => { + CREATOR_FLOW.set(HostedCreatorFlow::default()); + CREATOR_SCREEN.set(CharacterScreen::with_entries(Vec::new())); + CREATOR_INPUT.set(CreatorInput::default()); + successor_platform::web::creator_ready(); + } + Err(error) => { + FATAL.set(true); + successor_engine_core::rt::log::log_str(&error); + } + } + } else if demo_selector == 0 { + let player_id = LAUNCH + .get_mut() + .map(|launch| launch.character_id.clone()) + .unwrap_or_default(); + let mut read_asset = read_web_asset; + match ConnectedScene::build(&mut gpu, &player_id, &mut read_asset) { + Ok(mut scene) => { + let session = successor_platform::now_ms().max(1.0) as u64; + let player = player_id.bytes().fold(2_166_136_261u32, |hash, byte| { + (hash ^ byte as u32).wrapping_mul(16_777_619) + }); + scene.set_command_queue(CommandQueue::new( + SessionId(session), + PlayerId(player.max(1)), + session.saturating_mul(1000), + )); + CONNECTED_SCENE.set(scene); + VIEW_SENT.set(false); + MOVEMENT_CONTROLLER.set(movement::MovementController::default()); + } + Err(error) => { + FATAL.set(true); + successor_engine_core::rt::log::log_str(&error); + } + } + } else { + SCENE.set(build_scene(&mut gpu)); + } + GPU.set(gpu); + DEMO_SELECTOR.set(demo_selector); + FRAME.set(0); + SIZE.set((1280, 720)); + EXITING.set(false); + } + + #[no_mangle] + pub extern "C" fn resize(w: i32, h: i32) { + SIZE.set((w.max(1) as u32, h.max(1) as u32)); + } + + fn bounded_creator_error(prefix: &str, detail: &str) -> String { + const MAX_STATUS_BYTES: usize = 128; + let mut status = String::from(prefix); + let remaining = MAX_STATUS_BYTES.saturating_sub(status.len()); + status.push_str(&detail[..detail.len().min(remaining)]); + status + } + + fn complete_creator_create(screen: &mut CharacterScreen, character_id: &str) { + screen.draft = Default::default(); + screen.set_stage(CharacterStage::Roster); + screen.select_stable_id(character_id); + screen.set_status("CHARACTER CREATED. SELECT TO ENTER WORLD."); + } + + fn apply_creator_effect(effect: CreateEffect) { + let Some(screen) = CREATOR_SCREEN.get_mut() else { + return; + }; + match effect { + CreateEffect::Ignored => {} + CreateEffect::WaitingForRoster => { + screen.set_status("CREATE ACCEPTED. REFRESHING ROSTER."); + } + CreateEffect::Rejected(error) => { + screen.set_status_error(&bounded_creator_error("CREATE REJECTED: ", &error)); + } + CreateEffect::Created(character_id) => complete_creator_create(screen, &character_id), + CreateEffect::TimedOut => { + screen.set_status_error("CREATE TIMED OUT. TRY AGAIN."); + } + } + } + + fn drain_creator_messages() { + for _ in 0..8 { + let Some(bytes) = successor_platform::web::take_creator_message() else { + break; + }; + let Some(message) = parse_inbound_message(&bytes) else { + continue; + }; + match message { + CreatorInbound::State(state) => { + let Some(flow) = CREATOR_FLOW.get_mut() else { + continue; + }; + let effect = flow.apply_state(state); + let roster = flow.roster_entries(); + let Some(screen) = CREATOR_SCREEN.get_mut() else { + continue; + }; + screen.replace_roster(roster); + if let Some(id) = effect.selected_character_id.as_deref() { + screen.select_stable_id(id); + } + if let Some(id) = effect.created_character_id.as_deref() { + complete_creator_create(screen, id); + } + } + CreatorInbound::CreateResult(result) => { + let effect = CREATOR_FLOW + .get_mut() + .map(|flow| flow.handle_create_result(result)) + .unwrap_or(CreateEffect::Ignored); + apply_creator_effect(effect); + } + } + } + } + + fn handle_creator_action(action: ScreenAction) { + match action { + ScreenAction::SelectCharacter(index) => { + let id = CREATOR_SCREEN + .get_mut() + .and_then(|screen| screen.roster.get(index).map(|entry| entry.id.clone())) + .filter(|id| !id.is_empty()); + let Some(id) = id else { + return; + }; + let posted = successor_platform::web::post_creator_select(&id); + if let Some(screen) = CREATOR_SCREEN.get_mut() { + if posted { + screen.set_status("HANDOFF REQUESTED."); + } else { + screen.set_status_error("CHARACTER HANDOFF UNAVAILABLE."); + } + } + } + ScreenAction::CreateCharacter(display_name) => { + let Some((vocation, female)) = CREATOR_SCREEN + .get_mut() + .map(|screen| (screen.draft.vocation(), screen.draft.female)) + else { + return; + }; + let now_ms = successor_platform::now_ms().max(0.0) as u64; + let request = CREATOR_FLOW + .get_mut() + .map(|flow| flow.begin_create(&display_name, vocation, female, now_ms)); + match request { + Some(Ok(request)) => { + if successor_platform::web::post_creator_create(&request.to_json()) { + if let Some(screen) = CREATOR_SCREEN.get_mut() { + screen.set_status("CREATING CHARACTER..."); + } + } else { + if let Some(flow) = CREATOR_FLOW.get_mut() { + flow.cancel_pending(); + } + if let Some(screen) = CREATOR_SCREEN.get_mut() { + screen.set_status_error("CREATE REQUEST UNAVAILABLE."); + } + } + } + Some(Err(CreateStartError::Pending)) => { + if let Some(screen) = CREATOR_SCREEN.get_mut() { + screen.set_status("CREATE ALREADY IN PROGRESS."); + } + } + Some(Err(CreateStartError::InvalidName)) => { + if let Some(screen) = CREATOR_SCREEN.get_mut() { + screen + .set_status_error("NAME: 3-16 LETTERS; USE HYPHENS BETWEEN WORDS."); + } + } + Some(Err(CreateStartError::UnsupportedProfession)) | None => { + if let Some(screen) = CREATOR_SCREEN.get_mut() { + screen.set_status_error("SELECT A STARTING VOCATION."); + } + } + } + } + ScreenAction::Back => { + if let Some(screen) = CREATOR_SCREEN.get_mut() { + screen.set_status("SELECT OR CREATE A CHARACTER."); + } + } + ScreenAction::Connect(_) | ScreenAction::CancelConnect | ScreenAction::Quit => {} + } + } + + fn update_creator(dt: f32) { + drain_creator_messages(); + let now_ms = successor_platform::now_ms().max(0.0) as u64; + let effect = CREATOR_FLOW + .get_mut() + .map(|flow| flow.expire(now_ms)) + .unwrap_or(CreateEffect::Ignored); + apply_creator_effect(effect); + + let (w, h) = SIZE.get_mut().copied().unwrap_or((1280, 720)); + if w == 0 || h == 0 { + return; + } + let action = { + let (Some(screen), Some(ui), Some(input)) = ( + CREATOR_SCREEN.get_mut(), + CREATOR_UI.get_mut(), + CREATOR_INPUT.get_mut(), + ) else { + return; + }; + let (mouse_x, mouse_y) = successor_platform::mouse_position(); + ui.set_input(mouse_x, mouse_y, successor_platform::mouse_button_down(0)); + + let shift = successor_platform::is_key_down(Key::LeftShift); + let tab = successor_platform::is_key_down(Key::Tab); + let tab_edge = tab && !input.tab_was; + input.tab_was = tab; + let enter = successor_platform::is_key_down(Key::Enter); + let enter_edge = enter && !input.enter_was; + input.enter_was = enter; + let backspace = successor_platform::is_key_down(Key::Backspace); + let backspace_edge = backspace && !input.backspace_was; + input.backspace_was = backspace; + let escape = successor_platform::is_key_down(Key::Escape); + let escape_edge = escape && !input.escape_was; + input.escape_was = escape; + let up = successor_platform::is_key_down(Key::Up); + let up_edge = up && !input.up_was; + input.up_was = up; + let down = successor_platform::is_key_down(Key::Down); + let down_edge = down && !input.down_was; + input.down_was = down; + + screen.tick(dt); + while let Some(character) = successor_platform::poll_text_input() { + screen.input_char(character); + } + if tab_edge { + if shift { + screen.focus_prev(); + } else { + screen.focus_next(); + } + } + if backspace_edge { + screen.backspace(); + } + if up_edge { + screen.move_selection(-1, w as f32, h as f32); + } + if down_edge { + screen.move_selection(1, w as f32, h as f32); + } + if escape_edge { + match screen.stage() { + CharacterStage::CreateIdentity => { + screen.set_stage(CharacterStage::CreateProfile) + } + CharacterStage::CreateProfile => screen.set_stage(CharacterStage::Roster), + CharacterStage::Roster => screen.set_status("SELECT OR CREATE A CHARACTER."), + } + } + + ui.begin(w, h); + let mut action = screen.draw(ui, w as f32, h as f32); + if enter_edge && action.is_none() { + action = match screen.stage() { + CharacterStage::Roster => { + Some(ScreenAction::SelectCharacter(screen.selected())) + } + CharacterStage::CreateProfile => { + screen.set_stage(CharacterStage::CreateIdentity); + None + } + CharacterStage::CreateIdentity => { + let name = screen.draft.full_name(); + (!name.is_empty()).then_some(ScreenAction::CreateCharacter(name)) + } + }; + } + action + }; + if let Some(action) = action { + handle_creator_action(action); + } + } + + #[no_mangle] + pub extern "C" fn update(dt: f32) { + FRAME.get_mut().map(|frame| *frame += 1); + CONNECTED_DT.set(dt.clamp(0.0, 0.1)); + if creator_mode() { + update_creator(dt.clamp(0.0, 0.1)); + return; + } + if DEMO_SELECTOR.get_mut().copied().unwrap_or(0) != 0 { + return; + } + let Some(scene) = CONNECTED_SCENE.get_mut() else { + return; + }; + let movement_now_ms = successor_platform::now_ms().max(0.0) as u64; + let ready = SESSION + .get_mut() + .is_some_and(|session| session.state() == SessionState::Ready); + let actor_dead = scene + .player_actor() + .is_some_and(|actor| actor.life_state != "alive"); + let input_locked = scene.tuning_open() || actor_dead; + let intent = if ready && !input_locked { + let (manual_dx, manual_dy, held_sprint) = + movement::intent_from_keys(successor_platform::is_key_down); + let (dx, dy) = scene.navigation_intent(manual_dx, manual_dy); + (dx, dy, held_sprint || scene.sprint_toggled()) + } else { + (0, 0, false) + }; + scene.set_move_intent(intent.0, intent.1, intent.2); + let movement_update = MOVEMENT_CONTROLLER.get_mut().and_then(|controller| { + if !ready { + controller.release(movement_now_ms, movement::StopReason::Disconnected) + } else if actor_dead { + controller.release(movement_now_ms, movement::StopReason::Dead) + } else if input_locked { + controller.release(movement_now_ms, movement::StopReason::ModalInput) + } else { + controller.update( + movement::IntentState { + dx: intent.0, + dy: intent.1, + sprint: intent.2, + }, + movement_now_ms, + ) + } + }); + if let Some(intent) = movement_update { + let _ = scene.dispatch_gameplay_action(actions::GameplayAction::Move { + dx: intent.dx, + dy: intent.dy, + facing: movement::facing_from_intent(intent.dx, intent.dy), + sprint: intent.sprint, + }); + } + if let Some(controller) = MOVEMENT_CONTROLLER.get_mut() { + scene.set_movement_timing(controller.timing(), movement_now_ms); + } + if !ready { + flush_scene_commands(); + return; + } + let shift_down = successor_platform::is_key_down(Key::LeftShift); + for key in CONNECTED_INPUT_KEYS { + if let Some(action) = + scene.handle_key(key, successor_platform::is_key_down(key), shift_down) + { + let _ = scene.dispatch_gameplay_action(action); + } + } + let (mouse_x, mouse_y) = successor_platform::mouse_position(); + if let Some(action) = scene.handle_pointer( + mouse_x, + mouse_y, + successor_platform::mouse_button_down(0), + successor_platform::mouse_button_down(1), + scene.pointer_captured(), + ) { + let _ = scene.dispatch_gameplay_action(action); + } + if let Some((_, scroll_y)) = successor_platform::poll_scroll_delta() { + scene.handle_scroll(scroll_y); + } + flush_scene_commands(); + } + + #[no_mangle] + pub extern "C" fn release_movement_input(reason: u32) { + let stop_reason = match reason { + 0 => movement::StopReason::FocusLost, + _ => movement::StopReason::Transition, + }; + let now_ms = successor_platform::now_ms().max(0.0) as u64; + let movement_update = MOVEMENT_CONTROLLER + .get_mut() + .and_then(|controller| controller.release(now_ms, stop_reason)); + if let Some(scene) = CONNECTED_SCENE.get_mut() { + scene.set_move_intent(0, 0, false); + if let Some(intent) = movement_update { + let _ = scene.dispatch_gameplay_action(actions::GameplayAction::Move { + dx: intent.dx, + dy: intent.dy, + facing: movement::facing_from_intent(intent.dx, intent.dy), + sprint: intent.sprint, + }); + } + } + flush_scene_commands(); + } + + /// Allocation-free connected movement probe. Indices are stable and mirrored + /// by `web/successor.js`; unknown/unavailable values return NaN. + #[no_mangle] + pub extern "C" fn movement_probe(index: u32) -> f64 { + let Some(scene) = CONNECTED_SCENE.get_mut() else { + return f64::NAN; + }; + let Some(movement) = scene.movement_diagnostics() else { + return f64::NAN; + }; + let timing = MOVEMENT_CONTROLLER + .get_mut() + .map(|controller| controller.timing()) + .unwrap_or_default(); + match index { + 0 => movement.authoritative.0 as f64, + 1 => movement.authoritative.1 as f64, + 2 => movement.predicted.0 as f64, + 3 => movement.predicted.1 as f64, + 4 => movement.rendered.0 as f64, + 5 => movement.rendered.1 as f64, + 6 => movement.correction_cells as f64, + 7 => movement.intent.0 as f64, + 8 => movement.intent.1 as f64, + 9 => movement.intent.2 as u8 as f64, + 10 => movement.applied_command_id as f64, + 11 => movement.blocker_count as f64, + 12 => movement.presented_ground_y as f64, + 13 => movement.sampled_ground_y as f64, + 14 => movement.frame_dt_ms as f64, + 15 => timing.last_change_ms as f64, + 16 => timing.last_send_ms as f64, + 17 => timing.next_send_ms as f64, + 18 => timing.stop_retry_until_ms as f64, + 19 => successor_platform::now_ms(), + _ => f64::NAN, + } + } + + #[no_mangle] + pub extern "C" fn render() { + let (w, h) = SIZE.get_mut().copied().unwrap_or((1280, 720)); + if let Some(gpu) = GPU.get_mut() { + if creator_mode() { + if let (Some(renderer), Some(ui)) = + (CREATOR_RENDERER.get_mut(), CREATOR_UI.get_mut()) + { + gpu.begin_pass( + PassTarget::Screen, + RectPx { + x: 0, + y: 0, + w: w as i32, + h: h as i32, + }, + ClearSpec { + color: Some([0.012, 0.027, 0.039, 1.0]), + depth: Some(1.0), + }, + ); + gpu.end_pass(); + renderer.render_ui(gpu, &ui.buf, ui.quads, w, h); + } + return; + } + if DEMO_SELECTOR.get_mut().copied().unwrap_or(0) == 1 { + if let Some(scene) = PARITY_SCENE.get_mut() { + scene + .renderer + .render(gpu, &mut scene.world, w, h) + .expect("render failed"); + } + } else if matches!(DEMO_SELECTOR.get_mut().copied().unwrap_or(0), 2 | 3) { + if let Some(scene) = TERRAIN_SCENE.get_mut() { + scene + .renderer + .render(gpu, &mut scene.world, w, h) + .expect("terrain render failed"); + } + } else if DEMO_SELECTOR.get_mut().copied().unwrap_or(0) == 0 { + if let (Some(scene), Some(chat_client), Some(chat_input)) = ( + CONNECTED_SCENE.get_mut(), + CHAT_CLIENT.get_mut(), + CHAT_INPUT.get_mut(), + ) { + let dt = CONNECTED_DT.get_mut().copied().unwrap_or(1.0 / 60.0); + let mut platform = successor_platform::WebPlatform; + scene.frame(gpu, w, h, dt, &mut platform, chat_client, chat_input); + } + } else if let Some(scene) = SCENE.get_mut() { + scene + .renderer + .render(gpu, &mut scene.world, w, h) + .expect("render failed"); + } + } + } + + #[no_mangle] + pub extern "C" fn probe_material_parity() -> u32 { + if DEMO_SELECTOR.get_mut().copied().unwrap_or(0) != 1 { + return 0; + } + let (width, height) = SIZE.get_mut().copied().unwrap_or((0, 0)); + let pixels = successor_platform::read_pixels_rgba(width as i32, height as i32); + match crate::material_parity::probe_rgba_top_left(&pixels, width, height) { + Ok(_) => 1, + Err(error) => { + successor_engine_core::rt::log::log_str(&error); + 0 + } + } + } + + #[no_mangle] + pub extern "C" fn probe_terrain_material() -> u32 { + if !matches!(DEMO_SELECTOR.get_mut().copied().unwrap_or(0), 2 | 3) { + return 0; + } + let (width, height) = SIZE.get_mut().copied().unwrap_or((0, 0)); + let pixels = successor_platform::read_pixels_rgba(width as i32, height as i32); + match crate::world::terrain_material::probe_rgba(&pixels, width, height) { + Ok(probe) => { + successor_engine_core::rt::log::log_str(&format!( + "terrain probe stddev={:.5} neighbor={:.5} repeat={:.5}\n", + probe.luma_stddev, probe.neighbor_delta, probe.repeat_delta + )); + 1 + } + Err(error) => { + successor_engine_core::rt::log::log_str(&error); + 0 + } + } + } + + // --- wasm networking runtime -------------------------------------------- + // The sans-IO `Session` FSM + Colyseus matchmake/framing (client-proto) are + // target-agnostic; here they run on the browser WebSocket/fetch shim + // (`platform::web::net`). JS drives `net_connect` once, then `net_poll` each + // frame; `net_state` exposes the handshake state for the page. + + static SESSION: GlobalCell = GlobalCell::new(); + static WS: GlobalCell = GlobalCell::new(); + static CHAT_CLIENT: GlobalCell = GlobalCell::new(); + static CHAT_INPUT: GlobalCell = GlobalCell::new(); + static CHAT_TICKET: GlobalCell> = GlobalCell::new(); + static CHAT_WS: GlobalCell = GlobalCell::new(); + static CLIENT_RELEASE: GlobalCell = GlobalCell::new(); + + #[no_mangle] + pub extern "C" fn net_connect() { + let envelope = match LAUNCH.get_mut() { + Some(envelope) => envelope, + None => return, + }; + let game_ticket = match envelope.consume_game_ticket() { + Ok(ticket) => ticket, + Err(_) => return, + }; + let chat_ticket = match envelope.consume_chat_ticket() { + Ok(ticket) => ticket, + Err(_) => return, + }; + let endpoint = envelope.game_endpoint.clone(); + let chat_endpoint = envelope.chat_endpoint.clone(); + let client_release = envelope.client_release.clone(); + let http = endpoint + .replacen("wss://", "https://", 1) + .replacen("ws://", "http://", 1); + let opts = if game_ticket == "dev-identity" { + let mut opts = json!({ + "playerId": envelope.character_id, + "actorId": envelope.character_id, + }); + if let (Some(object), Some(spawn)) = (opts.as_object_mut(), envelope.dev_spawn.as_ref()) + { + object.insert("spawnArea".into(), json!(spawn.area)); + object.insert("spawnX".into(), json!(spawn.x)); + object.insert("spawnY".into(), json!(spawn.y)); + object.insert("facing".into(), json!(spawn.facing)); + } + opts + } else { + json!({ + "gameTicket": game_ticket, + "release": envelope.client_release, + }) + }; + let (url, body) = match colyseus::build_matchmake_request(&http, &opts) { + Ok(v) => v, + Err(_) => return, + }; + let resp = match successor_platform::http_post_json(&url, &body) { + Ok(r) => r, + Err(_) => return, + }; + let seat = match colyseus::parse_seat_reservation(&resp) { + Ok(s) => s, + Err(_) => return, + }; + let ws_url = colyseus::build_ws_url(&endpoint, &seat); + if let Ok(ws) = successor_platform::ws_connect(&ws_url) { + let mut s = Session::new(); + s.start_connecting(); + SESSION.set(s); + WS.set(ws); + } + let mut chat_client = ChatClient::with_endpoint(128, chat_endpoint.clone()); + chat_client.connection.begin(); + CHAT_CLIENT.set(chat_client); + CHAT_INPUT.set(successor_engine_render::ui::TextField::new(320)); + if let Ok(chat_ws) = successor_platform::ws_connect(&chat_endpoint) { + CHAT_TICKET.set(Some(chat_ticket)); + CHAT_WS.set(chat_ws); + CLIENT_RELEASE.set(client_release); + } + } + + #[no_mangle] + pub extern "C" fn net_exit_world() -> u32 { + let (session, socket) = match (SESSION.get_mut(), WS.get_mut()) { + (Some(session), Some(socket)) if session.state() == SessionState::Ready => { + (session, socket) + } + _ => return 0, + }; + match session.exit_world() { + Ok(SessionOut::SendFrame(frame)) => { + successor_platform::ws_send(socket, &frame); + EXITING.set(true); + 1 + } + _ => 0, + } + } + + #[no_mangle] + pub extern "C" fn net_exit_complete() -> u32 { + if !EXITING.get_mut().copied().unwrap_or(false) { + return 0; + } + SESSION + .get_mut() + .is_some_and(|session| session.state() != SessionState::Ready) as u32 + } + #[no_mangle] + pub extern "C" fn context_restored() { + let player_id = LAUNCH + .get_mut() + .map(|launch| launch.character_id.clone()) + .unwrap_or_default(); + let mut gpu = successor_platform::create_gpu(); + let mut read_asset = read_web_asset; + match ConnectedScene::build(&mut gpu, &player_id, &mut read_asset) { + Ok(mut rebuilt) => { + if let Some(previous) = CONNECTED_SCENE.get_mut() { + rebuilt.restore_projection_from(previous); + } + CONNECTED_SCENE.set(rebuilt); + GPU.set(gpu); + successor_engine_core::rt::log::log_str("webgl context restored"); + } + Err(error) => { + FATAL.set(true); + successor_engine_core::rt::log::log_str(&error); + } + } + } + + #[no_mangle] + pub extern "C" fn net_poll() { + let (session, socket) = match (SESSION.get_mut(), WS.get_mut()) { + (Some(session), Some(socket)) => (session, socket), + _ => return, + }; + let mut buffer = Vec::with_capacity(64 * 1024); + for _ in 0..64 { + buffer.clear(); + let event = successor_platform::ws_poll(socket, &mut buffer); + let (outputs, stop) = match event { + successor_platform::WsEvent::Open => (session.on_ws_event(WsInput::Open), false), + successor_platform::WsEvent::Frame(length) => ( + session.on_ws_event(WsInput::Frame(&buffer[..length])), + false, + ), + successor_platform::WsEvent::Closed => (session.on_ws_event(WsInput::Closed), true), + successor_platform::WsEvent::Error => { + (session.on_ws_event(WsInput::Error("ws error")), true) + } + successor_platform::WsEvent::None => break, + }; + for output in outputs { + match output { + SessionOut::SendFrame(frame) => successor_platform::ws_send(socket, &frame), + SessionOut::Emit(SessionEvent::Hello(hello)) => { + if let Some(scene) = CONNECTED_SCENE.get_mut() { + scene.on_snapshot(&hello.snapshot); + } + } + SessionOut::Emit(SessionEvent::Packet(packet)) => { + if let Some(scene) = CONNECTED_SCENE.get_mut() { + scene.apply_server_packet(packet); + } + } + SessionOut::Emit(SessionEvent::Room { msg_type, payload }) => { + if let Some(scene) = CONNECTED_SCENE.get_mut() { + scene.apply_room_message(&msg_type, &payload); + } + } + SessionOut::Emit(SessionEvent::Error(message)) => { + FATAL.set(true); + successor_engine_core::rt::log::log_str(&message); + } + SessionOut::Emit(SessionEvent::Closed) + | SessionOut::Emit(SessionEvent::ReconnectAttempt { .. }) => {} + } + } + if stop { + break; + } + } + if session.state() == SessionState::Ready && !VIEW_SENT.get_mut().copied().unwrap_or(false) + { + let view = json!({ "viewport_width_cells": 96, "viewport_height_cells": 96, "margin_cells": 32 }); + if let Ok(SessionOut::SendFrame(frame)) = session.send_view(&view) { + successor_platform::ws_send(socket, &frame); + VIEW_SENT.set(true); + } + } + let (chat_client, chat_ticket, chat_socket, client_release) = match ( + CHAT_CLIENT.get_mut(), + CHAT_TICKET.get_mut(), + CHAT_WS.get_mut(), + CLIENT_RELEASE.get_mut(), + ) { + (Some(client), Some(ticket), Some(socket), Some(release)) => { + (client, ticket, socket, release) + } + _ => return, + }; + let mut chat_buffer = Vec::with_capacity(64 * 1024); + for _ in 0..32 { + chat_buffer.clear(); + match successor_platform::ws_poll(chat_socket, &mut chat_buffer) { + successor_platform::WsEvent::Open => { + if let Some(frame) = chat_client + .connection + .authenticate(chat_ticket, client_release) + { + successor_platform::ws_send(chat_socket, frame.as_bytes()); + } + } + successor_platform::WsEvent::Frame(length) => { + let _ = + chat_client.on_incoming(&String::from_utf8_lossy(&chat_buffer[..length])); + } + successor_platform::WsEvent::Closed | successor_platform::WsEvent::Error => { + let _ = chat_client.connection.lost(); + break; + } + successor_platform::WsEvent::None => break, + } + } + } + + fn flush_scene_commands() { + let (scene, session, socket) = + match (CONNECTED_SCENE.get_mut(), SESSION.get_mut(), WS.get_mut()) { + (Some(scene), Some(session), Some(socket)) => (scene, session, socket), + _ => return, + }; + while let Some(envelope) = scene.take_next_command() { + if let Ok(SessionOut::SendFrame(frame)) = session.send_command(&envelope) { + successor_platform::ws_send(socket, &frame); + } + } + } + + /// Session handshake state as a small code for the page (0 = not started). + #[no_mangle] + pub extern "C" fn net_state() -> u32 { + SESSION.get_mut().map(|s| s.state() as u32).unwrap_or(0) + } + + #[no_mangle] + pub extern "C" fn net_fatal() -> u32 { + FATAL.get_mut().copied().unwrap_or(false) as u32 + } +} diff --git a/client-rust/source/app/src/main.rs b/client-rust/source/app/src/main.rs new file mode 100644 index 00000000..d0b4cdf8 --- /dev/null +++ b/client-rust/source/app/src/main.rs @@ -0,0 +1,3195 @@ +#![allow(clippy::same_item_push, clippy::unnecessary_unwrap)] + +//! Successor Rust client — native entry point. +//! +//! Modes: +//! successor --demo parity-basic --frames 600 [--stats-json PATH] [--assert-zero-allocs] +//! Headless: runs the standard scene through `NullGpu`, measuring frame +//! p50/p99, peak RSS, and steady-state allocations. No window. Used by +//! `make runtime-check` / `make check-allocs`. +//! successor --demo parity-basic --gl +//! Opens a GL window (visual QA) and renders the scene until closed. +//! successor --demo ui [--surface actions|inventory|...] [--screenshot PATH] +//! Captures one registered movable/resizable surface over clean game HUD. +//! successor --demo pregame [--stage entry|connecting|roster|roster-empty| +//! create-profile|create-summary|loading] +//! [--frames N] [--screenshot PATH] +//! Opens a GL window on the pregame flow (login → roster → creation). +//! Presentation only: no socket, no launch context, no roster authority. +//! successor --endpoint ws://127.0.0.1:28093 --player-id dev-1 --actor-id dev-1 +//! (Playable slice — wired in the PlayableSlice phase.) + +use successor_client::demo; +use successor_client::net::session::LaunchEnvelope; + +fn main() { + let args: Vec = std::env::args().collect(); + if args.iter().any(|arg| arg == "--model-corpus") { + #[cfg(feature = "dev-tools")] + { + run_model_corpus(); + return; + } + #[cfg(not(feature = "dev-tools"))] + { + eprintln!("developer probes and demo modes require the `dev-tools` capability"); + std::process::exit(2); + } + } + #[cfg(all(not(target_arch = "wasm32"), feature = "dev-tools"))] + configure_automation(&args); + #[cfg(all(not(target_arch = "wasm32"), not(feature = "dev-tools")))] + if args.iter().any(|arg| { + matches!( + arg.as_str(), + "--control" + | "--control-port" + | "--record-input" + | "--replay-input" + | "--screenshot" + | "--stats-json" + | "--gpu-stats-json" + | "--assert-zero-allocs" + | "--assert-material-parity" + | "--assert-terrain-material" + ) || arg.starts_with("--demo") + }) { + eprintln!("developer probes and demo modes require the `dev-tools` capability"); + std::process::exit(2); + } + + let mode = arg_value(&args, "--demo"); + let frames: u64 = arg_value(&args, "--frames") + .and_then(|s| s.parse().ok()) + .unwrap_or(600); + let stats_json = arg_value(&args, "--stats-json"); + let assert_zero = args.iter().any(|a| a == "--assert-zero-allocs"); + let gl = args.iter().any(|a| a == "--gl"); + let endpoint = arg_value(&args, "--endpoint"); + let player_arg = arg_value(&args, "--player-id"); + let actor_arg = arg_value(&args, "--actor-id"); + // A durable roster character must join by `characterId`: the shard rejects + // the bare `{playerId, actorId}` dev shape for any id its character store + // already owns ("durable character identity required"). + #[cfg(feature = "dev-tools")] + let character_arg = arg_value(&args, "--character-id"); + #[cfg(feature = "dev-tools")] + let (spawn_area_arg, spawn_x_arg, spawn_y_arg, spawn_facing_arg) = ( + arg_value(&args, "--spawn-area"), + arg_value(&args, "--spawn-x"), + arg_value(&args, "--spawn-y"), + arg_value(&args, "--spawn-facing"), + ); + #[cfg(feature = "dev-tools")] + let dev_spawn = match ( + spawn_area_arg.as_deref(), + spawn_x_arg.as_deref(), + spawn_y_arg.as_deref(), + ) { + (Some(area), Some(x), Some(y)) => { + Some((area, x, y, spawn_facing_arg.as_deref().unwrap_or("right"))) + } + (None, None, None) => None, + _ => { + eprintln!("development spawn requires --spawn-area, --spawn-x, and --spawn-y"); + std::process::exit(2); + } + }; + let dev_identity = args.iter().any(|a| a == "--dev-identity"); + #[cfg(not(feature = "dev-tools"))] + let _ = dev_identity; + + // Raw identity flags are strictly a development capability. In release + // builds they cannot accidentally become an alternate authenticated path. + if endpoint.is_some() || player_arg.is_some() || actor_arg.is_some() { + #[cfg(not(feature = "dev-tools"))] + { + eprintln!("raw endpoint/identity launch requires dev-tools"); + std::process::exit(2); + } + #[cfg(feature = "dev-tools")] + if !dev_identity || endpoint.is_none() || player_arg.is_none() || actor_arg.is_none() { + eprintln!( + "raw launch requires --dev-identity, --endpoint, --player-id, and --actor-id" + ); + std::process::exit(2); + } + } + if let Some(q) = arg_value(&args, "--quality") { + successor_client::set_render_quality(successor_client::parse_quality(&q)); + } + + #[cfg(not(target_arch = "wasm32"))] + if mode.is_none() { + let max_frames = arg_value(&args, "--frames").and_then(|s| s.parse::().ok()); + let screenshot = arg_value(&args, "--screenshot"); + let auto_walk = args.iter().any(|a| a == "--auto-walk"); + #[cfg(feature = "dev-tools")] + if let (Some(endpoint), Some(player), Some(actor)) = (endpoint, player_arg, actor_arg) { + std::process::exit(connected::run_dev( + &endpoint, + &player, + &actor, + character_arg.as_deref(), + dev_spawn, + max_frames, + screenshot.as_deref(), + auto_walk, + assert_zero, + )); + } + let raw = arg_value(&args, "--launch-context").unwrap_or_else(|| { + eprintln!("ordinary launch requires --launch-context "); + std::process::exit(2); + }); + let text = if raw.trim_start().starts_with('{') { + raw + } else { + std::fs::read_to_string(&raw).unwrap_or_else(|e| { + eprintln!("failed to read launch context: {e}"); + std::process::exit(2); + }) + }; + let value: serde_json::Value = serde_json::from_str(&text).unwrap_or_else(|e| { + eprintln!("invalid launch context JSON: {e}"); + std::process::exit(2); + }); + let mut envelope = LaunchEnvelope::from_json( + &value, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0), + ) + .unwrap_or_else(|e| { + eprintln!("launch rejected: {e:?}"); + std::process::exit(2); + }); + std::process::exit(connected::run_launch( + &mut envelope, + max_frames, + screenshot.as_deref(), + auto_walk, + assert_zero, + )); + } + + if mode.as_deref() == Some("glb-view") { + let glb = arg_value(&args, "--glb").unwrap_or_else(|| { + eprintln!("--demo glb-view requires --glb "); + std::process::exit(2); + }); + let clip = arg_value(&args, "--clip"); + let screenshot = arg_value(&args, "--screenshot"); + run_glb_view(&glb, clip.as_deref(), frames, screenshot.as_deref()); + return; + } + + if matches!(mode.as_deref(), Some("terrain" | "terrain-material")) { + let biome = arg_value(&args, "--biome"); + let screenshot = arg_value(&args, "--screenshot"); + let gpu_stats = arg_value(&args, "--gpu-stats-json"); + let assert_material = args.iter().any(|arg| arg == "--assert-terrain-material"); + run_terrain( + biome.as_deref(), + frames, + screenshot.as_deref(), + gpu_stats.as_deref(), + assert_material, + mode.as_deref() == Some("terrain-material"), + ); + return; + } + + if mode.as_deref() == Some("props") { + let screenshot = arg_value(&args, "--screenshot"); + run_props(frames, screenshot.as_deref()); + return; + } + + if mode.as_deref() == Some("gi") { + let screenshot = arg_value(&args, "--screenshot"); + let animate_camera = args.iter().any(|arg| arg == "--animate-camera"); + let assert_stable_gi = args.iter().any(|arg| arg == "--assert-stable-gi"); + run_gi( + frames, + screenshot.as_deref(), + animate_camera, + assert_stable_gi, + ); + return; + } + + if mode.as_deref() == Some("pawns") { + let screenshot = arg_value(&args, "--screenshot"); + let camera_distance = arg_value(&args, "--camera-distance") + .and_then(|value| value.parse::().ok()) + .unwrap_or(6.0) + .clamp(1.5, 20.0); + let camera_height = arg_value(&args, "--camera-height") + .and_then(|value| value.parse::().ok()) + .unwrap_or(1.5) + .clamp(-2.0, 10.0); + let camera_yaw = arg_value(&args, "--camera-yaw-deg") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0.0) + .to_radians(); + let orbit_speed = if args.iter().any(|arg| arg == "--camera-static") { + 0.0 + } else { + 0.01 + }; + run_pawns( + frames, + screenshot.as_deref(), + camera_distance, + camera_height, + camera_yaw, + orbit_speed, + ); + return; + } + + if mode.as_deref() == Some("ui") { + let screenshot = arg_value(&args, "--screenshot"); + let surface = arg_value(&args, "--surface"); + run_ui(frames, screenshot.as_deref(), surface.as_deref()); + return; + } + + // Pregame parity host. Presentation only: it drives `screens.rs` with a + // local demo roster and never touches launch/auth authority. + #[cfg(not(target_arch = "wasm32"))] + if mode.as_deref() == Some("pregame") { + let screenshot = arg_value(&args, "--screenshot"); + let stage = arg_value(&args, "--stage"); + run_pregame(frames, screenshot.as_deref(), stage.as_deref()); + return; + } + + if mode.as_deref() == Some("fx") { + let screenshot = arg_value(&args, "--screenshot"); + run_fx(frames, screenshot.as_deref()); + return; + } + + if mode.as_deref() == Some("env") { + let screenshot = arg_value(&args, "--screenshot"); + let minute = arg_value(&args, "--minute") + .and_then(|s| s.parse::().ok()) + .unwrap_or(720.0); + run_env(minute, frames, screenshot.as_deref()); + return; + } + + if mode.as_deref() == Some("material-parity") { + let screenshot = arg_value(&args, "--screenshot"); + let gpu_stats = arg_value(&args, "--gpu-stats-json"); + let assert_parity = args.iter().any(|arg| arg == "--assert-material-parity"); + run_material_parity( + frames, + screenshot.as_deref(), + gpu_stats.as_deref(), + assert_parity, + ); + return; + } + + if mode.is_some() || stats_json.is_some() || assert_zero { + if gl { + let screenshot = arg_value(&args, "--screenshot"); + run_windowed(frames, screenshot.as_deref(), None); + } else { + run_headless(frames, stats_json.as_deref(), assert_zero); + } + return; + } + + eprintln!( + "successor: no mode selected. Try `--demo parity-basic [--gl] [--frames N] \ + [--stats-json PATH] [--assert-zero-allocs]`, or `--demo pregame \ + [--stage entry|connecting|roster|roster-empty|create-profile|create-summary|loading] \ + [--frames N] [--screenshot PATH]`." + ); + std::process::exit(2); +} + +fn run_headless(frames: u64, stats_json: Option<&str>, assert_zero: bool) { + let stats = demo::run_headless(frames); + println!( + "parity-basic headless: {} frames | p50={:.3}ms p99={:.3}ms peak_rss={}B frame-allocs {}", + frames, + stats.frame_p50_ms, + stats.frame_p99_ms, + stats.peak_rss_bytes, + stats.frame_allocs_steady + ); + if let Some(path) = stats_json { + if let Err(e) = std::fs::write(path, stats.to_json()) { + eprintln!("failed to write stats json {path}: {e}"); + std::process::exit(1); + } + } + if assert_zero && stats.frame_allocs_steady != 0 { + eprintln!( + "ALLOC GATE FAIL: {} steady-state per-frame allocations (expected 0)", + stats.frame_allocs_steady + ); + std::process::exit(1); + } + println!("frame-allocs {}", stats.frame_allocs_steady); +} + +#[cfg(not(target_arch = "wasm32"))] +fn run_material_parity( + frames: u64, + screenshot: Option<&str>, + gpu_stats_json: Option<&str>, + assert_parity: bool, +) { + use successor_client::material_parity; + use successor_engine_render::gpu::Gpu; + if !successor_platform::init( + "Successor Material Parity", + material_parity::WIDTH as i32, + material_parity::HEIGHT as i32, + ) { + eprintln!("platform init failed (no display?)"); + std::process::exit(1); + } + let assets: [Vec; 6] = material_parity::ASSET_PATHS.map(|path| { + std::fs::read(path).unwrap_or_else(|error| { + eprintln!("failed to read parity asset {path}: {error}"); + std::process::exit(1); + }) + }); + let mut gpu = successor_platform::create_gpu(); + let mut scene = material_parity::build(&mut gpu, &assets).unwrap_or_else(|error| { + eprintln!("failed to build material parity scene: {error}"); + std::process::exit(1); + }); + let total = frames.max(1); + let mut gpu_times = Vec::with_capacity(total.saturating_sub(120) as usize); + let mut final_rgba = None; + for frame in 0..total { + successor_platform::begin_frame(); + let (width, height) = successor_platform::framebuffer_size(); + let start = std::time::Instant::now(); + if width > 0 && height > 0 { + scene + .renderer + .render(&mut gpu, &mut scene.world, width as u32, height as u32) + .expect("material parity render"); + } + if gpu_stats_json.is_some() { + gpu.finish(); + if frame >= 120 { + gpu_times.push(start.elapsed().as_secs_f64() * 1_000.0); + } + } + if frame + 1 == total && width > 0 && height > 0 && (screenshot.is_some() || assert_parity) + { + final_rgba = Some(( + successor_platform::read_pixels_rgba(width, height), + width as u32, + height as u32, + )); + } + successor_platform::end_frame(); + if successor_platform::should_quit() { + break; + } + } + if let Some((rgba, width, height)) = final_rgba { + if let Some(path) = screenshot { + write_bmp(path, &rgba, width, height).unwrap_or_else(|error| { + eprintln!("screenshot failed: {error}"); + std::process::exit(1); + }); + println!("screenshot written: {path} ({width}x{height})"); + } + if assert_parity { + material_parity::probe_rgba_top_left(&rgba, width, height).unwrap_or_else(|error| { + eprintln!("{error}"); + std::process::exit(1); + }); + } + } + if let Some(path) = gpu_stats_json { + if gpu_times.is_empty() { + eprintln!("GPU stats require more than 120 rendered frames"); + std::process::exit(1); + } + gpu_times.sort_by(f64::total_cmp); + let index = ((gpu_times.len() - 1) as f64 * 0.99).ceil() as usize; + let p99 = gpu_times[index]; + let json = format!( + "{{\"demo\":\"material-parity\",\"width\":{},\"height\":{},\"warmup_frames\":120,\"measured_frames\":{},\"render_gpu_p99_ms\":{:.6}}}\n", + material_parity::WIDTH, + material_parity::HEIGHT, + gpu_times.len(), + p99 + ); + std::fs::write(path, json).unwrap_or_else(|error| { + eprintln!("failed to write GPU stats {path}: {error}"); + std::process::exit(1); + }); + println!("material-parity render_gpu_p99_ms={p99:.3}"); + } + successor_platform::deinit(); +} + +#[cfg(not(target_arch = "wasm32"))] +fn run_windowed(frames: u64, screenshot: Option<&str>, gpu_stats_json: Option<&str>) { + use successor_engine_core::input::Key; + use successor_engine_render::gpu::Gpu; + if !successor_platform::init( + "Successor (Rust client)", + demo::SCREEN_W as i32, + demo::SCREEN_H as i32, + ) { + eprintln!("platform init failed (no display?)"); + std::process::exit(1); + } + let mut gpu = successor_platform::create_gpu(); + let mut scene = demo::build_scene(&mut gpu); + let icons = successor_client::hud::Icons::load(); + scene + .renderer + .set_ui_atlas(&mut gpu, icons.meta.width, icons.meta.height, &icons.rgba); + let mut ui = icons.ui_builder(); + let mut graphics_tuner = successor_client::graphics_tuning::GraphicsTuner::new(); + let total = frames.max(1); + let mut frame = 0u64; + let mut gpu_times = Vec::with_capacity(total.saturating_sub(120) as usize); + while !successor_platform::should_quit() && frame < total { + successor_platform::begin_frame(); + scene.animate(frame); + graphics_tuner.handle_toggle(successor_platform::is_key_down(Key::Backquote)); + let (w, h) = successor_platform::framebuffer_size(); + let start = std::time::Instant::now(); + if w > 0 && h > 0 { + scene + .renderer + .render(&mut gpu, &mut scene.world, w as u32, h as u32) + .expect("render failed"); + } + if w > 0 && h > 0 { + let (mx, my) = successor_platform::mouse_position(); + ui.set_input(mx, my, successor_platform::mouse_button_down(0)); + ui.begin(w as u32, h as u32); + graphics_tuner.draw(&mut ui, &mut scene.renderer, &mut gpu, w as u32, h as u32); + scene + .renderer + .render_ui(&mut gpu, &ui.buf, ui.quads, w as u32, h as u32); + } + if gpu_stats_json.is_some() { + gpu.finish(); + if frame >= 120 { + gpu_times.push(start.elapsed().as_secs_f64() * 1_000.0); + } + } + if screenshot.is_some() && frame + 1 == total && w > 0 && h > 0 { + let err = successor_platform::gl_error(); + if err != 0 { + eprintln!("GL error before readback: 0x{err:04x}"); + } + let rgba = successor_platform::read_pixels_rgba(w, h); + match write_bmp(screenshot.unwrap(), &rgba, w as u32, h as u32) { + Ok(()) => println!("screenshot written: {} ({}x{})", screenshot.unwrap(), w, h), + Err(e) => eprintln!("screenshot failed: {e}"), + } + } + successor_platform::end_frame(); + frame += 1; + } + if let Some(path) = gpu_stats_json { + if gpu_times.is_empty() { + eprintln!("GPU stats require more than 120 rendered frames"); + std::process::exit(1); + } + gpu_times.sort_by(f64::total_cmp); + let index = ((gpu_times.len() - 1) as f64 * 0.99).ceil() as usize; + let p99 = gpu_times[index]; + let json = format!( + "{{\"demo\":\"material-parity\",\"width\":{},\"height\":{},\"warmup_frames\":120,\"measured_frames\":{},\"render_gpu_p99_ms\":{:.6}}}\n", + demo::SCREEN_W, + demo::SCREEN_H, + gpu_times.len(), + p99 + ); + if let Err(error) = std::fs::write(path, json) { + eprintln!("failed to write GPU stats {path}: {error}"); + std::process::exit(1); + } + println!("material-parity render_gpu_p99_ms={p99:.3}"); + } + successor_platform::deinit(); +} + +#[cfg(not(target_arch = "wasm32"))] +fn run_ui(frames: u64, screenshot: Option<&str>, surface: Option<&str>) { + use successor_client::hud; + use successor_engine_render::gpu::Gpu; + use successor_engine_render::window::{WindowManager, WindowStyle}; + if !successor_platform::init("Successor UI", demo::SCREEN_W as i32, demo::SCREEN_H as i32) { + eprintln!("platform init failed (no display?)"); + std::process::exit(1); + } + let mut gpu = successor_platform::create_gpu(); + let _ = &mut gpu as &mut dyn Gpu; + let mut scene = demo::build_ui_scene(&mut gpu); + let icons = hud::Icons::load(); + scene + .renderer + .set_ui_atlas(&mut gpu, icons.meta.width, icons.meta.height, &icons.rgba); + let mut ui = icons.ui_builder(); + let mut hud_state = hud::HudState::default(); + let mut toolbar = hud::toolbar::Toolbar::new(hud::toolbar::ToolbarDoc::blank()); + // Demo-only slot seeding so the bar photographs occupied (connected mode + // starts blank per the owner spec and loads the persisted doc). + toolbar + .doc + .assign(0, hud::toolbar::SlotRef::Action("attack".into())); + toolbar + .doc + .assign(1, hud::toolbar::SlotRef::Action("reload".into())); + toolbar + .doc + .assign(4, hud::toolbar::SlotRef::Action("window:inventory".into())); + let mut hud_actions: Vec = Vec::with_capacity(8); + let mut right_was_down = false; + let win_model = successor_client::windows::WindowModel::visual_sample(); + // This fixture lives only in the `--demo ui` host; it is not a connected + // projection. Pointerless inventory captures seed a concrete first stack + // so the footer shows its details and authority-shaped actions. + successor_client::windows::inventory::clear_selection(); + if surface == Some("inventory") { + if let Some(row) = win_model.inventory.held().next() { + successor_client::windows::inventory::select_identity(&row.container, &row.stack_id); + } + } + // The demo consumes the same geometry registry as connected mode; a + // screenshot is therefore proof of the shipping defaults, not a second + // cascade policy. + let mut wm = WindowManager::new(); + for (id, title, icon) in hud::DEMO_WINDOWS { + let (rect, min_w, min_h) = successor_client::windows::spec::geometry( + id, + demo::SCREEN_W as f32, + demo::SCREEN_H as f32, + ); + wm.register(id, title, icons.cell(icon), rect, min_w, min_h); + } + hud::register_hud_surfaces_at( + &mut wm, + &icons, + (demo::SCREEN_W as f32, demo::SCREEN_H as f32), + ); + // A screenshot run is pointer-less, so seed one requested surface plus + // representative live HUD state. One window at a time keeps overlap from + // obscuring the component being reviewed. Pool depths come from the + // original client's own authority export (`world-entry` checkpoint: + // health 700, action 900, mind 1000), so the triple bar photographs at + // real proportions instead of invented ones. + if screenshot.is_some() { + wm.open(surface.unwrap_or("actions")); + hud_state.connection = hud::ConnectionHud::Live; + hud_state.fine_text = "LIVE / 3 IN FIELD".into(); + hud_state.name = "DEMO OPERATIVE".into(); + hud_state.health = hud::GaugeHud { + value: 616.0, + max: 700.0, + }; + hud_state.action = hud::GaugeHud { + value: 540.0, + max: 900.0, + }; + hud_state.spirit = hud::GaugeHud { + value: 1000.0, + max: 1000.0, + }; + hud_state.health_text = "616".into(); + hud_state.action_text = "540".into(); + hud_state.spirit_text = "1000".into(); + hud_state.area_label = "OPEN-DESERT".into(); + hud_state.weapon = Some(hud::WeaponHud { + label: "SLUGTHROWER PISTOL".into(), + melee: false, + magazine_size: 8, + loaded_rounds: 6, + rounds_text: "6/8 - 24".into(), + reloading: false, + reload_frac: 0.0, + swing_ready: false, + swing_frac: 0.0, + }); + hud_state.target = Some(hud::TargetHud { + actor_id: "raider".into(), + name: "RAIDER SCOUT".into(), + relation: hud::RelationHud::Hostile, + health: hud::GaugeHud { + value: 434.0, + max: 700.0, + }, + action: Some(hud::GaugeHud { + value: 720.0, + max: 900.0, + }), + spirit: Some(hud::GaugeHud { + value: 850.0, + max: 1000.0, + }), + distance_m: Some(23.0), + level: None, + alive: true, + stamp: None, + chips: vec![hud::ChipHud { + label: "HOSTILE".into(), + danger: true, + }], + }); + } + let total = frames.max(1); + let mut frame = 0u64; + while !successor_platform::should_quit() && frame < total { + successor_platform::begin_frame(); + scene.animate(frame); + // Route pointer + text input into the UI. + let (mx, my) = successor_platform::mouse_position(); + ui.set_input(mx, my, successor_platform::mouse_button_down(0)); + let right_down = successor_platform::mouse_button_down(1); + let right_pressed = right_down && !right_was_down; + right_was_down = right_down; + while successor_platform::poll_text_input().is_some() {} + let (w, h) = successor_platform::framebuffer_size(); + if w > 0 && h > 0 { + // The live rotating paperdoll fills whichever open pane owns a + // viewer cell, so a UI capture shows the same doll connected mode + // composites behind that cell. + let paperdoll_pane = ["inventory", "examine", "converse"] + .into_iter() + .find(|id| wm.is_open(id) && !wm.is_iconified(id)) + .and_then(|id| wm.content_rect(id).map(|content| (id, content))) + .map(|(id, content)| match id { + "examine" => successor_client::windows::live::examine_preview_rect(content), + "converse" => successor_client::windows::live::converse_preview_rect(content), + _ => successor_client::windows::inventory::layout(content).preview, + }); + scene.set_paperdoll_viewport(paperdoll_pane, w as f32, h as f32); + scene + .renderer + .render(&mut gpu, &mut scene.world, w as u32, h as u32) + .expect("render failed"); + ui.begin(w as u32, h as u32); + // HUD panes are locked by default. Their right-click context + // toggle is intentionally separate from left-button manager input, + // so normal gameplay clicks pass straight through. + let hud_lock_changed = right_pressed + .then(|| hud::toggle_hud_surface_lock_at(&mut wm, mx, my)) + .flatten(); + // Windows resolve pointer first (topmost consumes drag/close/focus). + wm.update_at( + &ui, + w as u32, + h as u32, + successor_platform::now_ms().max(0.0) as u64, + ); + let captured = wm.pointer_captured() || hud_lock_changed.is_some(); + ui.set_input_enabled(!captured); + let palette = hud::palette(0); + hud_actions.clear(); + let mut hud_frame = hud::HudFrame { + state: &hud_state, + toolbar: &mut toolbar, + chat: None, + palette, + now_ms: successor_platform::now_ms().max(0.0) as u64, + captured, + right_pressed, + }; + hud::build_hud( + &mut ui, + &icons, + &mut hud_frame, + &wm, + w as u32, + h as u32, + &mut hud_actions, + ); + ui.set_input_enabled(true); + for action in hud_actions.drain(..) { + match action { + hud::HudAction::ToggleWindow(id) => wm.toggle(id), + hud::HudAction::OpenWindow(id) => wm.open(id), + other => println!("ui action: {other:?}"), + } + } + // Draw open windows back-to-front over the HUD. HUD panes already + // drew their own content and layout affordance in `build_hud`. + let style = WindowStyle::default(); + for idx in wm.z_order() { + let id = wm.window_id(idx).to_string(); + let rect = wm.draw_chrome(&mut ui, idx, style); + if hud::is_hud_surface(&id) { + continue; + } + // Demo windows discard emitted intents (no live authority to + // route them to); inventory examine selection is window-local. + let mut actions = Vec::new(); + successor_client::windows::content( + &mut ui, + &id, + rect, + &win_model, + &icons, + &mut actions, + ); + drop(actions); + } + scene + .renderer + .render_ui(&mut gpu, &ui.buf, ui.quads, w as u32, h as u32); + // 3D viewers draw over the finished panel, as the original does + // after flushing its 2D queue. + scene.renderer.render_composites_overlay( + &mut gpu, + &mut scene.world, + w as u32, + h as u32, + ); + } + if screenshot.is_some() && frame + 1 == total && w > 0 && h > 0 { + let rgba = successor_platform::read_pixels_rgba(w, h); + match write_bmp(screenshot.unwrap(), &rgba, w as u32, h as u32) { + Ok(()) => println!("screenshot written: {} ({}x{})", screenshot.unwrap(), w, h), + Err(e) => eprintln!("screenshot failed: {e}"), + } + } + successor_platform::end_frame(); + frame += 1; + } + successor_platform::deinit(); +} + +#[cfg(not(target_arch = "wasm32"))] +fn run_fx(frames: u64, screenshot: Option<&str>) { + use successor_engine_core::math::{Mat4, Vec3}; + use successor_engine_render::fx::{glow_sprite, ParticlePool}; + use successor_engine_render::gpu::{ClearSpec, Gpu, PassTarget, RectPx}; + if !successor_platform::init("Successor FX", demo::SCREEN_W as i32, demo::SCREEN_H as i32) { + eprintln!("platform init failed (no display?)"); + std::process::exit(1); + } + let mut gpu = successor_platform::create_gpu(); + let mut renderer = + successor_client::configured_renderer(&mut gpu).expect("renderer initialization failed"); + let sprite = glow_sprite(64); + renderer.set_particle_atlas(&mut gpu, 64, 64, &sprite); + let mut pool = ParticlePool::new(0x51ce_57ed); + let eye = Vec3 { + x: 5.0, + y: 4.0, + z: 5.0, + }; + let center = Vec3 { + x: 0.0, + y: 1.0, + z: 0.0, + }; + // Billboard basis from the camera frame. + let fwd = center.sub(eye).normalize(); + let right = fwd.cross(Vec3::Y).normalize(); + let up = right.cross(fwd); + let total = frames.max(1); + let mut frame = 0u64; + let mut buf: Vec = Vec::with_capacity(64 * 1024); + while !successor_platform::should_quit() && frame < total { + successor_platform::begin_frame(); + let (w, h) = successor_platform::framebuffer_size(); + if w > 0 && h > 0 { + // Clear to a dusk sky + depth. + gpu.begin_pass( + PassTarget::Screen, + RectPx { x: 0, y: 0, w, h }, + ClearSpec { + color: Some([0.06, 0.07, 0.10, 1.0]), + depth: Some(1.0), + }, + ); + gpu.end_pass(); + let aspect = w as f32 / h as f32; + let vp = Mat4::perspective(0.9, aspect, 0.1, 100.0) + .mul(Mat4::look_at(eye, center, Vec3::Y)) + .to_cols_array(); + // Sustained fire: a spark + blood burst every few frames. + if frame.is_multiple_of(6) { + pool.emit_spark_burst([0.0, 1.1, 0.0], [0.0, 1.0, 0.0], [1.0, -0.2, 0.3], 1.6); + pool.emit_blood_burst([0.0, 1.1, 0.0], [1.0, 0.0, 0.3], 1.2); + } + pool.update(1.0 / 60.0); + // Additive layer. + buf.clear(); + let qa = pool.additive.fill_billboards( + [right.x, right.y, right.z], + [up.x, up.y, up.z], + &mut buf, + ); + renderer.render_particles(&mut gpu, &buf, qa, &vp, true, w as u32, h as u32); + // Normal-blend layers (blood + residue). + buf.clear(); + let mut qn = pool.normal.fill_billboards( + [right.x, right.y, right.z], + [up.x, up.y, up.z], + &mut buf, + ); + qn += pool.residue.fill_billboards( + [right.x, right.y, right.z], + [up.x, up.y, up.z], + &mut buf, + ); + renderer.render_particles(&mut gpu, &buf, qn, &vp, false, w as u32, h as u32); + } + if screenshot.is_some() && frame + 1 == total && w > 0 && h > 0 { + let rgba = successor_platform::read_pixels_rgba(w, h); + match write_bmp(screenshot.unwrap(), &rgba, w as u32, h as u32) { + Ok(()) => println!("screenshot written: {} ({}x{})", screenshot.unwrap(), w, h), + Err(e) => eprintln!("screenshot failed: {e}"), + } + } + successor_platform::end_frame(); + frame += 1; + } + successor_platform::deinit(); +} + +#[cfg(not(target_arch = "wasm32"))] +fn run_env(minute: f32, frames: u64, screenshot: Option<&str>) { + use successor_client::world::flora; + use successor_client::GameWorld; + use successor_engine_core::ecs::WorldOps; + use successor_engine_core::math::{vec3, Quat, Vec3}; + use successor_engine_render::components::{ + CamTarget, Camera, DirectionalLight, MeshRenderer, Projection, RectNorm, Transform, + }; + use successor_engine_render::environment; + use successor_engine_render::gpu::ClearSpec; + use successor_engine_render::primitives; + if !successor_platform::init( + "Successor env", + demo::SCREEN_W as i32, + demo::SCREEN_H as i32, + ) { + eprintln!("platform init failed (no display?)"); + std::process::exit(1); + } + let mut gpu = successor_platform::create_gpu(); + let mut renderer = + successor_client::configured_renderer(&mut gpu).expect("renderer initialization failed"); + let mut world = GameWorld::new(); + + let env = environment::sample(minute); + // Grade + fog now run inside the deferred tonemap pass. + renderer.set_grade( + env.bone_tint, + env.desaturate, + env.scene_darken, + env.black_lift, + ); + renderer + .set_bloom(1.0, env.bloom) + .expect("sampled bloom settings are valid"); + renderer.set_fog(env.fog, 180.0, 340.0); + + let (gv, gi) = primitives::plane(200.0); + let ground = renderer.upload_mesh(&mut gpu, &gv, &gi); + let ground_mat = renderer.add_material_desc(successor_engine_render::renderer::MaterialDesc { + base_color: [0.42, 0.36, 0.24, 1.0], + blend: false, + ..successor_engine_render::renderer::MaterialDesc::default() + }); + let g = world.spawn(); + world.set_component( + g, + Transform { + pos: Vec3::ZERO, + rot: Quat::IDENTITY, + scale: Vec3::ONE, + }, + ); + world.set_component( + g, + MeshRenderer { + mesh: ground, + material: ground_mat, + viewport_mask: 0b1, + ..Default::default() + }, + ); + + // Flora / world objects scattered deterministically over the ground, each + // rendered as a small shrub cube (verifies placement + density). + let (cv, ci) = primitives::cube(); + let shrub = renderer.upload_mesh(&mut gpu, &cv, &ci); + let shrub_mat = renderer.add_material_desc(successor_engine_render::renderer::MaterialDesc { + base_color: [0.28, 0.42, 0.20, 1.0], + blend: false, + ..successor_engine_render::renderer::MaterialDesc::default() + }); + let instances = flora::scatter(0x0d3d, [-20.0, -20.0], [20.0, 20.0], 0.5, |_p| false); + for f in instances.iter().take(400) { + let e = world.spawn(); + world.set_component( + e, + Transform { + pos: vec3(f.pos[0], f.scale * 0.5, f.pos[2]), + rot: Quat::from_axis_angle(Vec3::Y, f.yaw), + scale: vec3(f.scale * 0.5, f.scale, f.scale * 0.5), + }, + ); + world.set_component( + e, + MeshRenderer { + mesh: shrub, + material: shrub_mat, + viewport_mask: 0b1, + ..Default::default() + }, + ); + } + + let sun = world.spawn(); + world.set_component( + sun, + DirectionalLight { + dir: vec3(env.sun_dir[0], env.sun_dir[1], env.sun_dir[2]), + color: env.sun_color, + cast_shadows: true, + }, + ); + + let cam = world.spawn(); + world.set_component( + cam, + Camera { + viewport_id: 0, + order: 0, + projection: Projection::Perspective { + fovy: 0.9, + near: 0.1, + far: 400.0, + }, + target: CamTarget::Screen(RectNorm::FULL), + clear: ClearSpec { + color: Some([env.fog[0], env.fog[1], env.fog[2], 1.0]), + depth: Some(1.0), + }, + eye: vec3(24.0, 20.0, 28.0), + look_at: Vec3::ZERO, + up: Vec3::Y, + }, + ); + + let total = frames.max(1); + let mut frame = 0u64; + while !successor_platform::should_quit() && frame < total { + successor_platform::begin_frame(); + let (w, h) = successor_platform::framebuffer_size(); + if w > 0 && h > 0 { + renderer + .render(&mut gpu, &mut world, w as u32, h as u32) + .expect("render failed"); + } + if screenshot.is_some() && frame + 1 == total && w > 0 && h > 0 { + let rgba = successor_platform::read_pixels_rgba(w, h); + match write_bmp(screenshot.unwrap(), &rgba, w as u32, h as u32) { + Ok(()) => println!( + "screenshot written: {} ({}x{}) minute={}", + screenshot.unwrap(), + w, + h, + minute + ), + Err(e) => eprintln!("screenshot failed: {e}"), + } + } + successor_platform::end_frame(); + frame += 1; + } + successor_platform::deinit(); +} + +#[cfg(not(target_arch = "wasm32"))] +fn run_gi(frames: u64, screenshot: Option<&str>, animate_camera: bool, assert_stable_gi: bool) { + use successor_client::GameWorld; + use successor_engine_core::ecs::WorldOps; + use successor_engine_core::math::{vec3, Mat4, Quat, Vec3}; + use successor_engine_render::components::{ + CamTarget, Camera, DirectionalLight, MeshRenderer, Projection, RectNorm, Transform, + }; + use successor_engine_render::gi::GiOccluder; + use successor_engine_render::gpu::ClearSpec; + use successor_engine_render::primitives; + + if !successor_platform::init("Successor GI", demo::SCREEN_W as i32, demo::SCREEN_H as i32) { + eprintln!("platform init failed (no display?)"); + std::process::exit(1); + } + let mut gpu = successor_platform::create_gpu(); + let mut renderer = + successor_client::configured_renderer(&mut gpu).expect("renderer initialization failed"); + renderer.set_ambient(0.12); + renderer.set_fog([0.02, 0.02, 0.03], 400.0, 800.0); // effectively off at this scale + let mut world = GameWorld::new(); + + let (cv, ci) = primitives::cube(); + let unit = renderer.upload_mesh(&mut gpu, &cv, &ci); + + // White ground: a thin scaled cube (outward-wound top face, unlike plane()). + let ground_mat = renderer.add_material_desc(successor_engine_render::renderer::MaterialDesc { + base_color: [1.0, 1.0, 1.0, 1.0], + metallic: 0.0, + roughness: 0.9, + ..successor_engine_render::renderer::MaterialDesc::default() + }); + let g = world.spawn(); + world.set_component( + g, + Transform { + pos: vec3(0.0, -0.1, 6.0), + rot: Quat::IDENTITY, + scale: vec3(120.0, 0.2, 120.0), + }, + ); + world.set_component( + g, + MeshRenderer { + mesh: unit, + material: ground_mat, + viewport_mask: 0b1, + ..Default::default() + }, + ); + + // Tall red wall at z=0 spanning x, front face (+z) toward the camera/floor. + let wall_mat = renderer.add_material_desc(successor_engine_render::renderer::MaterialDesc { + base_color: [0.85, 0.05, 0.05, 1.0], + metallic: 0.0, + roughness: 0.9, + ..successor_engine_render::renderer::MaterialDesc::default() + }); + let wall_c = vec3(0.0, 3.0, 0.0); + let wall_h = vec3(8.0, 3.0, 0.4); + let wall = world.spawn(); + world.set_component( + wall, + Transform { + pos: wall_c, + rot: Quat::IDENTITY, + scale: vec3(wall_h.x * 2.0, wall_h.y * 2.0, wall_h.z * 2.0), + }, + ); + world.set_component( + wall, + MeshRenderer { + mesh: unit, + material: wall_mat, + viewport_mask: 0b1, + ..Default::default() + }, + ); + + // White cube on the visible floor (casts a soft shadow toward the camera). + let cube_mat = renderer.add_material_desc(successor_engine_render::renderer::MaterialDesc { + base_color: [0.95, 0.95, 0.95, 1.0], + metallic: 0.0, + roughness: 0.9, + ..successor_engine_render::renderer::MaterialDesc::default() + }); + let cube_c = vec3(3.0, 1.0, 8.0); + let cube = world.spawn(); + world.set_component( + cube, + Transform { + pos: cube_c, + rot: Quat::IDENTITY, + scale: vec3(2.0, 2.0, 2.0), + }, + ); + world.set_component( + cube, + MeshRenderer { + mesh: unit, + material: cube_mat, + viewport_mask: 0b1, + ..Default::default() + }, + ); + + // Static GI occluder proxies. + renderer.gi_set_ground_albedo([1.0, 1.0, 1.0]); + renderer.gi_set_occluders(&[ + GiOccluder { + center: [wall_c.x, wall_c.y, wall_c.z], + half_extents: [wall_h.x, wall_h.y, wall_h.z], + yaw: 0.0, + albedo: [0.85, 0.05, 0.05], + }, + GiOccluder { + center: [cube_c.x, cube_c.y, cube_c.z], + half_extents: [1.0, 1.0, 1.0], + yaw: 0.0, + albedo: [0.95, 0.95, 0.95], + }, + ]); + + // Sun raking from +z and above onto the wall's front face. + let sun = world.spawn(); + let sd = Vec3 { + x: 0.0, + y: -1.0, + z: -1.0, + } + .normalize(); + world.set_component( + sun, + DirectionalLight { + dir: sd, + color: [1.0, 1.0, 1.0], + cast_shadows: true, + }, + ); + + let eye = vec3(0.0, 12.0, 24.0); + let look = vec3(0.0, 1.0, 6.0); + renderer.gi_set_focus([look.x, look.y, look.z]); + let cam = world.spawn(); + world.set_component( + cam, + Camera { + viewport_id: 0, + order: 0, + projection: Projection::Perspective { + fovy: 0.7, + near: 0.1, + far: 400.0, + }, + target: CamTarget::Screen(RectNorm::FULL), + clear: ClearSpec { + color: Some([0.02, 0.02, 0.03, 1.0]), + depth: Some(1.0), + }, + eye, + look_at: look, + up: Vec3::Y, + }, + ); + + let mut stability_before = None; + let mut scroll_before = None; + let mut stability_frames = 0u64; + let mut stability_samples = Vec::with_capacity(600); + let mut scroll_samples = Vec::with_capacity(256); + let mut diagnostic_complete = !animate_camera; + + let total = if animate_camera { + frames.max(900) + } else { + frames.max(1) + }; + let mut frame = 0u64; + while !successor_platform::should_quit() && frame < total { + if animate_camera && stability_before.is_some() && stability_frames < 600 { + let angle = stability_frames as f32 * core::f32::consts::TAU / 600.0; + if let Some(camera) = world.get_component::(cam) { + camera.eye = eye.add(vec3(angle.sin() * 8.0, 0.0, angle.cos() * 8.0)); + camera.look_at = look.add(vec3(angle.cos() * 8.0, 0.0, angle.sin() * 8.0)); + } + } + let render_started = std::time::Instant::now(); + successor_platform::begin_frame(); + let (w, h) = successor_platform::framebuffer_size(); + if w > 0 && h > 0 { + renderer + .render(&mut gpu, &mut world, w as u32, h as u32) + .expect("render failed"); + } + let render_ms = render_started.elapsed().as_secs_f64() * 1000.0; + if animate_camera { + if stability_before.is_none() && renderer.gi_is_idle() { + stability_before = Some(renderer.gi_work_counters()); + } else if stability_before.is_some() && stability_frames < 600 { + stability_samples.push(render_ms); + stability_frames += 1; + if stability_frames == 600 { + let before = stability_before.expect("GI stability baseline"); + let after = renderer.gi_work_counters(); + let delta = gi_counter_delta(after, before); + let (p50, p99) = percentiles(&mut stability_samples); + println!( + "gi-stability albedo_builds={} radiance_builds={} resident_uploads={} mipmap_rebuilds={} full_rebuilds={} p50_ms={:.3} p99_ms={:.3}", + delta.albedo_builds, + delta.radiance_builds, + delta.resident_uploads, + delta.mipmap_rebuilds, + delta.full_rebuilds, + p50, + p99 + ); + if assert_stable_gi && delta != Default::default() { + eprintln!("camera motion scheduled GI work"); + std::process::exit(1); + } + scroll_before = Some(after); + renderer.gi_set_focus([look.x + 6.0, look.y, look.z]); + } + } else if let Some(before) = scroll_before { + scroll_samples.push(render_ms); + if renderer.gi_is_idle() && !diagnostic_complete { + let delta = gi_counter_delta(renderer.gi_work_counters(), before); + let (p50, p99) = percentiles(&mut scroll_samples); + println!( + "gi-scroll albedo_builds={} radiance_builds={} resident_uploads={} mipmap_rebuilds={} full_rebuilds={} p50_ms={:.3} p99_ms={:.3}", + delta.albedo_builds, + delta.radiance_builds, + delta.resident_uploads, + delta.mipmap_rebuilds, + delta.full_rebuilds, + p50, + p99 + ); + let expected = successor_engine_render::gi::GiWorkCounters { + albedo_builds: 8, + radiance_builds: 8, + resident_uploads: 8, + mipmap_rebuilds: 1, + full_rebuilds: 0, + }; + if assert_stable_gi && delta != expected { + eprintln!("GI scroll work was not bounded: {delta:?}"); + std::process::exit(1); + } + if let Some(camera) = world.get_component::(cam) { + camera.eye = eye; + camera.look_at = look; + } + diagnostic_complete = true; + } + } + } + if frame + 1 == total && w > 0 && h > 0 { + let rgba = successor_platform::read_pixels_rgba(w, h); + let aspect = w as f32 / h as f32; + let view = Mat4::look_at(eye, look, Vec3::Y); + let proj = Mat4::perspective(0.7, aspect, 0.1, 400.0); + let vp = proj.mul(view).to_cols_array(); + let wf = w as f32; + let hf = h as f32; + // Project a world floor point to a pixel (GL bottom-up). + let project = |p: [f32; 3]| -> (i32, i32) { + let cx = vp[0] * p[0] + vp[4] * p[1] + vp[8] * p[2] + vp[12]; + let cy = vp[1] * p[0] + vp[5] * p[1] + vp[9] * p[2] + vp[13]; + let cw = vp[3] * p[0] + vp[7] * p[1] + vp[11] * p[2] + vp[15]; + let ndx = cx / cw; + let ndy = cy / cw; + ( + ((ndx * 0.5 + 0.5) * wf) as i32, + ((ndy * 0.5 + 0.5) * hf) as i32, + ) + }; + let win = 10i32; + let sample = |px: i32, py: i32| -> (f32, f32, f32) { + let (mut r, mut gg, mut b, mut n) = (0.0f32, 0.0f32, 0.0f32, 0.0f32); + for dy in -win..=win { + for dx in -win..=win { + let x = px + dx; + let y = py + dy; + if x < 0 || y < 0 || x >= w || y >= h { + continue; + } + let i = ((y as u32 * w as u32 + x as u32) * 4) as usize; + r += rgba[i] as f32; + gg += rgba[i + 1] as f32; + b += rgba[i + 2] as f32; + n += 1.0; + } + } + if n > 0.0 { + (r / n, gg / n, b / n) + } else { + (0.0, 0.0, 0.0) + } + }; + // Floor probes: near the red wall vs far from it. + let (nx, ny) = project([0.0, 0.02, 1.0]); + let (fx, fy) = project([0.0, 0.02, 14.0]); + let (nr, _ng, nb) = sample(nx, ny); + let (fr, _fg, fb) = sample(fx, fy); + let near_rb = nr / nb.max(1.0); + let far_rb = fr / fb.max(1.0); + println!( + "gi-check quality={:?} near_r/b={:.3} far_r/b={:.3} ratio={:.3} (expect >=1.15 with GI)", + successor_client::render_quality(), near_rb, far_rb, near_rb / far_rb.max(1e-3) + ); + // Shadow probes: behind the cube (shadowed) vs open floor. + let (sx, sy) = project([3.0, 0.02, 6.0]); + let (lx, ly) = project([-4.0, 0.02, 6.0]); + let lum = |c: (f32, f32, f32)| 0.299 * c.0 + 0.587 * c.1 + 0.114 * c.2; + let shadow_lum = lum(sample(sx, sy)); + let lit_lum = lum(sample(lx, ly)); + // Penumbra width: scan the row between shadow and lit probes, count + // pixels in the mid-luminance transition band. + let row = (sy + ly) / 2; + let (x0, x1) = (sx.min(lx), sx.max(lx)); + let mut penumbra = 0i32; + for x in x0..=x1 { + if x < 0 || x >= w || row < 0 || row >= h { + continue; + } + let i = ((row as u32 * w as u32 + x as u32) * 4) as usize; + let l = 0.299 * rgba[i] as f32 + + 0.587 * rgba[i + 1] as f32 + + 0.114 * rgba[i + 2] as f32; + let t = (l - shadow_lum) / (lit_lum - shadow_lum).max(1.0); + if t > 0.2 && t < 0.8 { + penumbra += 1; + } + } + println!( + "shadow-check quality={:?} lit_lum={:.1} shadow_lum={:.1} penumbra_px={}", + successor_client::render_quality(), + lit_lum, + shadow_lum, + penumbra + ); + if let Some(path) = screenshot { + match write_bmp(path, &rgba, w as u32, h as u32) { + Ok(()) => println!("screenshot written: {} ({}x{})", path, w, h), + Err(e) => eprintln!("screenshot failed: {e}"), + } + } + } + successor_platform::end_frame(); + frame += 1; + } + successor_platform::deinit(); +} + +#[cfg(not(target_arch = "wasm32"))] +fn gi_counter_delta( + after: successor_engine_render::gi::GiWorkCounters, + before: successor_engine_render::gi::GiWorkCounters, +) -> successor_engine_render::gi::GiWorkCounters { + successor_engine_render::gi::GiWorkCounters { + albedo_builds: after.albedo_builds - before.albedo_builds, + radiance_builds: after.radiance_builds - before.radiance_builds, + resident_uploads: after.resident_uploads - before.resident_uploads, + mipmap_rebuilds: after.mipmap_rebuilds - before.mipmap_rebuilds, + full_rebuilds: after.full_rebuilds - before.full_rebuilds, + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn percentiles(samples: &mut [f64]) -> (f64, f64) { + if samples.is_empty() { + return (0.0, 0.0); + } + samples.sort_by(f64::total_cmp); + let at = |quantile: f64| { + let index = ((samples.len() - 1) as f64 * quantile).round() as usize; + samples[index] + }; + (at(0.50), at(0.99)) +} + +#[cfg(not(target_arch = "wasm32"))] +fn run_glb_view(glb_path: &str, clip: Option<&str>, frames: u64, screenshot: Option<&str>) { + use successor_client::glb_scene::GlbScene; + use successor_engine_render::gpu::Gpu; + let bytes = match std::fs::read(glb_path) { + Ok(b) => b, + Err(e) => { + eprintln!("failed to read {glb_path}: {e}"); + std::process::exit(1); + } + }; + if !successor_platform::init( + "Successor GLB viewer", + demo::SCREEN_W as i32, + demo::SCREEN_H as i32, + ) { + eprintln!("platform init failed (no display?)"); + std::process::exit(1); + } + let mut gpu = successor_platform::create_gpu(); + let _ = &mut gpu as &mut dyn Gpu; + let mut scene = match GlbScene::build(&mut gpu, &bytes, clip) { + Ok(s) => s, + Err(e) => { + eprintln!("GLB parse failed for {glb_path}: {e:?}"); + successor_platform::deinit(); + std::process::exit(1); + } + }; + let total = frames.max(1); + let mut frame = 0u64; + while !successor_platform::should_quit() && frame < total { + successor_platform::begin_frame(); + scene.animate(frame); + let (w, h) = successor_platform::framebuffer_size(); + if w > 0 && h > 0 { + scene + .renderer + .render(&mut gpu, &mut scene.world, w as u32, h as u32) + .expect("render failed"); + } + if screenshot.is_some() && frame + 1 == total && w > 0 && h > 0 { + let err = successor_platform::gl_error(); + if err != 0 { + eprintln!("GL error before readback: 0x{err:04x}"); + } + let rgba = successor_platform::read_pixels_rgba(w, h); + match write_bmp(screenshot.unwrap(), &rgba, w as u32, h as u32) { + Ok(()) => println!("screenshot written: {} ({}x{})", screenshot.unwrap(), w, h), + Err(e) => eprintln!("screenshot failed: {e}"), + } + } + successor_platform::end_frame(); + frame += 1; + } + successor_platform::deinit(); +} + +#[cfg(not(target_arch = "wasm32"))] +fn run_terrain( + biome: Option<&str>, + frames: u64, + screenshot: Option<&str>, + gpu_stats_json: Option<&str>, + assert_material: bool, + detail_view: bool, +) { + use successor_client::world::chunks::TerrainScene; + use successor_client::world::terrain::Biome; + use successor_engine_render::gpu::Gpu; + let biome = match biome { + Some("forest") => Biome::Forest, + _ => Biome::Desert, + }; + if !successor_platform::init( + "Successor terrain material", + demo::SCREEN_W as i32, + demo::SCREEN_H as i32, + ) { + eprintln!("platform init failed (no display?)"); + std::process::exit(1); + } + let mut gpu = successor_platform::create_gpu(); + let _ = &mut gpu as &mut dyn Gpu; + let mut scene = TerrainScene::build(&mut gpu, biome); + if detail_view { + scene.use_material_detail_view(); + } + assert_eq!( + successor_platform::gl_error(), + 0, + "terrain GPU resource initialization failed" + ); + let total = frames.max(1); + let mut gpu_times = Vec::with_capacity(total.saturating_sub(120) as usize); + let mut final_rgba = None; + for frame in 0..total { + successor_platform::begin_frame(); + scene.animate(frame); + let (width, height) = successor_platform::framebuffer_size(); + let start = std::time::Instant::now(); + if width > 0 && height > 0 { + scene + .renderer + .render(&mut gpu, &mut scene.world, width as u32, height as u32) + .expect("terrain render failed"); + if gpu_stats_json.is_some() { + gpu.finish(); + if frame >= 120 { + gpu_times.push(start.elapsed().as_secs_f64() * 1_000.0); + } + } + assert_eq!( + successor_platform::gl_error(), + 0, + "terrain render produced an OpenGL error" + ); + } + if frame + 1 == total + && width > 0 + && height > 0 + && (screenshot.is_some() || assert_material) + { + final_rgba = Some(( + successor_platform::read_pixels_rgba(width, height), + width as u32, + height as u32, + )); + } + successor_platform::end_frame(); + if successor_platform::should_quit() { + break; + } + } + if let Some((rgba, width, height)) = final_rgba { + if let Some(path) = screenshot { + write_bmp(path, &rgba, width, height).unwrap_or_else(|error| { + eprintln!("screenshot failed: {error}"); + std::process::exit(1); + }); + println!("screenshot written: {path} ({width}x{height})"); + } + if assert_material { + assert_terrain_material_pixels(&rgba, width, height); + } + } + if let Some(path) = gpu_stats_json { + if gpu_times.is_empty() { + eprintln!("terrain GPU stats require more than 120 rendered frames"); + std::process::exit(1); + } + gpu_times.sort_by(f64::total_cmp); + let index = ((gpu_times.len() - 1) as f64 * 0.99).ceil() as usize; + let p99 = gpu_times[index]; + let json = format!( + "{{\"demo\":\"terrain-material\",\"width\":{},\"height\":{},\"warmup_frames\":120,\"measured_frames\":{},\"render_gpu_p99_ms\":{:.6}}}\n", + demo::SCREEN_W, + demo::SCREEN_H, + gpu_times.len(), + p99 + ); + std::fs::write(path, json).unwrap_or_else(|error| { + eprintln!("failed to write terrain GPU stats {path}: {error}"); + std::process::exit(1); + }); + println!("terrain-material render_gpu_p99_ms={p99:.3}"); + } + successor_platform::deinit(); +} + +#[cfg(not(target_arch = "wasm32"))] +fn assert_terrain_material_pixels(rgba: &[u8], width: u32, height: u32) { + let probe = successor_client::world::terrain_material::probe_rgba(rgba, width, height) + .unwrap_or_else(|error| panic!("{error}")); + println!( + "terrain-material luma_mean={:.5} luma_stddev={:.5} neighbor_delta={:.5} repeat_delta={:.5}", + probe.luma_mean, probe.luma_stddev, probe.neighbor_delta, probe.repeat_delta + ); +} + +#[cfg(not(target_arch = "wasm32"))] +fn run_props(frames: u64, screenshot: Option<&str>) { + use successor_client::world::props::WorldScene; + use successor_engine_render::gpu::Gpu; + let assets_dir = "../client-3d/public/assets"; + let mapping = match std::fs::read_to_string("../client-3d/src/render/props-mapping.json") { + Ok(s) => s, + Err(e) => { + eprintln!("read props-mapping: {e}"); + std::process::exit(1); + } + }; + let slice = + match std::fs::read_to_string("../client/public/successor-slice/open-desert-slice.json") { + Ok(s) => s, + Err(e) => { + eprintln!("read slice: {e}"); + std::process::exit(1); + } + }; + if !successor_platform::init( + "Successor world", + demo::SCREEN_W as i32, + demo::SCREEN_H as i32, + ) { + eprintln!("platform init failed (no display?)"); + std::process::exit(1); + } + let mut gpu = successor_platform::create_gpu(); + let _ = &mut gpu as &mut dyn Gpu; + let mut scene = match WorldScene::build(&mut gpu, assets_dir, &mapping, &slice) { + Ok(s) => s, + Err(()) => { + eprintln!("world scene build failed"); + successor_platform::deinit(); + std::process::exit(1); + } + }; + let total = frames.max(1); + let mut frame = 0u64; + while !successor_platform::should_quit() && frame < total { + successor_platform::begin_frame(); + scene.animate(frame); + let (w, h) = successor_platform::framebuffer_size(); + if w > 0 && h > 0 { + scene + .renderer + .render(&mut gpu, &mut scene.world, w as u32, h as u32) + .expect("render failed"); + } + if screenshot.is_some() && frame + 1 == total && w > 0 && h > 0 { + let rgba = successor_platform::read_pixels_rgba(w, h); + match write_bmp(screenshot.unwrap(), &rgba, w as u32, h as u32) { + Ok(()) => println!("screenshot written: {} ({}x{})", screenshot.unwrap(), w, h), + Err(e) => eprintln!("screenshot failed: {e}"), + } + } + successor_platform::end_frame(); + frame += 1; + } + successor_platform::deinit(); +} + +#[cfg(not(target_arch = "wasm32"))] +fn run_pawns( + frames: u64, + screenshot: Option<&str>, + camera_distance: f32, + camera_height: f32, + camera_yaw: f32, + orbit_speed: f32, +) { + use successor_client::pawn::scene::{PawnScene, PawnView}; + use successor_engine_render::gpu::Gpu; + let path = "../client-3d/public/assets/pawn-pack/pawn_male.glb"; + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) => { + eprintln!("read {path}: {e}"); + std::process::exit(1); + } + }; + if !successor_platform::init( + "Successor pawns", + demo::SCREEN_W as i32, + demo::SCREEN_H as i32, + ) { + eprintln!("platform init failed (no display?)"); + std::process::exit(1); + } + let mut gpu = successor_platform::create_gpu(); + let _ = &mut gpu as &mut dyn Gpu; + let mut scene = match PawnScene::build( + &mut gpu, + &bytes, + PawnView { + distance: camera_distance, + height: camera_height, + yaw_radians: camera_yaw, + orbit_speed, + }, + ) { + Ok(s) => s, + Err(()) => { + eprintln!("pawn scene build failed"); + successor_platform::deinit(); + std::process::exit(1); + } + }; + let total = frames.max(1); + let mut frame = 0u64; + while !successor_platform::should_quit() && frame < total { + successor_platform::begin_frame(); + scene.animate(frame); + let (w, h) = successor_platform::framebuffer_size(); + if w > 0 && h > 0 { + scene + .renderer + .render(&mut gpu, &mut scene.world, w as u32, h as u32) + .expect("render failed"); + } + if screenshot.is_some() && frame + 1 == total && w > 0 && h > 0 { + let rgba = successor_platform::read_pixels_rgba(w, h); + match write_bmp(screenshot.unwrap(), &rgba, w as u32, h as u32) { + Ok(()) => println!("screenshot written: {} ({}x{})", screenshot.unwrap(), w, h), + Err(e) => eprintln!("screenshot failed: {e}"), + } + } + successor_platform::end_frame(); + frame += 1; + } + successor_platform::deinit(); +} + +/// Write RGBA8 bottom-up pixels as a 24-bit BMP (BMP is bottom-up, matching GL). +#[cfg(not(target_arch = "wasm32"))] +fn write_bmp(path: &str, rgba: &[u8], w: u32, h: u32) -> std::io::Result<()> { + let row_bytes = (w * 3 + 3) & !3; // padded to 4 bytes + let img_size = row_bytes * h; + let file_size = 54 + img_size; + let mut out: Vec = Vec::with_capacity(file_size as usize); + out.extend_from_slice(b"BM"); + out.extend_from_slice(&file_size.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&54u32.to_le_bytes()); + out.extend_from_slice(&40u32.to_le_bytes()); + out.extend_from_slice(&(w as i32).to_le_bytes()); + out.extend_from_slice(&(h as i32).to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&24u16.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&img_size.to_le_bytes()); + out.extend_from_slice(&2835i32.to_le_bytes()); + out.extend_from_slice(&2835i32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + for y in 0..h { + for x in 0..w { + let i = ((y * w + x) * 4) as usize; + // RGBA -> BGR + out.push(rgba[i + 2]); + out.push(rgba[i + 1]); + out.push(rgba[i]); + } + for _ in 0..(row_bytes - w * 3) { + out.push(0); + } + } + std::fs::write(path, out) +} + +fn arg_value(args: &[String], key: &str) -> Option { + let mut it = args.iter(); + while let Some(a) = it.next() { + if a == key { + return it.next().cloned(); + } + if let Some(v) = a.strip_prefix(key).and_then(|r| r.strip_prefix('=')) { + return Some(v.to_string()); + } + } + None +} +#[cfg(all(not(target_arch = "wasm32"), feature = "dev-tools"))] +fn environment_flag(name: &str) -> bool { + std::env::var(name).ok().is_some_and(|value| { + matches!( + value.to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + +/// Framebuffer for the connected client: `--size WxH`, else the 1280x720 +/// default. Clamped to a sane floor so a typo cannot open a zero-area window. +#[cfg(not(target_arch = "wasm32"))] +fn window_size(args: &[String]) -> (i32, i32) { + const DEFAULT: (i32, i32) = (1280, 720); + let Some(value) = arg_value(args, "--size") else { + return DEFAULT; + }; + let Some((width, height)) = value.split_once(['x', 'X']) else { + eprintln!("--size expects WxH, e.g. 1920x1080"); + std::process::exit(2); + }; + match (width.trim().parse::(), height.trim().parse::()) { + (Ok(w), Ok(h)) if w >= 320 && h >= 240 => (w, h), + _ => { + eprintln!("--size expects WxH with width >= 320 and height >= 240"); + std::process::exit(2); + } + } +} +#[cfg(all(not(target_arch = "wasm32"), feature = "dev-tools"))] +fn configure_automation(args: &[String]) { + let control_requested = + args.iter().any(|arg| arg == "--control") || environment_flag("SUCCESSOR_CONTROL"); + let explicit_port = arg_value(args, "--control-port"); + if args.iter().any(|arg| arg == "--control-port") && explicit_port.is_none() { + eprintln!("--control-port requires a port number"); + std::process::exit(2); + } + let port = if let Some(value) = explicit_port { + Some(value.parse::().unwrap_or_else(|_| { + eprintln!("invalid --control-port: {value}"); + std::process::exit(2); + })) + } else if control_requested { + Some( + std::env::var("SUCCESSOR_CONTROL_PORT") + .ok() + .map(|value| { + value.parse::().unwrap_or_else(|_| { + eprintln!("invalid SUCCESSOR_CONTROL_PORT: {value}"); + std::process::exit(2); + }) + }) + .unwrap_or(successor_platform::DEFAULT_CONTROL_PORT), + ) + } else { + None + }; + let record_path = arg_value(args, "--record-input").map(std::path::PathBuf::from); + let replay_path = arg_value(args, "--replay-input").map(std::path::PathBuf::from); + if port.is_none() && record_path.is_none() && replay_path.is_none() { + return; + } + + let status = successor_platform::configure_control(successor_platform::ControlConfig { + port, + record_path, + replay_path, + }) + .unwrap_or_else(|error| { + eprintln!("control configuration failed: {error}"); + std::process::exit(2); + }); + if let Some(port) = status.listen_port { + println!("successor_control_server=127.0.0.1:{port}"); + } + if status.recording { + println!("successor_input_recording=active"); + } + if status.replaying { + println!("successor_input_replay=active"); + } +} + +/// Live playable slice: connect to a local authority, project actors, send +/// movement, render with the GL backend. Native-only. Requires a display and a +/// running authority; verified by compile/link here and by the headless +/// projection/movement/chat unit tests. Live run command is in PARITY.md. +#[cfg(not(target_arch = "wasm32"))] +mod connected { + use serde_json::json; + use successor_client::game::actions; + use successor_client::game::chat_net::{ChatClient, ChatConnectionState}; + use successor_client::game::command_queue::CommandQueue; + use successor_client::game::connected_scene::{ConnectedScene, CONNECTED_INPUT_KEYS}; + use successor_client::game::movement; + use successor_client_proto::colyseus; + use successor_client_proto::packets::GameServerPacket; + use successor_client_proto::session::{ + Session, SessionEvent, SessionOut, SessionState, WsInput, + }; + use successor_engine_core::input::Key; + use successor_engine_render::gpu::Gpu; + use successor_engine_render::ui::TextField; + use successor_platform as plat; + + use std::path::PathBuf; + use successor_client::net::session::{GameConnection, GameLifecycle, LaunchEnvelope}; + use successor_client::{App, AppMode}; + use successor_platform::{NativePlatform, Platform, SettingsScope}; + + #[cfg(feature = "dev-tools")] + #[allow(clippy::too_many_arguments)] + pub fn run_dev( + endpoint: &str, + player_id: &str, + actor_id: &str, + character_id: Option<&str>, + dev_spawn: Option<(&str, &str, &str, &str)>, + max_frames: Option, + screenshot: Option<&str>, + auto_walk: bool, + assert_zero: bool, + ) -> i32 { + let chat_endpoint = successor_client::net::connect::dev_chat_endpoint(endpoint, player_id); + run_inner( + endpoint, + player_id, + actor_id, + character_id, + dev_spawn, + None, + None, + chat_endpoint, + None, + None, + max_frames, + screenshot, + auto_walk, + assert_zero, + ) + } + + pub fn run_launch( + envelope: &mut LaunchEnvelope, + max_frames: Option, + screenshot: Option<&str>, + auto_walk: bool, + assert_zero: bool, + ) -> i32 { + let game_ticket = match envelope.consume_game_ticket() { + Ok(ticket) => ticket, + Err(error) => { + eprintln!("game ticket rejected: {error:?}"); + return 2; + } + }; + // Keep this distinct capability only until the chat socket's first + // authentication frame; ChatConnection takes it and immediately clears it. + let chat_ticket = match envelope.consume_chat_ticket() { + Ok(ticket) => ticket, + Err(error) => { + eprintln!("chat ticket rejected: {error:?}"); + return 2; + } + }; + let endpoint = envelope.game_endpoint.clone(); + let chat_endpoint = envelope.chat_endpoint.clone(); + let client_release = envelope.client_release.clone(); + let character = envelope.character_id.clone(); + run_inner( + &endpoint, + &character, + &character, + None, + None, + Some(game_ticket), + Some(chat_ticket), + Some(chat_endpoint), + Some(client_release), + envelope.shard.as_deref(), + max_frames, + screenshot, + auto_walk, + assert_zero, + ) + } + #[allow(clippy::too_many_arguments)] + fn run_inner( + endpoint: &str, + player_id: &str, + actor_id: &str, + dev_character_id: Option<&str>, + dev_spawn: Option<(&str, &str, &str, &str)>, + game_ticket: Option, + chat_ticket: Option, + chat_endpoint: Option, + client_release: Option, + expected_shard: Option<&str>, + max_frames: Option, + screenshot: Option<&str>, + auto_walk: bool, + assert_zero: bool, + ) -> i32 { + // Ordinary launch carries a one-use ticket; only explicit dev mode + // reaches this function without one. + let http_endpoint = endpoint + .replacen("wss://", "https://", 1) + .replacen("ws://", "http://", 1); + // A standalone authority validates the ticketed body strictly as + // `{gameTicket, release}`: `characterId` is carried inside the ticket, + // and an extra or missing key is rejected outright. Dev identity keeps + // the unticketed shape, which only a non-standalone shard accepts. + let opts = if let Some(ticket) = game_ticket.as_deref() { + json!({ + "gameTicket": ticket, + "release": client_release.clone().unwrap_or_default(), + }) + } else if let Some(character_id) = dev_character_id { + json!({ "characterId": character_id }) + } else { + let mut opts = json!({ "playerId": player_id, "actorId": actor_id }); + if let (Some(object), Some((area, x, y, facing))) = (opts.as_object_mut(), dev_spawn) { + object.insert("spawnArea".into(), json!(area)); + object.insert("spawnX".into(), json!(x)); + object.insert("spawnY".into(), json!(y)); + object.insert("facing".into(), json!(facing)); + } + opts + }; + let settings_root = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join("Library") + .join("Application Support") + .join("Successor") + .join("rust-client"); + let mut app = App::new(NativePlatform { + asset_root: PathBuf::from(".."), + settings_root, + asset_requests: std::collections::HashMap::new(), + next_asset_handle: 0, + }); + app.mode = AppMode::Loading; + let mut lifecycle = GameLifecycle::default(); + lifecycle.begin_matchmake(); + let (url, body) = match colyseus::build_matchmake_request(&http_endpoint, &opts) { + Ok(v) => v, + Err(e) => { + eprintln!("matchmake request build failed: {e}"); + return 1; + } + }; + let resp = match plat::http_post_json(&url, &body) { + Ok(r) => r, + Err(e) => { + eprintln!("matchmake POST failed: {e}"); + return 1; + } + }; + let seat = match colyseus::parse_seat_reservation(&resp) { + Ok(s) => s, + Err(e) => { + eprintln!("seat reservation parse failed: {e}"); + return 1; + } + }; + let ws_url = colyseus::build_ws_url(endpoint, &seat); + + // 2) Window + GL + the composed connected scene (terrain + props + pawns + // + HUD), driven by the authority store. `--size WxH` opens the + // window at a chosen framebuffer so capture runs are not pinned to + // the 1280x720 default; the HUD reflows from the live viewport. + let (window_w, window_h) = crate::window_size(&std::env::args().collect::>()); + if !plat::init("Successor (Rust client)", window_w, window_h) { + eprintln!("platform init failed (no display?)"); + return 1; + } + let mut gpu = plat::create_gpu(); + let _ = &mut gpu as &mut dyn Gpu; + let theme = + successor_client::persist::load_section(&app.platform, SettingsScope::Local, "theme"); + let toolbar = + successor_client::persist::load_section(&app.platform, SettingsScope::Local, "toolbar"); + let split_snap = successor_client::persist::load_section( + &app.platform, + SettingsScope::Local, + "splitSnap", + ); + let waypoints = successor_client::persist::load_section( + &app.platform, + SettingsScope::Character, + "waypoints", + ); + let macros = successor_client::persist::load_section( + &app.platform, + SettingsScope::Character, + "macros", + ); + let window_layout = successor_client::persist::load_section( + &app.platform, + SettingsScope::Local, + "windowLayout", + ); + let ui_opacity = successor_client::persist::load_section( + &app.platform, + SettingsScope::Local, + "uiOpacity", + ); + let mut read_asset = |stable_id: &str| app.platform.read_asset(stable_id).ok(); + let mut scene = match ConnectedScene::build(&mut gpu, player_id, &mut read_asset) { + Ok(s) => s, + Err(e) => { + eprintln!("connected scene build failed: {e}"); + plat::deinit(); + return 1; + } + }; + scene.load_persisted( + theme.as_ref(), + toolbar.as_ref(), + split_snap.as_ref(), + waypoints.as_ref(), + macros.as_ref(), + window_layout.as_ref(), + ui_opacity.as_ref(), + ); + let audio_mixer = scene.audio_mixer(); + let _audio_output = if assert_zero { + None + } else { + Some(plat::AudioOutput::start( + successor_client::audio::OUT_RATE, + Box::new(move |out| { + audio_mixer + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .mix_into(out); + }), + )) + }; + + // Namespace commands with process time and the authenticated player; + // movement and verbs never use a synthetic command id. + let player_num = player_id + .bytes() + .fold(2166136261u64, |hash, byte| { + (hash ^ byte as u64).wrapping_mul(16777619) + }) + .max(1); + let session_num = plat::now_ms().max(1.0) as u64; + let command_floor = session_num.saturating_mul(1000).max(1); + scene.set_command_queue(CommandQueue::new( + successor_net::SessionId(session_num), + successor_net::PlayerId(player_num as u32), + command_floor, + )); + // 3) Connect + drive. + let mut ws = match plat::ws_connect(&ws_url) { + Ok(w) => w, + Err(e) => { + eprintln!("ws connect failed: {e}"); + plat::deinit(); + return 1; + } + }; + let mut sess = Session::new(); + sess.start_connecting(); + let mut buf: Vec = Vec::with_capacity(64 * 1024); + let mut chat_ticket = chat_ticket; + let mut chat_client = + ChatClient::with_endpoint(128, chat_endpoint.clone().unwrap_or_default()); + chat_client.connection.begin(); + let mut chat_ws = chat_endpoint + .as_deref() + .and_then(|url| match plat::ws_connect(url) { + Ok(socket) => Some(socket), + Err(error) => { + chat_client.connection.failed(&error); + None + } + }); + let mut movement_controller = movement::MovementController::default(); + let mut chat_buf = Vec::with_capacity(64 * 1024); + let mut chat_input = TextField::new(320); + let mut chat_enter_was_down = false; + let mut chat_backspace_was_down = false; + let mut chat_escape_was_down = false; + let mut view_sent = false; + let mut frame: u64 = 0; + #[cfg(feature = "alloc-count")] + let mut connected_frame_allocs = 0u64; + #[cfg(feature = "alloc-count")] + let mut connected_actor_count = 0usize; + #[cfg(feature = "alloc-count")] + let mut connected_stable_frames = 0u64; + #[cfg(feature = "alloc-count")] + let mut connected_alloc_frame = None; + + let mut last_frame_ms = plat::now_ms(); + while !plat::should_quit() && max_frames.is_none_or(|m| frame < m) { + let frame_now_ms = plat::now_ms(); + let frame_delta_seconds = + ((frame_now_ms - last_frame_ms).clamp(0.0, 100.0) / 1_000.0) as f32; + last_frame_ms = frame_now_ms; + let movement_now_ms = frame_now_ms.max(0.0) as u64; + plat::begin_frame(); + // Chat is deliberately independent: loss degrades only chat while + // the authoritative game scene and movement continue rendering. + if let Some(socket) = chat_ws.as_mut() { + chat_buf.clear(); + for _ in 0..64 { + match plat::ws_poll(socket, &mut chat_buf) { + plat::WsEvent::Frame(n) => { + if let Some(message) = + chat_client.on_incoming(&String::from_utf8_lossy(&chat_buf[..n])) + { + scene.ingest_chat_message(&message); + } + } + plat::WsEvent::None => break, + plat::WsEvent::Open => { + if let Some(frame) = chat_client.connection.authenticate( + &mut chat_ticket, + client_release.as_deref().unwrap_or_default(), + ) { + plat::ws_send(socket, frame.as_bytes()); + } + } + plat::WsEvent::Closed | plat::WsEvent::Error => { + chat_client.connection.lost(); + break; + } + } + } + } + + // Drain socket → session; feed packets into the scene + combat FX. + // Bound socket work so a continuously streaming authority cannot + // starve input, rendering, status probes, or screenshot acks. + for _ in 0..64 { + buf.clear(); + let ev = plat::ws_poll(&mut ws, &mut buf); + let (outs, brk) = match ev { + plat::WsEvent::Open => (sess.on_ws_event(WsInput::Open), false), + plat::WsEvent::Frame(n) => (sess.on_ws_event(WsInput::Frame(&buf[..n])), false), + plat::WsEvent::Closed => (sess.on_ws_event(WsInput::Closed), true), + plat::WsEvent::Error => (sess.on_ws_event(WsInput::Error("ws error")), true), + plat::WsEvent::None => break, + }; + for out in outs { + match out { + SessionOut::SendFrame(f) => plat::ws_send(&mut ws, &f), + SessionOut::Emit(SessionEvent::Hello(hello)) => { + lifecycle.authenticated(); + if let Err(error) = + lifecycle.validate_hello(&hello, None, expected_shard) + { + app.fail(format!("game hello rejected: {error:?}")); + eprintln!("game hello rejected: {error:?}"); + return 2; + } + if lifecycle.state != GameConnection::Connected { + app.fail("game lifecycle did not reach Connected"); + return 2; + } + app.mode = AppMode::Connected; + scene.set_loading(false); + scene.on_snapshot(&hello.snapshot); + } + SessionOut::Emit(SessionEvent::Packet(packet)) => { + if let GameServerPacket::Error { code, message } = &packet { + eprintln!("game.error {code}: {message}"); + } + scene.apply_server_packet(packet); + } + SessionOut::Emit(SessionEvent::Room { msg_type, payload }) => { + scene.apply_room_message(&msg_type, &payload); + } + SessionOut::Emit(SessionEvent::Error(m)) => eprintln!("session error: {m}"), + SessionOut::Emit(SessionEvent::Closed) => eprintln!("session closed"), + SessionOut::Emit(SessionEvent::ReconnectAttempt { + attempt, + max_attempts, + }) => { + eprintln!("reconnect {attempt}/{max_attempts}"); + } + } + } + if brk { + if lifecycle.socket_lost().is_none() + && lifecycle.state == GameConnection::Exhausted + { + app.fail("game reconnect exhausted"); + } + break; + } + } + + // Declare AOI view interest once ready so deltas stream. + if !view_sent && sess.state() == SessionState::Ready { + let view = json!({ "viewport_width_cells": 96, "viewport_height_cells": 96, "margin_cells": 32 }); + if let Ok(SessionOut::SendFrame(f)) = sess.send_view(&view) { + plat::ws_send(&mut ws, &f); + } + view_sent = true; + } + while let Some(c) = plat::poll_text_input() { + if chat_input.focused { + chat_input.insert(c); + } + } + let enter_down = plat::is_key_down(Key::Enter); + let backspace_down = plat::is_key_down(Key::Backspace); + let escape_down = plat::is_key_down(Key::Escape); + let enter_pressed = enter_down && !chat_enter_was_down; + let backspace_pressed = backspace_down && !chat_backspace_was_down; + let escape_pressed = escape_down && !chat_escape_was_down; + chat_enter_was_down = enter_down; + chat_backspace_was_down = backspace_down; + chat_escape_was_down = escape_down; + let chat_consumed_escape = escape_pressed && chat_input.focused; + if chat_consumed_escape { + chat_input.focused = false; + } else if enter_pressed { + if chat_input.focused && !chat_input.text.trim().is_empty() { + let line = chat_input.text.clone(); + let command = chat_client.submit_input(&line); + if let Some(payload) = chat_client.command_frame(command) { + if let Some(socket) = chat_ws.as_mut() { + plat::ws_send(socket, payload.as_bytes()); + chat_input.clear(); + } + } + } else { + chat_input.focused = true; + } + } else if backspace_pressed && chat_input.focused { + chat_input.backspace(); + } + + scene.handle_tuning_toggle(plat::is_key_down(Key::Backquote)); + + // Connected input is translated into gameplay actions, then queued + // with fresh ids. UI/window keys are consumed by the scene locally. + scene.set_move_intent(0, 0, false); + if sess.state() == SessionState::Ready { + let window_focused = plat::window_focused(); + let modal_input = scene.tuning_open() || chat_input.focused; + let intent = if !window_focused || modal_input { + (0, 0, false) + } else if auto_walk { + (0, -1, false) + } else { + let (manual_dx, manual_dy, held_sprint) = + movement::intent_from_keys(plat::is_key_down); + let (dx, dy) = scene.navigation_intent(manual_dx, manual_dy); + (dx, dy, held_sprint || scene.sprint_toggled()) + }; + let actor_dead = scene + .player_actor() + .is_some_and(|actor| actor.life_state != "alive"); + let predicted_intent = if actor_dead || !window_focused || modal_input { + (0, 0, false) + } else { + intent + }; + scene.set_move_intent(predicted_intent.0, predicted_intent.1, predicted_intent.2); + let movement_update = if !window_focused { + movement_controller.release(movement_now_ms, movement::StopReason::FocusLost) + } else if modal_input { + movement_controller.release(movement_now_ms, movement::StopReason::ModalInput) + } else if actor_dead { + movement_controller.release(movement_now_ms, movement::StopReason::Dead) + } else { + movement_controller.update( + movement::IntentState { + dx: intent.0, + dy: intent.1, + sprint: intent.2, + }, + movement_now_ms, + ) + }; + if let Some(intent) = movement_update { + let _ = scene.dispatch_gameplay_action(actions::GameplayAction::Move { + dx: intent.dx, + dy: intent.dy, + facing: movement::facing_from_intent(intent.dx, intent.dy), + sprint: intent.sprint, + }); + } + if chat_consumed_escape || chat_input.focused { + // Chat owns Escape while editing; keep the scene edge reset + // so releasing the key cannot close a window afterward. + let _ = scene.handle_key(Key::Escape, false, false); + } else { + let _ = scene.handle_key(Key::Escape, escape_pressed, false); + } + let shift_down = plat::is_key_down(Key::LeftShift); + if !chat_input.focused && !scene.tuning_open() { + for key in CONNECTED_INPUT_KEYS { + if key == Key::Escape { + continue; + } + if let Some(action) = + scene.handle_key(key, plat::is_key_down(key), shift_down) + { + let _ = scene.dispatch_gameplay_action(action); + } + } + } else { + let _ = scene.handle_key(Key::C, false, false); + } + let (mx, my) = plat::mouse_position(); + if let Some(action) = scene.handle_pointer( + mx, + my, + plat::mouse_button_down(0), + plat::mouse_button_down(1), + scene.pointer_captured(), + ) { + let _ = scene.dispatch_gameplay_action(action); + } + if let Some((_, scroll_y)) = plat::poll_scroll_delta() { + scene.handle_scroll(scroll_y); + } + while let Some(env) = scene.take_next_command() { + if let Ok(SessionOut::SendFrame(f)) = sess.send_command(&env) { + plat::ws_send(&mut ws, &f); + } + } + } else if let Some(intent) = + movement_controller.release(movement_now_ms, movement::StopReason::Disconnected) + { + let _ = scene.dispatch_gameplay_action(actions::GameplayAction::Move { + dx: intent.dx, + dy: intent.dy, + facing: movement::facing_from_intent(intent.dx, intent.dy), + sprint: intent.sprint, + }); + } + let _ = plat::is_key_down(Key::Escape); + + let (w, h) = plat::framebuffer_size(); + if w > 0 + && h > 0 + && chat_client.connection.state == ChatConnectionState::Online + && frame.is_multiple_of(300) + { + if let Some(socket) = chat_ws.as_mut() { + let ping = chat_client.ping(); + plat::ws_send(socket, ping.as_bytes()); + } + } + scene.set_movement_timing(movement_controller.timing(), movement_now_ms); + let actor = scene.player_actor(); + let movement_diagnostics = scene.movement_diagnostics(); + let movement_timing = movement_controller.timing(); + let status = plat::ControlStatusV2 { + frame, + framebuffer: (w > 0 && h > 0).then_some((w as u32, h as u32)), + app_mode: Some(format!("{:?}", app.mode)), + game_connection: format!("{:?}", lifecycle.state), + chat_connection: match chat_client.connection.state { + ChatConnectionState::Online => "connected", + ChatConnectionState::Connecting + | ChatConnectionState::Authenticating + | ChatConnectionState::SyncingHistory + | ChatConnectionState::Reconnecting => "reconnecting", + ChatConnectionState::Offline + | ChatConnectionState::Degraded + | ChatConnectionState::Exhausted => "degraded", + } + .into(), + shard: scene.shard_id().map(str::to_owned), + tick: Some(scene.store.tick), + area: scene.area_id().map(str::to_owned), + source_hashes: scene.store.source_state_hash.iter().cloned().collect(), + player_actor_id: (!scene.store.player_actor_id.is_empty()) + .then(|| scene.store.player_actor_id.clone()), + player_position: actor.map(|a| (a.x, a.y)), + life: actor.map(|a| a.life_state.clone()), + selection: scene.selected_actor_id().map(str::to_owned), + windows: scene.open_window_ids(), + focused_window: scene.focused_window_id(), + pending_command_kinds: scene.pending_command_kinds(), + last_receipt: scene.store.last_receipt.as_ref().map(|r| { + format!( + "{}:{}", + if r.accepted { "accepted" } else { "rejected" }, + r.reason_code.as_deref().unwrap_or("ok") + ) + }), + renderer_degradation_ids: Vec::new(), + window_frames: scene.window_frames(), + movement: movement_diagnostics.map(|movement| plat::ControlMovementStatus { + authoritative: movement.authoritative, + predicted: movement.predicted, + rendered: movement.rendered, + correction_cells: movement.correction_cells, + intent: movement.intent, + applied_command_id: movement.applied_command_id, + blocker_count: movement.blocker_count, + presented_ground_y: movement.presented_ground_y, + sampled_ground_y: movement.sampled_ground_y, + frame_dt_ms: movement.frame_dt_ms, + last_change_ms: movement_timing.last_change_ms, + last_send_ms: movement_timing.last_send_ms, + next_send_ms: movement_timing.next_send_ms, + sampled_at_ms: movement_now_ms, + stop_retry_until_ms: movement_timing.stop_retry_until_ms, + }), + }; + for intent in plat::take_ui_intents() { + if !scene.apply_control_ui_intent(&intent) { + eprintln!("control ui intent ignored: {intent:?}"); + } + } + plat::publish_control_status(status); + #[cfg(feature = "alloc-count")] + { + successor_engine_core::rt::alloc::reset_alloc_count(); + #[cfg(not(target_arch = "wasm32"))] + if connected_stable_frames >= 240 { + successor_client::alloc_trace::reset_histogram(); + successor_client::alloc_trace::arm_trace(); + } + } + if w > 0 && h > 0 { + #[cfg(all(feature = "alloc-count", not(target_arch = "wasm32")))] + successor_client::alloc_trace::scene_active(true); + scene.frame( + &mut gpu, + w as u32, + h as u32, + frame_delta_seconds, + &mut app.platform, + &mut chat_client, + &mut chat_input, + ); + #[cfg(all(feature = "alloc-count", not(target_arch = "wasm32")))] + successor_client::alloc_trace::scene_active(false); + #[cfg(feature = "alloc-count")] + { + let actor_count = scene.actor_count(); + if actor_count != connected_actor_count { + connected_actor_count = actor_count; + connected_stable_frames = 0; + connected_frame_allocs = 0; + } else { + connected_stable_frames = connected_stable_frames.saturating_add(1); + if connected_stable_frames > 240 { + let allocations = successor_engine_core::rt::alloc::alloc_count(); + connected_frame_allocs = connected_frame_allocs.max(allocations); + if allocations != 0 { + connected_alloc_frame.get_or_insert(frame); + #[cfg(not(target_arch = "wasm32"))] + successor_client::alloc_trace::absorb_frame(frame); + } + } + } + } + } + if let Some(report) = scene.take_bug_report() { + if let Ok(SessionOut::SendFrame(frame)) = + sess.send_message("support.bug-report", &report) + { + plat::ws_send(&mut ws, &frame); + } + } + let (theme, toolbar, split_snap, waypoints, macros, window_layout, ui_opacity) = + scene.take_persisted(); + for (scope, key, value) in [ + (SettingsScope::Local, "theme", theme), + (SettingsScope::Local, "toolbar", toolbar), + (SettingsScope::Local, "splitSnap", split_snap), + (SettingsScope::Character, "waypoints", waypoints), + (SettingsScope::Character, "macros", macros), + (SettingsScope::Local, "windowLayout", window_layout), + (SettingsScope::Local, "uiOpacity", ui_opacity), + ] { + if let Some(value) = value { + if let Err(error) = successor_client::persist::store_section( + &mut app.platform, + scope, + key, + value, + ) { + eprintln!("settings save failed for {key}: {error}"); + } + } + } + if let (Some(path), true) = (screenshot, max_frames.is_some_and(|m| frame + 1 == m)) { + if w > 0 && h > 0 { + let rgba = plat::read_pixels_rgba(w, h); + match crate::write_bmp(path, &rgba, w as u32, h as u32) { + Ok(()) => println!("screenshot written: {} ({}x{})", path, w, h), + Err(e) => eprintln!("screenshot failed: {e}"), + } + } + } + plat::end_frame(); + frame += 1; + } + + if sess.state() != SessionState::Ready || lifecycle.state != GameConnection::Connected { + eprintln!( + "connected run failed: session={:?} lifecycle={:?}", + sess.state(), + lifecycle.state + ); + plat::deinit(); + return 1; + } + + let p = scene.player_pos(); + println!( + "connected summary: actors={} player_pos=({:.2},{:.2},{:.2}) session_state={:?}", + scene.actor_count(), + p.x, + p.y, + p.z, + sess.state() + ); + if let Some(intent) = movement_controller.release( + plat::now_ms().max(0.0) as u64, + movement::StopReason::ControlReleased, + ) { + let _ = scene.dispatch_gameplay_action(actions::GameplayAction::Move { + dx: intent.dx, + dy: intent.dy, + facing: movement::facing_from_intent(intent.dx, intent.dy), + sprint: intent.sprint, + }); + while let Some(env) = scene.take_next_command() { + if let Ok(SessionOut::SendFrame(f)) = sess.send_command(&env) { + plat::ws_send(&mut ws, &f); + } + } + } + lifecycle.intentional_exit(); + app.mode = AppMode::Entry; + if let Ok(SessionOut::SendFrame(f)) = sess.exit_world() { + plat::ws_send(&mut ws, &f); + } + if assert_zero { + #[cfg(feature = "alloc-count")] + { + if connected_stable_frames < 240 { + eprintln!( + "CONNECTED ALLOC GATE FAIL: only {connected_stable_frames} stable frames" + ); + plat::deinit(); + return 1; + } + println!( + "connected-frame-allocs {connected_frame_allocs} first-frame {:?}", + connected_alloc_frame + ); + #[cfg(not(target_arch = "wasm32"))] + successor_client::alloc_trace::dump_alloc_trace(24); + if connected_frame_allocs != 0 { + eprintln!( + "CONNECTED ALLOC GATE FAIL: {connected_frame_allocs} steady-state allocations" + ); + plat::deinit(); + return 1; + } + } + #[cfg(not(feature = "alloc-count"))] + { + eprintln!("connected allocation probe requires alloc-count"); + plat::deinit(); + return 2; + } + } + plat::deinit(); + 0 + } +} + +#[cfg(feature = "dev-tools")] +fn run_model_corpus() { + use std::io::Read; + + let mut input = Vec::new(); + if let Err(error) = std::io::stdin().read_to_end(&mut input) { + eprintln!("failed to read model list: {error}"); + std::process::exit(1); + } + let mut models = 0usize; + let mut primitives = 0usize; + let mut materials = 0usize; + let mut images = 0usize; + let mut explicit_lights = 0usize; + let mut explicit_light_node_refs = 0usize; + let mut emissive_candidates = 0usize; + let mut inferred_lights = 0usize; + let mut retained_lights = 0usize; + let mut suppressed_low_flux = 0usize; + let mut suppressed_duplicates = 0usize; + let mut light_errors = 0usize; + let mut over_budget_lights = 0usize; + let mut decode_errors = 0usize; + let mut transform_errors = 0usize; + let mut unsupported = 0usize; + let mut skipped = 0usize; + for path_bytes in input + .split(|byte| *byte == 0) + .filter(|path| !path.is_empty()) + { + let Ok(path) = std::str::from_utf8(path_bytes) else { + skipped += 1; + continue; + }; + if !path.ends_with(".glb") { + eprintln!("unsupported tracked model: {path}"); + unsupported += 1; + continue; + } + let full_path = std::path::Path::new("..").join(path); + let bytes = match std::fs::read(&full_path) { + Ok(bytes) => bytes, + Err(error) => { + eprintln!("failed to read {}: {error}", full_path.display()); + skipped += 1; + continue; + } + }; + let document = match successor_engine_core::glb::parse(&bytes) { + Ok(document) => document, + Err(error) => { + eprintln!("failed to parse {path}: {error:?}"); + unsupported += 1; + continue; + } + }; + models += 1; + let model_primitives = document + .meshes + .iter() + .map(|mesh| mesh.primitives.len()) + .sum::(); + primitives += model_primitives; + materials += document.materials.len(); + images += document.images.len(); + explicit_lights += document.lights.len(); + explicit_light_node_refs += document + .nodes + .iter() + .filter(|node| node.light.is_some()) + .count(); + for light in &document.lights { + if light + .color + .iter() + .any(|value| !value.is_finite() || *value < 0.0) + || !light.intensity.is_finite() + || light.intensity < 0.0 + || light + .range + .is_some_and(|range| !range.is_finite() || range <= 0.0) + { + light_errors += 1; + } + } + let mut image_means = Vec::with_capacity(document.images.len()); + for image in &document.images { + match successor_engine_core::image::decode_image(&image.mime_type, &image.bytes) { + Ok(decoded) => image_means.push(Some( + successor_engine_render::model::emissive_texture_mean_linear(&decoded.pixels), + )), + Err(_) => { + eprintln!("failed to decode embedded image in {path}"); + decode_errors += 1; + image_means.push(None); + } + } + } + let means = document + .materials + .iter() + .map(|material| { + material + .emissive_texture + .and_then(|reference| document.textures.get(reference.texture)) + .and_then(|texture| image_means.get(texture.source)) + .copied() + .flatten() + }) + .collect::>(); + let report = successor_client::assets::model::light_extraction_report(&document, &means); + emissive_candidates += report.candidates; + retained_lights += report.retained; + inferred_lights += report.retained.saturating_sub(report.explicit); + suppressed_low_flux += report.suppressed_low_flux; + suppressed_duplicates += report.suppressed_duplicate; + light_errors += report.errors; + over_budget_lights += report.over_budget; + let rest_pose: Vec = document + .nodes + .iter() + .map(|node| successor_engine_core::anim::JointTransform { + t: node.translation, + r: node.rotation, + s: node.scale, + }) + .collect(); + if rest_pose + .iter() + .any(|transform| transform.matrix().m.iter().any(|value| !value.is_finite())) + { + transform_errors += 1; + } + for animation in &document.animations { + if animation.samplers.iter().any(|sampler| { + sampler + .input + .iter() + .chain(sampler.output.iter()) + .any(|value| !value.is_finite()) + }) { + transform_errors += 1; + continue; + } + for time in [0.0, animation.duration * 0.5, animation.duration] { + let mut pose = rest_pose.clone(); + successor_engine_core::anim::apply_animation(animation, time, &mut pose); + if pose + .iter() + .any(|transform| transform.matrix().m.iter().any(|value| !value.is_finite())) + { + transform_errors += 1; + } + for skin_index in 0..document.skins.len() { + let Some(mut skeleton) = + successor_engine_core::anim::Skeleton::from_document(&document, skin_index) + else { + transform_errors += 1; + continue; + }; + let mut palette = Vec::with_capacity(skeleton.joint_count()); + skeleton.compute_palette(&pose, &mut palette); + if palette.iter().flatten().any(|value| !value.is_finite()) { + transform_errors += 1; + } + } + } + } + if document.skins.iter().any(|skin| skin.joints.len() > 64) { + unsupported += 1; + } + } + println!( + "{{\"models\":{models},\"primitives\":{primitives},\"materials\":{materials},\"images\":{images},\"explicit_light_definitions\":{explicit_lights},\"explicit_light_references\":{explicit_light_node_refs},\"emissive_candidates\":{emissive_candidates},\"retained_lights\":{retained_lights},\"inferred_lights\":{inferred_lights},\"suppressed_low_flux\":{suppressed_low_flux},\"suppressed_duplicates\":{suppressed_duplicates},\"light_errors\":{light_errors},\"over_budget_lights\":{over_budget_lights},\"unsupported\":{unsupported},\"decode_errors\":{decode_errors},\"transform_errors\":{transform_errors},\"skipped\":{skipped}}}" + ); + if unsupported + decode_errors + transform_errors + skipped + light_errors + over_budget_lights + != 0 + { + std::process::exit(1); + } +} + +/// Pregame parity host — renders the entry → roster → creation flow from +/// `screens.rs` against a local demo roster so the surfaces can be exercised +/// and photographed without a server. +/// +/// **Auth invariant:** this is a `--demo` mode, so it is already behind the +/// `dev-tools` gate in `main`. It never opens a socket and never constructs a +/// `LaunchEnvelope`; the `Connect` intent is printed and answered with a local +/// simulated link, and `SelectCharacter` / `CreateCharacter` are printed and +/// acknowledged on-screen. Ordinary native launch still requires +/// `--launch-context` and the production ticket path, untouched by this code. +#[cfg(not(target_arch = "wasm32"))] +fn run_pregame(frames: u64, screenshot: Option<&str>, stage: Option<&str>) { + use successor_client::hud; + use successor_client::screens::{ + CharacterScreen, CharacterStage, EntryScreen, LoadingScreen, RosterEntry, ScreenAction, + }; + use successor_engine_core::input::Key; + use successor_engine_render::gpu::{ClearSpec, Gpu, PassTarget, RectPx}; + use successor_platform as plat; + + /// Which surface the host is showing. The demo owns this transition table; + /// each screen owns its own internal stages. + enum Phase { + Entry(EntryScreen), + Character(Box), + Loading(LoadingScreen), + } + + // Demo rows are presentation-only: they carry no host stable id, which is + // exactly what `selected_stable_id()` reports as absent. + fn demo_roster() -> Vec { + vec![ + RosterEntry { + name: "Sarath Halvex".into(), + lineage: "TERRAN".into(), + vocation: "MARKSMAN".into(), + location: "OPEN-DESERT".into(), + played: "31H 12M".into(), + ..Default::default() + }, + RosterEntry { + name: "Velira Okoro".into(), + lineage: "IRIDIAN".into(), + vocation: "SCOUT".into(), + location: "SALT FLATS".into(), + played: "8H 40M".into(), + ..Default::default() + }, + RosterEntry { + name: "Tordan Moss".into(), + lineage: "VOSKAN".into(), + vocation: "TECHNICIAN".into(), + location: "TIDEWORKS".into(), + played: "112H 05M".into(), + ..Default::default() + }, + ] + } + + let stage = stage.unwrap_or("entry"); + let known = [ + "entry", + "connecting", + "roster", + "roster-empty", + "create-profile", + "create-summary", + "loading", + ]; + if !known.contains(&stage) { + eprintln!( + "--demo pregame --stage must be one of: {}", + known.join(", ") + ); + std::process::exit(2); + } + + // Seed the requested stage directly so a pointer-less screenshot run can + // photograph any surface in the flow. + let mut phase = match stage { + "connecting" => { + let mut entry = EntryScreen::new(); + entry.begin_connecting(); + Phase::Entry(entry) + } + "entry" => Phase::Entry(EntryScreen::new()), + "loading" => { + let mut screen = LoadingScreen::default(); + screen.set_indeterminate(true); + Phase::Loading(screen) + } + other => { + let roster = if other == "roster-empty" { + Vec::new() + } else { + demo_roster() + }; + let mut screen = CharacterScreen::with_entries(roster); + screen.set_stage(match other { + "create-profile" => CharacterStage::CreateProfile, + "create-summary" => CharacterStage::CreateIdentity, + _ => CharacterStage::Roster, + }); + if other == "create-summary" { + screen.draft.lineage = 2; + screen.draft.vocation = 1; + screen.draft.build = 0.62; + screen.draft.generate(); + } + Phase::Character(Box::new(screen)) + } + }; + + if !plat::init( + "Successor Pregame", + demo::SCREEN_W as i32, + demo::SCREEN_H as i32, + ) { + eprintln!("platform init failed (no display?)"); + std::process::exit(1); + } + let mut gpu = plat::create_gpu(); + let mut renderer = + successor_client::configured_renderer(&mut gpu).expect("renderer initialization failed"); + let icons = hud::Icons::load(); + renderer.set_ui_atlas(&mut gpu, icons.meta.width, icons.meta.height, &icons.rgba); + let mut ui = icons.ui_builder(); + + println!( + "pregame demo: stage={stage} (presentation only — no network, no launch context)\n \ + mouse: click to interact · TAB / SHIFT+TAB: field focus · UP / DOWN: roster\n \ + ENTER: confirm · ESC: back one step · typing edits the focused field" + ); + + // Simulated link: the demo answers its own Connect intent after a beat so + // the connecting surface is observable, then hands over a local roster. + let mut link_deadline: Option = None; + let mut tab_was = false; + let (mut enter_was, mut back_was, mut esc_was) = (false, false, false); + let (mut up_was, mut down_was) = (false, false); + + let total = frames.max(1); + let mut frame = 0u64; + let mut quit = false; + while !plat::should_quit() && frame < total && !quit { + plat::begin_frame(); + let now = plat::now_ms().max(0.0); + let (w, h) = plat::framebuffer_size(); + if w <= 0 || h <= 0 { + plat::end_frame(); + frame += 1; + continue; + } + + // A screenshot run has no pointer; park it off-surface so nothing + // photographs in a hover state. + let (mx, my) = if screenshot.is_some() { + (-1.0, -1.0) + } else { + plat::mouse_position() + }; + ui.set_input(mx, my, screenshot.is_none() && plat::mouse_button_down(0)); + + let shift = plat::is_key_down(Key::LeftShift); + let tab = plat::is_key_down(Key::Tab); + let tab_edge = tab && !tab_was; + tab_was = tab; + let enter = plat::is_key_down(Key::Enter); + let enter_edge = enter && !enter_was; + enter_was = enter; + let backspace = plat::is_key_down(Key::Backspace); + let backspace_edge = backspace && !back_was; + back_was = backspace; + let escape = plat::is_key_down(Key::Escape); + let escape_edge = escape && !esc_was; + esc_was = escape; + let up = plat::is_key_down(Key::Up); + let up_edge = up && !up_was; + up_was = up; + let down = plat::is_key_down(Key::Down); + let down_edge = down && !down_was; + down_was = down; + + let fw = w as f32; + let fh = h as f32; + let dt = 1.0 / 60.0; + + // Keyboard + text routing, then draw, then act on the emitted intent. + let mut action = None; + match &mut phase { + Phase::Entry(entry) => { + entry.tick(dt); + while let Some(c) = plat::poll_text_input() { + entry.input_char(c); + } + if tab_edge { + if shift { + entry.focus_prev(); + } else { + entry.focus_next(); + } + } + if backspace_edge { + entry.backspace(); + } + if escape_edge { + entry.reset(); + link_deadline = None; + } + + ui.begin(w as u32, h as u32); + action = entry.draw(&mut ui, fw, fh); + if enter_edge && action.is_none() { + action = Some(ScreenAction::Connect(entry.join_options())); + entry.begin_connecting(); + } + } + Phase::Character(screen) => { + screen.tick(dt); + while let Some(c) = plat::poll_text_input() { + screen.input_char(c); + } + if tab_edge { + if shift { + screen.focus_prev(); + } else { + screen.focus_next(); + } + } + if backspace_edge { + screen.backspace(); + } + if up_edge { + screen.move_selection(-1, fw, fh); + } + if down_edge { + screen.move_selection(1, fw, fh); + } + if escape_edge { + match screen.stage() { + CharacterStage::CreateIdentity => { + screen.set_stage(CharacterStage::CreateProfile) + } + CharacterStage::CreateProfile => screen.set_stage(CharacterStage::Roster), + CharacterStage::Roster => action = Some(ScreenAction::Back), + } + } + + ui.begin(w as u32, h as u32); + let drawn = screen.draw(&mut ui, fw, fh); + if drawn.is_some() { + action = drawn; + } + } + Phase::Loading(screen) => { + screen.tick(dt); + if escape_edge { + action = Some(ScreenAction::Back); + } + ui.begin(w as u32, h as u32); + let drawn = screen.draw(&mut ui, fw, fh); + if drawn.is_some() { + action = drawn; + } + } + } + + match action { + Some(ScreenAction::Connect(opts)) => { + println!( + "pregame: CONNECT intent endpoint={} player={} (demo simulates the link)", + opts.endpoint, opts.player_id + ); + link_deadline = Some(now + 1400.0); + } + Some(ScreenAction::CancelConnect) => { + println!("pregame: link attempt cancelled"); + link_deadline = None; + } + Some(ScreenAction::SelectCharacter(index)) => { + println!("pregame: SELECT character #{index} (no authority to route to)"); + if let Phase::Character(screen) = &mut phase { + let name = screen + .roster + .get(index) + .map(|entry| entry.name.clone()) + .unwrap_or_default(); + screen.set_status(format!( + "HANDOFF WOULD ENTER WORLD AS {}", + name.to_uppercase() + )); + } + } + Some(ScreenAction::CreateCharacter(name)) => { + println!("pregame: CREATE character '{name}' (no authority to route to)"); + if let Phase::Character(screen) = &mut phase { + let upper = name.to_uppercase(); + screen.roster.push(RosterEntry { + name, + lineage: screen.draft.lineage().name.to_string(), + vocation: screen.draft.vocation().to_string(), + location: screen.draft.lineage().home.to_string(), + played: "NEW".into(), + ..Default::default() + }); + screen.draft = Default::default(); + screen.set_stage(CharacterStage::Roster); + screen.set_status(format!("{upper} FILED TO THE LOCAL DEMO ROSTER")); + } + } + Some(ScreenAction::Back) => match &phase { + Phase::Entry(_) => { + println!("pregame: BACK from entry — leaving the flow"); + quit = true; + } + Phase::Character(_) | Phase::Loading(_) => { + println!("pregame: BACK to entry"); + phase = Phase::Entry(EntryScreen::new()); + link_deadline = None; + } + }, + Some(ScreenAction::Quit) => { + println!("pregame: QUIT"); + quit = true; + } + None => {} + } + + // Simulated link completes → hand over to the character surfaces. + if let Some(deadline) = link_deadline { + if now >= deadline { + link_deadline = None; + println!("pregame: simulated link established — showing demo roster"); + phase = Phase::Character(Box::new(CharacterScreen::with_entries(demo_roster()))); + } + } + + gpu.begin_pass( + PassTarget::Screen, + RectPx { x: 0, y: 0, w, h }, + ClearSpec { + color: Some([0.012, 0.027, 0.039, 1.0]), + depth: Some(1.0), + }, + ); + gpu.end_pass(); + renderer.render_ui(&mut gpu, &ui.buf, ui.quads, w as u32, h as u32); + + if let Some(path) = screenshot { + if frame + 1 == total { + let rgba = plat::read_pixels_rgba(w, h); + write_bmp(path, &rgba, w as u32, h as u32).unwrap_or_else(|error| { + eprintln!("screenshot failed: {error}"); + std::process::exit(1); + }); + println!("screenshot written: {path} ({w}x{h})"); + } + } + plat::end_frame(); + frame += 1; + } + plat::deinit(); +} diff --git a/client-rust/source/app/src/material_parity.rs b/client-rust/source/app/src/material_parity.rs new file mode 100644 index 00000000..66fa3157 --- /dev/null +++ b/client-rust/source/app/src/material_parity.rs @@ -0,0 +1,651 @@ +//! Cross-backend material, transparency, bloom, and edge conformance scene. + +use successor_engine_core::ecs::WorldOps; +use successor_engine_core::glb; +use successor_engine_core::math::{vec3, Mat4, Quat, Vec3}; +use successor_engine_render::components::{ + CamTarget, Camera, DirectionalLight, MeshRenderer, Projection, RectNorm, Transform, +}; +use successor_engine_render::gpu::{ + ClearSpec, Filter, Gpu, MinFilter, TextureDesc, TextureFormat, Wrap, +}; +use successor_engine_render::model::upload_glb; +use successor_engine_render::primitives; +use successor_engine_render::renderer::{MaterialDesc, Renderer}; + +use crate::GameWorld; + +pub const WIDTH: u32 = 1280; +pub const HEIGHT: u32 = 720; +pub const ASSET_PATHS: [&str; 6] = [ + "../client-3d/public/assets/world-items/commerce_facility.glb", + "../client-3d/public/assets/pawn-pack/weapons/custom/lightning_carbine.glb", + "../client-3d/public/assets/creatures/mossmuff_adult.glb", + "../client-3d/public/assets/wave-props/everyday-wave-20260719/prepared-foods/successor_food_beer_mug.glb", + "../client-3d/public/assets/items/custom/accessories/field_cap.glb", + "../client-3d/public/assets/world-items/megalith_brick_hex.glb", +]; + +#[derive(Clone, Copy, Debug)] +pub struct ProbeRect { + pub name: &'static str, + pub x: u32, + pub y: u32, + pub w: u32, + pub h: u32, +} + +pub const NORMAL_CONTROL: ProbeRect = ProbeRect { + name: "normal-control", + x: 16, + y: 24, + w: 112, + h: 112, +}; +pub const NORMAL_DETAIL: ProbeRect = ProbeRect { + name: "normal-detail", + x: 144, + y: 24, + w: 112, + h: 112, +}; +pub const BASE_PBR: ProbeRect = ProbeRect { + name: "base-pbr", + x: 336, + y: 24, + w: 112, + h: 112, +}; +pub const CLEARCOAT: ProbeRect = ProbeRect { + name: "clearcoat", + x: 464, + y: 24, + w: 112, + h: 112, +}; +pub const CHECKER: ProbeRect = ProbeRect { + name: "checker", + x: 656, + y: 24, + w: 112, + h: 112, +}; +pub const TRANSMISSION: ProbeRect = ProbeRect { + name: "transmission", + x: 784, + y: 24, + w: 112, + h: 112, +}; +pub const ALPHA_BACKGROUND: ProbeRect = ProbeRect { + name: "alpha-background", + x: 976, + y: 24, + w: 112, + h: 112, +}; +pub const ALPHA_OVERLAP: ProbeRect = ProbeRect { + name: "alpha-overlap", + x: 1104, + y: 24, + w: 112, + h: 112, +}; +pub const EMIT_LOW_HALO: ProbeRect = ProbeRect { + name: "emit-1.5-halo", + x: 16, + y: 184, + w: 112, + h: 112, +}; +pub const EMIT_HIGH_HALO: ProbeRect = ProbeRect { + name: "emit-4.0-halo", + x: 144, + y: 184, + w: 112, + h: 112, +}; +pub const AA_EDGE: ProbeRect = ProbeRect { + name: "aa-edge", + x: 336, + y: 184, + w: 240, + h: 112, +}; +pub const FLAT_CONTROL: ProbeRect = ProbeRect { + name: "flat-control", + x: 656, + y: 184, + w: 240, + h: 112, +}; + +pub struct Scene { + pub world: GameWorld, + pub renderer: Renderer, +} + +pub fn build(gpu: &mut G, assets: &[Vec; 6]) -> Result { + let mut renderer = crate::configured_renderer(gpu)?; + renderer.set_ambient(0.18); + renderer.set_fog([0.01, 0.01, 0.015], 10_000.0, 20_000.0); + renderer.set_grade([1.0; 3], 0.0, 1.0, 0.0); + renderer + .set_bloom(2.0, 0.8) + .map_err(|error| format!("bloom settings: {error:?}"))?; + let mut world = GameWorld::new(); + let (cube_vertices, cube_indices) = primitives::cube(); + let cube = renderer.upload_mesh(gpu, &cube_vertices, &cube_indices); + + let checker = upload_rgba(gpu, 8, 8, &checker_pixels()); + let inverse_checker = upload_rgba(gpu, 8, 8, &inverse_checker_pixels()); + let normal = upload_rgba(gpu, 8, 8, &normal_pixels()); + let white = MaterialDesc { + base_color: [0.62, 0.57, 0.48, 1.0], + metallic: 0.0, + roughness: 0.8, + specular: 0.0, + ..MaterialDesc::default() + }; + let materials = [ + renderer.add_material_desc(white), + renderer.add_material_desc(MaterialDesc { + normal_texture: Some(normal), + normal_scale: 1.8, + ..white + }), + renderer.add_material_desc(MaterialDesc { + clearcoat: 1.0, + clearcoat_roughness: 0.35, + ..white + }), + renderer.add_material_desc(MaterialDesc { + base_color_texture: Some(checker), + metallic: 0.0, + roughness: 0.7, + ..MaterialDesc::default() + }), + renderer.add_material_desc(MaterialDesc { + base_color: [0.75, 0.9, 1.0, 1.0], + metallic: 0.0, + roughness: 0.15, + transmission: 1.0, + ior: 1.45, + blend: true, + ..MaterialDesc::default() + }), + renderer.add_material_desc(MaterialDesc { + base_color: [0.95, 0.2, 0.12, 0.55], + metallic: 0.0, + roughness: 0.6, + blend: true, + double_sided: true, + ..MaterialDesc::default() + }), + renderer.add_material_desc(MaterialDesc { + base_color_texture: Some(inverse_checker), + metallic: 0.0, + roughness: 0.8, + ..MaterialDesc::default() + }), + renderer.add_material_desc(MaterialDesc { + base_color: [0.03, 0.03, 0.03, 1.0], + metallic: 0.0, + roughness: 1.0, + emissive_factor: [1.0, 0.35, 0.05], + emissive_strength: 1.5, + ..MaterialDesc::default() + }), + renderer.add_material_desc(MaterialDesc { + base_color: [0.03, 0.03, 0.03, 1.0], + metallic: 0.0, + roughness: 1.0, + emissive_factor: [1.0, 0.35, 0.05], + emissive_strength: 4.0, + ..MaterialDesc::default() + }), + renderer.add_material_desc(MaterialDesc { + base_color: [0.95, 0.95, 0.95, 1.0], + metallic: 0.0, + roughness: 0.9, + double_sided: true, + ..MaterialDesc::default() + }), + ]; + + let xs = [-6.91, -5.35, -3.02, -1.46, 0.88, 2.44, 4.77, 6.33]; + spawn_panel( + &mut world, + cube, + materials[0], + vec3(xs[0], 3.41, 0.0), + vec3(1.36, 1.36, 0.12), + Quat::IDENTITY, + ); + spawn_panel( + &mut world, + cube, + materials[1], + vec3(xs[1], 3.41, 0.0), + vec3(1.36, 1.36, 0.12), + Quat::IDENTITY, + ); + let highlight_rotation = Quat::from_axis_angle(vec3(1.0, 0.0, 0.0), -0.42) + .mul(Quat::from_axis_angle(vec3(0.0, 1.0, 0.0), -0.24)); + spawn_panel( + &mut world, + cube, + materials[0], + vec3(xs[2], 3.41, 0.0), + vec3(1.36, 1.36, 0.12), + highlight_rotation, + ); + spawn_panel( + &mut world, + cube, + materials[2], + vec3(xs[3], 3.41, 0.0), + vec3(1.36, 1.36, 0.12), + highlight_rotation, + ); + spawn_panel( + &mut world, + cube, + materials[3], + vec3(xs[4], 3.41, -0.25), + vec3(1.36, 1.36, 0.08), + Quat::IDENTITY, + ); + spawn_panel( + &mut world, + cube, + materials[3], + vec3(xs[5], 3.41, -0.25), + vec3(1.36, 1.36, 0.08), + Quat::IDENTITY, + ); + spawn_panel( + &mut world, + cube, + materials[4], + vec3(xs[5], 3.41, 0.15), + vec3(1.30, 1.30, 0.08), + Quat::IDENTITY, + ); + spawn_panel( + &mut world, + cube, + materials[6], + vec3(xs[6], 3.41, -0.2), + vec3(1.36, 1.36, 0.08), + Quat::IDENTITY, + ); + spawn_panel( + &mut world, + cube, + materials[5], + vec3(xs[7], 3.41, 0.15), + vec3(1.36, 1.36, 0.08), + Quat::IDENTITY, + ); + + spawn_panel( + &mut world, + cube, + materials[7], + vec3(-6.91, 1.46, 0.0), + vec3(0.45, 0.45, 0.12), + Quat::IDENTITY, + ); + spawn_panel( + &mut world, + cube, + materials[8], + vec3(-5.35, 1.46, 0.0), + vec3(0.45, 0.45, 0.12), + Quat::IDENTITY, + ); + let diagonal = Quat::from_axis_angle(vec3(0.0, 0.0, 1.0), -0.45); + spawn_panel( + &mut world, + cube, + materials[9], + vec3(-2.24, 1.46, 0.0), + vec3(2.4, 0.035, 0.08), + diagonal, + ); + spawn_panel( + &mut world, + cube, + materials[9], + vec3(1.66, 1.46, 0.0), + vec3(2.4, 1.30, 0.08), + Quat::IDENTITY, + ); + + for (index, bytes) in assets.iter().enumerate() { + add_asset(&mut renderer, gpu, &mut world, bytes, index)?; + } + + let sun = world.spawn(); + world.set_component( + sun, + DirectionalLight { + dir: vec3(0.4, -0.7, -0.6).normalize(), + color: [1.0, 0.98, 0.94], + cast_shadows: true, + }, + ); + let camera = world.spawn(); + world.set_component( + camera, + Camera { + viewport_id: 0, + order: 0, + projection: Projection::Perspective { + fovy: 0.7, + near: 0.1, + far: 100.0, + }, + target: CamTarget::Screen(RectNorm::FULL), + clear: ClearSpec { + color: Some([0.01, 0.01, 0.015, 1.0]), + depth: Some(1.0), + }, + eye: vec3(0.0, 0.0, 12.0), + look_at: Vec3::ZERO, + up: Vec3::Y, + }, + ); + Ok(Scene { world, renderer }) +} + +fn spawn_panel( + world: &mut GameWorld, + mesh: successor_engine_render::components::MeshId, + material: successor_engine_render::components::MaterialId, + pos: Vec3, + scale: Vec3, + rot: Quat, +) { + let entity = world.spawn(); + world.set_component(entity, Transform { pos, rot, scale }); + world.set_component( + entity, + MeshRenderer { + mesh, + material, + viewport_mask: 1, + ..Default::default() + }, + ); +} + +fn upload_rgba( + gpu: &mut G, + width: u32, + height: u32, + pixels: &[u8], +) -> successor_engine_render::gpu::TextureId { + gpu.create_texture( + &TextureDesc { + width, + height, + format: TextureFormat::Srgba8, + mag_filter: Filter::Linear, + min_filter: MinFilter::Linear, + wrap_s: Wrap::Repeat, + wrap_t: Wrap::Repeat, + mipmaps: false, + }, + Some(pixels), + ) +} + +fn checker_pixels() -> Vec { + let mut pixels = vec![0; 8 * 8 * 4]; + for y in 0..8 { + for x in 0..8 { + let value = if (x + y) % 2 == 0 { 235 } else { 25 }; + let offset = (y * 8 + x) * 4; + pixels[offset..offset + 4].copy_from_slice(&[value, value, value, 255]); + } + } + pixels +} + +fn inverse_checker_pixels() -> Vec { + let mut pixels = checker_pixels(); + for pixel in pixels.chunks_exact_mut(4) { + pixel[0] = 255 - pixel[0]; + pixel[1] = 255 - pixel[1]; + pixel[2] = 255 - pixel[2]; + } + pixels +} + +fn normal_pixels() -> Vec { + let mut pixels = vec![0; 8 * 8 * 4]; + for y in 0..8 { + for x in 0..8 { + let sx = if x % 2 == 0 { 70 } else { 186 }; + let sy = if y % 2 == 0 { 70 } else { 186 }; + let offset = (y * 8 + x) * 4; + pixels[offset..offset + 4].copy_from_slice(&[sx, sy, 230, 255]); + } + } + pixels +} + +fn add_asset( + renderer: &mut Renderer, + gpu: &mut G, + world: &mut GameWorld, + bytes: &[u8], + cell: usize, +) -> Result<(), String> { + let doc = glb::parse(bytes).map_err(|error| format!("asset {cell} parse: {error:?}"))?; + let uploaded = upload_glb(renderer, gpu, &doc) + .map_err(|error| format!("asset {cell} upload: {error:?}"))?; + let globals = doc.node_globals(); + let mut min = vec3(f32::MAX, f32::MAX, f32::MAX); + let mut max = vec3(f32::MIN, f32::MIN, f32::MIN); + for (node_index, node) in doc.nodes.iter().enumerate() { + let Some(mesh_index) = node.mesh else { + continue; + }; + let Some(mesh) = doc.meshes.get(mesh_index) else { + continue; + }; + for primitive in &mesh.primitives { + for position in &primitive.positions { + let point = globals[node_index].transform_point(vec3( + position[0], + position[1], + position[2], + )); + min = vec3(min.x.min(point.x), min.y.min(point.y), min.z.min(point.z)); + max = vec3(max.x.max(point.x), max.y.max(point.y), max.z.max(point.z)); + } + } + } + + if min.x > max.x { + return Err(format!("asset {cell} has no geometry")); + } + let center = min.add(max).scale(0.5); + let extent = max.sub(min); + let cell_width = 2.34; + let cell_height = 2.55; + let scale = (cell_width / extent.x.max(0.001)) + .min(cell_height / extent.y.max(0.001)) + .min(cell_height / extent.z.max(0.001)) + * 0.9; + let x = -6.49 + cell as f32 * 2.596; + let outer = Mat4::from_trs( + vec3(x, -2.92, 0.0).sub(center.scale(scale)), + Quat::IDENTITY, + vec3(scale, scale, scale), + ); + for (node_index, node) in doc.nodes.iter().enumerate() { + let Some(mesh_index) = node.mesh else { + continue; + }; + let Some(mesh) = doc.meshes.get(mesh_index) else { + continue; + }; + for primitive_index in 0..mesh.primitives.len() { + let item = uploaded + .primitives + .iter() + .find(|item| { + item.source_mesh == mesh_index && item.source_primitive == primitive_index + }) + .ok_or_else(|| format!("asset {cell} primitive missing"))?; + let (pos, rot, scale) = outer.mul(globals[node_index]).to_trs(); + spawn_panel(world, item.mesh, item.material, pos, scale, rot); + } + } + Ok(()) +} + +#[derive(Debug)] +pub struct ProbeReport { + pub normal_difference: f32, + pub clearcoat_peak_delta: f32, + pub transmission_correlation: f32, + pub opaque_correlation: f32, + pub alpha_margin: f32, + pub bloom_halo_delta: f32, + pub aa_intermediate: usize, + pub flat_difference: f32, +} + +pub fn probe_rgba_top_left( + rgba_bottom_left: &[u8], + width: u32, + height: u32, +) -> Result { + if width != WIDTH || height != HEIGHT || rgba_bottom_left.len() != (width * height * 4) as usize + { + return Err(format!("probe requires {WIDTH}x{HEIGHT} RGBA readback")); + } + let mut rgba = vec![0; rgba_bottom_left.len()]; + let row = (width * 4) as usize; + for y in 0..height as usize { + rgba[y * row..(y + 1) * row].copy_from_slice( + &rgba_bottom_left[(height as usize - 1 - y) * row..(height as usize - y) * row], + ); + } + let normal_difference = mean_abs_luma_difference(&rgba, width, NORMAL_CONTROL, NORMAL_DETAIL); + let clearcoat_peak_delta = + peak_luma(&rgba, width, CLEARCOAT) - peak_luma(&rgba, width, BASE_PBR); + let transmission_correlation = correlation(&rgba, width, CHECKER, TRANSMISSION); + let opaque_correlation = correlation(&rgba, width, CHECKER, ALPHA_BACKGROUND); + let alpha_mean = mean_luma(&rgba, width, ALPHA_OVERLAP); + let alpha_margin = (alpha_mean - mean_luma(&rgba, width, ALPHA_BACKGROUND)).abs(); + let bloom_halo_delta = + mean_luma(&rgba, width, EMIT_HIGH_HALO) - mean_luma(&rgba, width, EMIT_LOW_HALO); + let aa_intermediate = diagonal_intermediate_count(&rgba, width, AA_EDGE); + let flat_difference = flat_neighbor_difference(&rgba, width, FLAT_CONTROL); + let report = ProbeReport { + normal_difference, + clearcoat_peak_delta, + transmission_correlation, + opaque_correlation, + alpha_margin, + bloom_halo_delta, + aa_intermediate, + flat_difference, + }; + println!("material-parity normal_difference={normal_difference:.5} rects={NORMAL_CONTROL:?}/{NORMAL_DETAIL:?}"); + println!("material-parity clearcoat_peak_delta={clearcoat_peak_delta:.5} rects={BASE_PBR:?}/{CLEARCOAT:?}"); + println!("material-parity transmission_correlation={transmission_correlation:.5} opaque_correlation={opaque_correlation:.5} rects={CHECKER:?}/{TRANSMISSION:?}/{ALPHA_BACKGROUND:?}"); + println!("material-parity alpha_margin={alpha_margin:.5} rect={ALPHA_OVERLAP:?}"); + println!("material-parity bloom_halo_delta={bloom_halo_delta:.5} rects={EMIT_LOW_HALO:?}/{EMIT_HIGH_HALO:?}"); + println!("material-parity aa_intermediate={aa_intermediate} flat_difference={flat_difference:.5} rects={AA_EDGE:?}/{FLAT_CONTROL:?}"); + if normal_difference < 0.03 + || clearcoat_peak_delta < 0.05 + || transmission_correlation < 0.50 + || opaque_correlation > 0.10 + || alpha_margin < 0.02 + || bloom_halo_delta < 0.02 + || aa_intermediate < 26 + || flat_difference > 0.01 + { + return Err(format!("material parity inequalities failed: {report:?}")); + } + Ok(report) +} + +fn samples(rgba: &[u8], width: u32, rect: ProbeRect) -> impl Iterator + '_ { + (rect.y..rect.y + rect.h).flat_map(move |y| { + (rect.x..rect.x + rect.w).map(move |x| { + let offset = ((y * width + x) * 4) as usize; + (0.2126 * rgba[offset] as f32 + + 0.7152 * rgba[offset + 1] as f32 + + 0.0722 * rgba[offset + 2] as f32) + / 255.0 + }) + }) +} +fn mean_luma(rgba: &[u8], width: u32, rect: ProbeRect) -> f32 { + samples(rgba, width, rect).sum::() / (rect.w * rect.h) as f32 +} +fn peak_luma(rgba: &[u8], width: u32, rect: ProbeRect) -> f32 { + samples(rgba, width, rect).fold(0.0, f32::max) +} +fn mean_abs_luma_difference(rgba: &[u8], width: u32, a: ProbeRect, b: ProbeRect) -> f32 { + samples(rgba, width, a) + .zip(samples(rgba, width, b)) + .map(|(x, y)| (x - y).abs()) + .sum::() + / (a.w * a.h) as f32 +} +fn correlation(rgba: &[u8], width: u32, a: ProbeRect, b: ProbeRect) -> f32 { + let ma = mean_luma(rgba, width, a); + let mb = mean_luma(rgba, width, b); + let (mut num, mut da, mut db) = (0.0, 0.0, 0.0); + for (x, y) in samples(rgba, width, a).zip(samples(rgba, width, b)) { + let ax = x - ma; + let by = y - mb; + num += ax * by; + da += ax * ax; + db += by * by; + } + num / (da * db).sqrt().max(1e-6) +} +fn diagonal_intermediate_count(rgba: &[u8], width: u32, rect: ProbeRect) -> usize { + (0..128) + .filter(|index| { + let x = rect.x + (*index as u32 * rect.w / 128).min(rect.w - 1); + let y = rect.y + (*index as u32 * rect.h / 128).min(rect.h - 1); + let value = samples( + rgba, + width, + ProbeRect { + name: "edge-sample", + x, + y, + w: 1, + h: 1, + }, + ) + .next() + .unwrap_or(0.0); + value > 0.10 && value < 0.90 + }) + .count() +} +fn flat_neighbor_difference(rgba: &[u8], width: u32, rect: ProbeRect) -> f32 { + let shifted = ProbeRect { + x: rect.x + 1, + w: rect.w - 1, + ..rect + }; + let base = ProbeRect { + w: rect.w - 1, + ..rect + }; + mean_abs_luma_difference(rgba, width, base, shifted) +} diff --git a/client-rust/source/app/src/net/connect.rs b/client-rust/source/app/src/net/connect.rs new file mode 100644 index 00000000..1d18bede --- /dev/null +++ b/client-rust/source/app/src/net/connect.rs @@ -0,0 +1,247 @@ +//! Connection URL and JoinOptions parsing for the playable slice. + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JoinOptions { + pub endpoint: String, + pub player_id: String, + pub actor_id: String, + pub ticket: Option, + pub release: Option, +} + +/// Helper to percent-decode a string, handling standard hex escapes and '+' to space conversion. +fn percent_decode(s: &str) -> String { + let mut bytes = Vec::with_capacity(s.len()); + let s_bytes = s.as_bytes(); + let mut i = 0; + while i < s_bytes.len() { + if s_bytes[i] == b'%' && i + 2 < s_bytes.len() { + let h1 = s_bytes[i + 1]; + let h2 = s_bytes[i + 2]; + if let (Some(d1), Some(d2)) = (char::from(h1).to_digit(16), char::from(h2).to_digit(16)) + { + bytes.push(((d1 << 4) | d2) as u8); + i += 3; + continue; + } + } + if s_bytes[i] == b'+' { + bytes.push(b' '); + } else { + bytes.push(s_bytes[i]); + } + i += 1; + } + String::from_utf8_lossy(&bytes).into_owned() +} + +/// Parses a connection/play-launch URL and extracts the join parameters. +/// Accepts ws://, wss://, http://, and https:// variants. +pub fn parse_connect_url(url: &str) -> Option { + if !(url.starts_with("ws://") + || url.starts_with("wss://") + || url.starts_with("http://") + || url.starts_with("https://")) + { + return None; + } + + // Split query and fragment out + let without_fragment = match url.find('#') { + Some(idx) => &url[..idx], + None => url, + }; + + let (endpoint, query_str) = match without_fragment.find('?') { + Some(idx) => (&without_fragment[..idx], &without_fragment[idx + 1..]), + None => (without_fragment, ""), + }; + + let mut player_id = None; + let mut actor_id = None; + let mut ticket = None; + let mut release = None; + + if !query_str.is_empty() { + for part in query_str.split('&') { + if part.is_empty() { + continue; + } + let mut kv = part.splitn(2, '='); + let key = kv.next().unwrap_or(""); + let val = kv.next().unwrap_or(""); + + let decoded_key = percent_decode(key); + let decoded_val = percent_decode(val); + + match decoded_key.as_str() { + "player" => player_id = Some(decoded_val), + "actor" => actor_id = Some(decoded_val), + "ticket" => ticket = Some(decoded_val), + "release" => release = Some(decoded_val), + _ => {} + } + } + } + + Some(JoinOptions { + endpoint: endpoint.to_string(), + player_id: player_id.unwrap_or_else(|| "dev-1".to_string()), + actor_id: actor_id.unwrap_or_else(|| "dev-1".to_string()), + ticket, + release, + }) +} + +/// Strict launch parsing used by connected mode. Unlike the development-only +/// helper above this never invents an identity when URL fields are absent. +pub fn parse_connect_url_strict(url: &str) -> Option { + let parsed = parse_connect_url(url)?; + if parsed.player_id == "dev-1" + && parsed.actor_id == "dev-1" + && !url.split('?').nth(1).unwrap_or("").contains("player=") + { + return None; + } + if parsed.player_id.trim().is_empty() || parsed.actor_id.trim().is_empty() { + return None; + } + Some(parsed) +} + +/// Maps wss:// to https:// and ws:// to http://. +pub fn http_endpoint(endpoint: &str) -> String { + endpoint + .replacen("wss://", "https://", 1) + .replacen("ws://", "http://", 1) +} + +/// Builds the local-development chat socket alongside a game endpoint. +pub fn dev_chat_endpoint(endpoint: &str, player_id: &str) -> Option { + if !(endpoint.starts_with("ws://") || endpoint.starts_with("wss://")) { + return None; + } + let authority_start = endpoint.find("://")? + 3; + let authority_end = endpoint[authority_start..] + .find(['/', '?', '#']) + .map(|offset| authority_start + offset) + .unwrap_or(endpoint.len()); + let origin = &endpoint[..authority_end]; + let mut encoded = String::with_capacity(player_id.len()); + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + for byte in player_id.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(byte as char); + } else { + encoded.push('%'); + encoded.push(HEX[(byte >> 4) as usize] as char); + encoded.push(HEX[(byte & 0x0f) as usize] as char); + } + } + Some(format!("{origin}/chat/ws?playerId={encoded}")) +} + +/// Builds the Colyseus matchmaker POST path for a room. +pub fn matchmake_path(room: &str) -> String { + format!("/matchmake/joinOrCreate/{}", room) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_full_url() { + let url = "ws://127.0.0.1:28093/?player=p1&actor=a1&ticket=t1&release=r1"; + let parsed = parse_connect_url(url).unwrap(); + assert_eq!(parsed.endpoint, "ws://127.0.0.1:28093/"); + assert_eq!(parsed.player_id, "p1"); + assert_eq!(parsed.actor_id, "a1"); + assert_eq!(parsed.ticket, Some("t1".to_string())); + assert_eq!(parsed.release, Some("r1".to_string())); + } + + #[test] + fn test_parse_empty_params_but_present() { + let url = "ws://127.0.0.1:28093/?player=&actor=&ticket=&release="; + let parsed = parse_connect_url(url).unwrap(); + assert_eq!(parsed.endpoint, "ws://127.0.0.1:28093/"); + assert_eq!(parsed.player_id, ""); + assert_eq!(parsed.actor_id, ""); + assert_eq!(parsed.ticket, Some("".to_string())); + assert_eq!(parsed.release, Some("".to_string())); + } + + #[test] + fn test_defaults_when_absent() { + let url = "ws://127.0.0.1:28093/"; + let parsed = parse_connect_url(url).unwrap(); + assert_eq!(parsed.endpoint, "ws://127.0.0.1:28093/"); + assert_eq!(parsed.player_id, "dev-1"); + assert_eq!(parsed.actor_id, "dev-1"); + assert_eq!(parsed.ticket, None); + assert_eq!(parsed.release, None); + } + + #[test] + fn test_http_endpoint_mapping() { + assert_eq!( + http_endpoint("ws://127.0.0.1:28093/"), + "http://127.0.0.1:28093/" + ); + assert_eq!( + http_endpoint("wss://127.0.0.1:28093/"), + "https://127.0.0.1:28093/" + ); + assert_eq!(http_endpoint("http://example.com/"), "http://example.com/"); + assert_eq!( + http_endpoint("https://example.com/"), + "https://example.com/" + ); + } + + #[test] + fn test_dev_chat_endpoint() { + assert_eq!( + dev_chat_endpoint("ws://127.0.0.1:28093/game?ticket=ignored", "dev one"), + Some("ws://127.0.0.1:28093/chat/ws?playerId=dev%20one".into()) + ); + assert_eq!( + dev_chat_endpoint("wss://example.test", "dev-1"), + Some("wss://example.test/chat/ws?playerId=dev-1".into()) + ); + assert!(dev_chat_endpoint("https://example.test", "dev-1").is_none()); + } + + #[test] + fn test_percent_decoding() { + let url = "ws://127.0.0.1:28093/?player=dev%2D1&actor=dev%202&ticket=a%2Bb%25c"; + let parsed = parse_connect_url(url).unwrap(); + assert_eq!(parsed.player_id, "dev-1"); + assert_eq!(parsed.actor_id, "dev 2"); + assert_eq!(parsed.ticket, Some("a+b%c".to_string())); + + let url2 = "ws://127.0.0.1:28093/?player=dev%2d1"; + let parsed2 = parse_connect_url(url2).unwrap(); + assert_eq!(parsed2.player_id, "dev-1"); + + let url3 = "ws://127.0.0.1:28093/?player=dev+space"; + let parsed3 = parse_connect_url(url3).unwrap(); + assert_eq!(parsed3.player_id, "dev space"); + } + + #[test] + fn test_wss_url_parses() { + let url = "wss://secure.example.com:28093/path?player=bob"; + let parsed = parse_connect_url(url).unwrap(); + assert_eq!(parsed.endpoint, "wss://secure.example.com:28093/path"); + assert_eq!(parsed.player_id, "bob"); + assert_eq!(parsed.actor_id, "dev-1"); + } + + #[test] + fn test_matchmake_path() { + assert_eq!(matchmake_path("game"), "/matchmake/joinOrCreate/game"); + assert_eq!(matchmake_path("lobby"), "/matchmake/joinOrCreate/lobby"); + } +} diff --git a/client-rust/source/app/src/net/mod.rs b/client-rust/source/app/src/net/mod.rs new file mode 100644 index 00000000..a3653608 --- /dev/null +++ b/client-rust/source/app/src/net/mod.rs @@ -0,0 +1,6 @@ +//! Networking helpers for the playable slice: connect-URL + join-option parsing +//! (the live Colyseus transport itself lives in `platform` + `game`). + +pub mod connect; +pub mod release; +pub mod session; diff --git a/client-rust/source/app/src/net/release.rs b/client-rust/source/app/src/net/release.rs new file mode 100644 index 00000000..dbb566d3 --- /dev/null +++ b/client-rust/source/app/src/net/release.rs @@ -0,0 +1,145 @@ +//! Release identity + reconnect policy for the playable slice. +//! +//! The server gates joins on a client release allowlist (`server/src/auth/ +//! runtime.ts`). Per AGENTS.md the Rust client stays UNPUBLISHED/unallowlisted +//! until parity + product promotion, so it advertises a clearly non-production +//! identity — it must never impersonate an allowlisted web/desktop release. +//! +//! `ReconnectPolicy` is the backoff schedule the connect loop waits between +//! attempts (the session FSM counts attempts; this owns the timing). + +/// This client's release identity. Intentionally an `unlisted` channel: it will +/// NOT match the production allowlist until a deliberate product decision +/// registers it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ReleaseIdentity { + pub name: &'static str, + pub version: &'static str, + pub channel: &'static str, +} + +pub const CURRENT: ReleaseIdentity = ReleaseIdentity { + name: "successor-rust-client", + version: env!("CARGO_PKG_VERSION"), + channel: "unlisted", +}; + +impl ReleaseIdentity { + /// The identity string sent to the server on join (`name/version+channel`). + pub fn header(&self) -> String { + format!("{}/{}+{}", self.name, self.version, self.channel) + } + + /// Whether this identity claims a production/allowlisted channel (it must + /// not — a guard so we never accidentally point an unrecognized client at + /// the live authority as if promoted). + pub fn is_production(&self) -> bool { + matches!(self.channel, "stable" | "release" | "production") + } +} + +/// Exponential-backoff reconnect schedule with a cap and a hard attempt limit. +#[derive(Clone, Copy, Debug)] +pub struct ReconnectPolicy { + pub attempt: u32, + pub max_attempts: u32, + pub base_delay_ms: u32, + pub max_delay_ms: u32, +} + +impl Default for ReconnectPolicy { + fn default() -> Self { + Self { + attempt: 0, + max_attempts: 6, + base_delay_ms: 500, + max_delay_ms: 8_000, + } + } +} + +impl ReconnectPolicy { + pub fn new(max_attempts: u32, base_delay_ms: u32, max_delay_ms: u32) -> Self { + Self { + attempt: 0, + max_attempts, + base_delay_ms, + max_delay_ms, + } + } + + /// Record a failed connection; returns the delay (ms) to wait before the + /// next attempt, or `None` once the attempt budget is exhausted (give up). + pub fn record_failure(&mut self) -> Option { + if self.attempt >= self.max_attempts { + return None; + } + let delay = self.delay_for(self.attempt); + self.attempt += 1; + Some(delay) + } + + /// Backoff delay for a zero-based attempt index (base × 2^n, capped). + pub fn delay_for(&self, attempt: u32) -> u32 { + let shifted = self.base_delay_ms.saturating_mul(1u32 << attempt.min(16)); + shifted.min(self.max_delay_ms) + } + + /// A successful connection resets the schedule. + pub fn reset(&mut self) { + self.attempt = 0; + } + + pub fn exhausted(&self) -> bool { + self.attempt >= self.max_attempts + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn release_identity_is_unlisted_not_production() { + assert!( + !CURRENT.is_production(), + "Rust client must stay unallowlisted" + ); + assert_eq!(CURRENT.channel, "unlisted"); + let h = CURRENT.header(); + assert!(h.starts_with("successor-rust-client/")); + assert!(h.ends_with("+unlisted")); + } + + #[test] + fn backoff_grows_and_caps() { + let p = ReconnectPolicy::default(); // base 500, cap 8000 + assert_eq!(p.delay_for(0), 500); + assert_eq!(p.delay_for(1), 1000); + assert_eq!(p.delay_for(2), 2000); + assert_eq!(p.delay_for(3), 4000); + assert_eq!(p.delay_for(4), 8000); + assert_eq!(p.delay_for(5), 8000, "capped at max_delay"); + assert_eq!(p.delay_for(20), 8000, "no overflow at large attempt"); + } + + #[test] + fn gives_up_after_max_attempts() { + let mut p = ReconnectPolicy::new(3, 100, 1000); + assert_eq!(p.record_failure(), Some(100)); + assert_eq!(p.record_failure(), Some(200)); + assert_eq!(p.record_failure(), Some(400)); + assert_eq!(p.record_failure(), None, "budget exhausted"); + assert!(p.exhausted()); + } + + #[test] + fn reset_after_success_restarts_schedule() { + let mut p = ReconnectPolicy::new(4, 100, 1000); + p.record_failure(); + p.record_failure(); + p.reset(); + assert_eq!(p.attempt, 0); + assert_eq!(p.record_failure(), Some(100), "back to base after reset"); + } +} diff --git a/client-rust/source/app/src/net/session.rs b/client-rust/source/app/src/net/session.rs new file mode 100644 index 00000000..ab2b55a3 --- /dev/null +++ b/client-rust/source/app/src/net/session.rs @@ -0,0 +1,494 @@ +//! Authenticated launch/session lifecycle shared by native and WebGL shells. +//! This module is transport-neutral: platform sockets execute the requests and +//! feed events back into these state machines. + +use super::release::ReconnectPolicy; +use successor_client_proto::packets::GameHello; + +#[derive(Debug, PartialEq, Eq)] +pub struct LaunchEnvelope { + pub schema: String, + pub game_ticket: String, + pub chat_ticket: String, + pub game_endpoint: String, + pub chat_endpoint: String, + pub client_release: String, + pub server_release: String, + pub shard: Option, + pub character_id: String, + pub expires_at_ms: u64, + pub dev_spawn: Option, + game_consumed: bool, + chat_consumed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DevSpawn { + pub area: String, + pub x: String, + pub y: String, + pub facing: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LaunchError { + Invalid(String), + Expired, + Replayed, +} + +impl LaunchEnvelope { + /// Strictly validate and consume a standalone launch payload. Tickets are + /// held only until `authenticate_*` takes them, then removed immediately. + pub fn from_json(value: &serde_json::Value, now_ms: u64) -> Result { + let obj = value + .as_object() + .ok_or_else(|| LaunchError::Invalid("launch must be an object".into()))?; + let get = |k: &str| { + obj.get(k) + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + .map(str::to_owned) + }; + if obj.get("schema").and_then(|v| v.as_str()) != Some("successor.launch-context.v1") { + return Err(LaunchError::Invalid("unsupported launch schema".into())); + } + let game_ticket = + get("gameTicket").ok_or_else(|| LaunchError::Invalid("missing game ticket".into()))?; + let chat_ticket = + get("chatTicket").ok_or_else(|| LaunchError::Invalid("missing chat ticket".into()))?; + if game_ticket == chat_ticket { + return Err(LaunchError::Invalid("tickets must be distinct".into())); + } + let endpoints = obj + .get("endpoints") + .and_then(|v| v.as_object()) + .ok_or_else(|| LaunchError::Invalid("missing endpoints".into()))?; + let endpoint = |key: &str| -> Result { + let s = endpoints + .get(key) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| LaunchError::Invalid(format!("missing {key} endpoint")))?; + if !(s.starts_with("wss://") || s.starts_with("ws://")) { + return Err(LaunchError::Invalid(format!("invalid {key} endpoint"))); + } + Ok(s.to_owned()) + }; + let release = obj + .get("release") + .and_then(|v| v.as_object()) + .ok_or_else(|| LaunchError::Invalid("missing release".into()))?; + let client_release = release + .get("client") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| LaunchError::Invalid("missing client release".into()))? + .to_owned(); + let server_release = release + .get("server") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| LaunchError::Invalid("missing server release".into()))? + .to_owned(); + let character_id = + get("characterId").ok_or_else(|| LaunchError::Invalid("missing character".into()))?; + let expires_at_ms = obj + .get("expiresAt") + .and_then(|v| v.as_u64()) + .ok_or_else(|| LaunchError::Invalid("invalid expiry".into()))?; + if expires_at_ms <= now_ms { + return Err(LaunchError::Expired); + } + let dev_spawn = match obj.get("devSpawn") { + None => None, + Some(_) if game_ticket != "dev-identity" => { + return Err(LaunchError::Invalid( + "dev spawn requires development identity".into(), + )); + } + Some(value) => { + let spawn = value + .as_object() + .ok_or_else(|| LaunchError::Invalid("dev spawn must be an object".into()))?; + let field = |key: &str| { + spawn + .get(key) + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .ok_or_else(|| LaunchError::Invalid(format!("invalid dev spawn {key}"))) + }; + Some(DevSpawn { + area: field("area")?, + x: field("x")?, + y: field("y")?, + facing: field("facing")?, + }) + } + }; + Ok(Self { + schema: "successor.launch-context.v1".into(), + game_ticket, + chat_ticket, + game_endpoint: endpoint("game")?, + chat_endpoint: endpoint("chat")?, + client_release, + server_release, + shard: release + .get("shard") + .and_then(|v| v.as_str()) + .map(str::to_owned), + character_id, + dev_spawn, + expires_at_ms, + game_consumed: false, + chat_consumed: false, + }) + } + pub fn consume_game_ticket(&mut self) -> Result { + if self.game_consumed { + return Err(LaunchError::Replayed); + } + self.game_consumed = true; + Ok(std::mem::take(&mut self.game_ticket)) + } + pub fn consume_chat_ticket(&mut self) -> Result { + if self.chat_consumed { + return Err(LaunchError::Replayed); + } + self.chat_consumed = true; + Ok(std::mem::take(&mut self.chat_ticket)) + } + pub fn tickets_cleared(&self) -> bool { + self.game_ticket.is_empty() && self.chat_ticket.is_empty() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GameConnection { + Offline, + Matchmaking, + Authenticating, + AwaitingSnapshot, + Connected, + Reconnecting, + Exhausted, + IntentionalExit, + Fatal, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GameFailure { + Ticket(String), + Matchmake(String), + Authentication(String), + Protocol(String), + SourceMismatch, + SnapshotMissing, + ReconnectExhausted, +} + +pub struct GameLifecycle { + pub state: GameConnection, + pub reconnect: ReconnectPolicy, + pub source_hash: Option, + pub shard: Option, + pub tick: u64, + intentional_exit: bool, +} +impl Default for GameLifecycle { + fn default() -> Self { + Self { + state: GameConnection::Offline, + reconnect: ReconnectPolicy::default(), + source_hash: None, + shard: None, + tick: 0, + intentional_exit: false, + } + } +} +impl GameLifecycle { + pub fn begin_matchmake(&mut self) { + self.intentional_exit = false; + self.state = GameConnection::Matchmaking; + } + pub fn authenticated(&mut self) { + self.state = GameConnection::Authenticating; + } + pub fn validate_hello( + &mut self, + hello: &GameHello, + expected_source: Option<&str>, + expected_shard: Option<&str>, + ) -> Result<(), GameFailure> { + if hello.session_id.is_empty() || hello.player_actor_id.is_empty() { + return Err(GameFailure::SnapshotMissing); + } + let snap = &hello.snapshot; + if snap.schema.is_empty() || !snap.actors.contains_key(&hello.player_actor_id) { + return Err(GameFailure::SnapshotMissing); + } + if snap.source_state_hash.is_none() || snap.source_actor_count.is_none() { + return Err(GameFailure::SourceMismatch); + } + if expected_source.is_some_and(|s| snap.source_state_hash.as_deref() != Some(s)) + || expected_shard.is_some_and(|s| snap.shard_id != s) + { + return Err(GameFailure::SourceMismatch); + } + self.source_hash = snap.source_state_hash.clone(); + self.shard = Some(snap.shard_id.clone()); + self.tick = snap.tick; + self.reconnect.reset(); + self.state = GameConnection::Connected; + Ok(()) + } + pub fn socket_lost(&mut self) -> Option { + if self.intentional_exit { + self.state = GameConnection::IntentionalExit; + return None; + } + self.state = GameConnection::Reconnecting; + let d = self.reconnect.record_failure(); + if d.is_none() { + self.state = GameConnection::Exhausted; + } + d + } + pub fn intentional_exit(&mut self) { + self.intentional_exit = true; + self.state = GameConnection::IntentionalExit; + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RosterAction { + Create, + Delete, + Select, + Enter, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CharacterSummary { + pub id: String, + pub name: String, + pub profession: String, + pub online: bool, + pub deletable: bool, +} +#[derive(Debug, Clone, Default)] +pub struct RosterState { + pub characters: Vec, + pub selected: Option, + pub slot_limit: u32, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RosterError { + InvalidName, + InvalidProfession, + NoSlots, + NotFound, + OnlineCharacter, + RequestFailed(String), +} +impl RosterState { + pub fn replace(&mut self, characters: Vec, slot_limit: u32) { + self.characters = characters; + self.slot_limit = slot_limit; + self.selected = None; + } + pub fn select(&mut self, id: &str) -> Result<&CharacterSummary, RosterError> { + let index = self + .characters + .iter() + .position(|c| c.id == id) + .ok_or(RosterError::NotFound)?; + self.selected = Some(id.to_owned()); + Ok(&self.characters[index]) + } + pub fn validate_create(&self, name: &str, profession: &str) -> Result<(), RosterError> { + let name = name.trim(); + if !(2..=24).contains(&name.chars().count()) + || !name + .chars() + .all(|c| c.is_alphanumeric() || c == ' ' || c == '-' || c == '_') + { + return Err(RosterError::InvalidName); + } + if profession.trim().is_empty() { + return Err(RosterError::InvalidProfession); + } + if self.characters.len() as u32 >= self.slot_limit { + return Err(RosterError::NoSlots); + } + Ok(()) + } + pub fn validate_delete(&self, id: &str, legacy: bool) -> Result<(), RosterError> { + let c = self + .characters + .iter() + .find(|c| c.id == id) + .ok_or(RosterError::NotFound)?; + if !legacy || !c.deletable { + return Err(RosterError::RequestFailed("deletion unavailable".into())); + } + if c.online { + return Err(RosterError::OnlineCharacter); + } + Ok(()) + } +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EntryState { + Entry, + LoadingRoster, + CharacterSelect, + Creating, + Deleting, + Entering, + Fatal(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use successor_client_proto::packets::{GameActorSnapshot, GameShardSnapshot}; + + fn launch_json() -> serde_json::Value { + serde_json::json!({ + "schema": "successor.launch-context.v1", + "gameTicket": "game-ticket", + "chatTicket": "chat-ticket", + "endpoints": { + "game": "wss://world.example/game", + "chat": "wss://chat.example/chat" + }, + "release": { + "client": "client-sha", + "server": "server-sha", + "shard": "open-desert" + }, + "characterId": "player", + "expiresAt": 2000 + }) + } + + fn hello() -> GameHello { + let mut snapshot = GameShardSnapshot { + schema: "successor.authoritative-shard-snapshot.v1".into(), + shard_id: "open-desert".into(), + tick: 42, + player_actor_id: "player".into(), + source_state_hash: Some("source-hash".into()), + source_actor_count: Some(1), + ..GameShardSnapshot::default() + }; + snapshot + .actors + .insert("player".into(), GameActorSnapshot::default()); + GameHello { + session_id: "session".into(), + player_actor_id: "player".into(), + snapshot, + server_time: "now".into(), + } + } + #[test] + fn development_spawn_is_explicitly_gated_by_dev_identity() { + let mut dev = launch_json(); + dev["gameTicket"] = serde_json::json!("dev-identity"); + dev["devSpawn"] = serde_json::json!({ + "area": "open-desert-overworld", + "x": "700", + "y": "700", + "facing": "right" + }); + let launch = LaunchEnvelope::from_json(&dev, 1000).unwrap(); + assert_eq!( + launch.dev_spawn, + Some(DevSpawn { + area: "open-desert-overworld".into(), + x: "700".into(), + y: "700".into(), + facing: "right".into(), + }) + ); + + let mut hosted = launch_json(); + hosted["devSpawn"] = dev["devSpawn"].clone(); + assert!(matches!( + LaunchEnvelope::from_json(&hosted, 1000), + Err(LaunchError::Invalid(_)) + )); + } + + #[test] + fn launch_rejects_expired_wrong_schema_wrong_purpose_and_replay() { + let mut expired = launch_json(); + expired["expiresAt"] = serde_json::json!(1000); + assert_eq!( + LaunchEnvelope::from_json(&expired, 1000), + Err(LaunchError::Expired) + ); + + let mut schema = launch_json(); + schema["schema"] = serde_json::json!("successor.launch-context.v0"); + assert!(matches!( + LaunchEnvelope::from_json(&schema, 1), + Err(LaunchError::Invalid(_)) + )); + + let mut same_ticket = launch_json(); + same_ticket["chatTicket"] = same_ticket["gameTicket"].clone(); + assert!(matches!( + LaunchEnvelope::from_json(&same_ticket, 1), + Err(LaunchError::Invalid(_)) + )); + + let mut launch = LaunchEnvelope::from_json(&launch_json(), 1).unwrap(); + assert_eq!(launch.consume_game_ticket().unwrap(), "game-ticket"); + assert_eq!(launch.consume_game_ticket(), Err(LaunchError::Replayed)); + assert_eq!(launch.consume_chat_ticket().unwrap(), "chat-ticket"); + assert_eq!(launch.consume_chat_ticket(), Err(LaunchError::Replayed)); + assert!(launch.tickets_cleared()); + } + + #[test] + fn hello_fails_closed_on_source_shard_or_snapshot_mismatch() { + let mut lifecycle = GameLifecycle::default(); + assert_eq!( + lifecycle.validate_hello(&hello(), Some("wrong"), Some("open-desert")), + Err(GameFailure::SourceMismatch) + ); + assert_ne!(lifecycle.state, GameConnection::Connected); + + assert_eq!( + lifecycle.validate_hello(&hello(), Some("source-hash"), Some("wrong-shard")), + Err(GameFailure::SourceMismatch) + ); + let mut missing = hello(); + missing.snapshot.actors.clear(); + assert_eq!( + lifecycle.validate_hello(&missing, None, None), + Err(GameFailure::SnapshotMissing) + ); + } + + #[test] + fn socket_loss_exhausts_bounded_reconnect_and_exit_does_not_retry() { + let mut lifecycle = GameLifecycle { + reconnect: ReconnectPolicy::new(2, 1, 2), + ..GameLifecycle::default() + }; + assert_eq!(lifecycle.socket_lost(), Some(1)); + assert_eq!(lifecycle.socket_lost(), Some(2)); + assert_eq!(lifecycle.socket_lost(), None); + assert_eq!(lifecycle.state, GameConnection::Exhausted); + + lifecycle.intentional_exit(); + assert_eq!(lifecycle.socket_lost(), None); + assert_eq!(lifecycle.state, GameConnection::IntentionalExit); + } +} diff --git a/client-rust/source/app/src/pawn/animator.rs b/client-rust/source/app/src/pawn/animator.rs new file mode 100644 index 00000000..4a33e0b2 --- /dev/null +++ b/client-rust/source/app/src/pawn/animator.rs @@ -0,0 +1,1242 @@ +//! Per-actor pawn animation lane — port of the L0 locomotion contract from +//! `client-3d/src/render/pawns.ts` (state→clip table at lines 9-41, constants at +//! 120-198): idle/walk_f/run_f/walk_b gait selection with start/stop and +//! walk↔run hysteresis, unarmed/rifle/melee clip lanes, and death hold. Drives +//! the `engine-core::anim` mixer + `Skeleton` palette on a `PawnTemplate`. +//! +//! Base locomotion, authored melee draw/sheath transitions, and rifle +//! support-hand IK are evaluated on the shared runtime skeleton. + +use successor_engine_core::anim::{blend_into, JointTransform}; +use successor_engine_core::math::{vec3, Mat4, Quat, Vec3}; + +use super::catalog::SupportArmPosture; +use super::pack::PawnTemplate; + +// pawns.ts constants. +const IDLE_START: f32 = 0.12; // cells/s: above this a stopped pawn starts moving +const IDLE_STOP: f32 = 0.035; // cells/s: below this a moving pawn returns to idle +const WALK_RUN_HYSTERESIS: f32 = 0.12; +const RUN_START: f32 = 3.2; // cells/s: walk→run +const RUN_STOP: f32 = RUN_START - WALK_RUN_HYSTERESIS; +const GAIT_CROSSFADE_SECONDS: f32 = 0.11; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum WeaponLane { + Unarmed, + Rifle, + Melee, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Gait { + Idle, + WalkF, + RunF, + WalkB, + Death, +} + +impl WeaponLane { + /// (idle, walk_f, run_f, walk_b, kneel) clip names for the lane. + fn base_clips(self) -> [&'static str; 5] { + match self { + WeaponLane::Unarmed => ["idle", "walk_f", "run_f", "walk_b", "kneel_loop"], + WeaponLane::Rifle => [ + "rifle_idle", + "rifle_walk_f", + "rifle_run_f", + "walk_b", + "kneel_loop", + ], + WeaponLane::Melee => [ + "melee_idle", + "melee_walk_f", + "melee_run_f", + "walk_b", + "kneel_loop", + ], + } + } +} + +#[derive(Clone, Copy)] +struct RifleArm { + shoulder_parent: usize, + /// Parent of `shoulder_parent`; the frame the girdle swing rotates in. + girdle_parent: Option, + upper: usize, + lower: usize, + hand: usize, + weapon_hand: usize, +} + +pub struct PawnAnimator { + pose: Vec, + base_pose: Vec, + gait_from_pose: Vec, + gait_target_pose: Vec, + palette: Vec<[f32; 16]>, + cycle_phase: f32, + gait_blend_elapsed: f32, + gait_blending: bool, + was_full_body_override: bool, + gait: Gait, + moving: bool, // hysteresis latch for idle start/stop + running: bool, // hysteresis latch for walk/run + rifle_arm: Option, +} + +impl PawnAnimator { + pub fn new(template: &PawnTemplate) -> Self { + PawnAnimator { + pose: template.rest_pose(), + base_pose: template.rest_pose(), + gait_from_pose: template.rest_pose(), + gait_target_pose: template.rest_pose(), + palette: Vec::with_capacity(template.joint_count()), + cycle_phase: 0.0, + gait_blend_elapsed: 0.0, + gait_blending: false, + was_full_body_override: false, + gait: Gait::Idle, + moving: false, + running: false, + rifle_arm: find_rifle_arm(template), + } + } + + /// Update the hysteresis latches and pick the gait for this frame. + pub fn resolve_gait( + &mut self, + speed_cells: f32, + against_facing: bool, + alive: bool, + run_hint: Option, + ) -> Gait { + if !alive { + self.gait = Gait::Death; + return Gait::Death; + } + if self.moving { + if speed_cells < IDLE_STOP { + self.moving = false; + } + } else if speed_cells >= IDLE_START { + self.moving = true; + } + if !self.moving { + self.gait = Gait::Idle; + return Gait::Idle; + } + if against_facing { + self.running = false; + self.gait = Gait::WalkB; + return Gait::WalkB; + } + if let Some(run) = run_hint { + self.running = run; + } else if self.running { + if speed_cells < RUN_STOP { + self.running = false; + } + } else if speed_cells >= RUN_START { + self.running = true; + } + self.gait = if self.running { + Gait::RunF + } else { + Gait::WalkF + }; + self.gait + } + + /// The clip name for the current gait in a lane, with unarmed fallback when + /// the lane-specific clip is absent from the template. + fn clip_for(&self, template: &PawnTemplate, lane: WeaponLane) -> &'static str { + let clips = lane.base_clips(); + let name = match self.gait { + Gait::Idle => clips[0], + Gait::WalkF => clips[1], + Gait::RunF => clips[2], + Gait::WalkB => clips[3], + Gait::Death => "death_f", + }; + if template.animation(name).is_some() { + name + } else { + // Fall back to the unarmed equivalent, else plain idle. + let un = WeaponLane::Unarmed.base_clips(); + let fallback = match self.gait { + Gait::Idle => un[0], + Gait::WalkF => un[1], + Gait::RunF => un[2], + Gait::WalkB => un[3], + Gait::Death => "death_f", + }; + if template.animation(fallback).is_some() { + fallback + } else { + "idle" + } + } + } + + /// Advance the clip and compute the skinning palette for this frame. + pub fn update( + &mut self, + template: &mut PawnTemplate, + lane: WeaponLane, + speed_cells: f32, + against_facing: bool, + alive: bool, + run_hint: Option, + dt_seconds: f32, + ) -> &[[f32; 16]] { + self.update_weapon_transition( + template, + lane, + 1.0, + None, + speed_cells, + against_facing, + alive, + run_hint, + dt_seconds, + ) + } + + /// Blend unarmed/armed locomotion while a weapon moves between its hand + /// and authored stow socket. Melee draw/sheath clips take precedence while + /// active so the prop transfer and body motion share one normalized phase. + #[allow(clippy::too_many_arguments)] + pub fn update_weapon_transition( + &mut self, + template: &mut PawnTemplate, + lane: WeaponLane, + armed_blend: f32, + transition: Option<(&str, f32)>, + speed_cells: f32, + against_facing: bool, + alive: bool, + run_hint: Option, + dt_seconds: f32, + ) -> &[[f32; 16]] { + let previous_gait = self.gait; + self.resolve_gait(speed_cells, against_facing, alive, run_hint); + let armed_clip = self.clip_for(template, lane); + let unarmed_clip = self.clip_for(template, WeaponLane::Unarmed); + let armed_duration = template + .animation(armed_clip) + .map(|a| a.duration.max(0.001)) + .unwrap_or(1.0); + + if self.gait == Gait::Death { + self.cycle_phase = 1.0; + self.gait_blending = false; + self.was_full_body_override = false; + template.pose_at(armed_clip, armed_duration, &mut self.pose); + self.base_pose.copy_from_slice(&self.pose); + } else if let Some((clip, transition_phase)) = + transition.filter(|(clip, _)| template.animation(clip).is_some()) + { + let duration = template + .animation(clip) + .map(|a| a.duration.max(0.001)) + .unwrap_or(1.0); + template.pose_at( + clip, + transition_phase.clamp(0.0, 1.0) * duration, + &mut self.pose, + ); + self.base_pose.copy_from_slice(&self.pose); + self.was_full_body_override = true; + } else { + let nominal = match self.gait { + Gait::RunF => 3.5, + Gait::WalkF | Gait::WalkB => 1.4, + _ => 1.0, + }; + let time_scale = if self.gait == Gait::Idle { + 1.0 + } else { + (speed_cells / nominal).clamp(0.5, 1.6) + }; + self.cycle_phase = + (self.cycle_phase + dt_seconds.max(0.0) * time_scale / armed_duration) % 1.0; + + if previous_gait != self.gait || self.was_full_body_override { + self.gait_from_pose.copy_from_slice(&self.base_pose); + self.gait_blend_elapsed = 0.0; + self.gait_blending = true; + self.was_full_body_override = false; + } + + let unarmed_duration = template + .animation(unarmed_clip) + .map(|a| a.duration.max(0.001)) + .unwrap_or(1.0); + template.pose_at( + unarmed_clip, + self.cycle_phase * unarmed_duration, + &mut self.gait_target_pose, + ); + let armed_weight = armed_blend.clamp(0.0, 1.0); + if armed_weight > 0.0 { + template.pose_at( + armed_clip, + self.cycle_phase * armed_duration, + &mut self.pose, + ); + blend_into(&mut self.gait_target_pose, &self.pose, armed_weight, None); + } + + if self.gait_blending { + self.gait_blend_elapsed = + (self.gait_blend_elapsed + dt_seconds.max(0.0)).min(GAIT_CROSSFADE_SECONDS); + self.pose.copy_from_slice(&self.gait_from_pose); + let weight = (self.gait_blend_elapsed / GAIT_CROSSFADE_SECONDS).clamp(0.0, 1.0); + blend_into(&mut self.pose, &self.gait_target_pose, weight, None); + if weight >= 1.0 { + self.gait_blending = false; + } + } else { + self.pose.copy_from_slice(&self.gait_target_pose); + } + self.base_pose.copy_from_slice(&self.pose); + } + + template + .skeleton + .compute_palette(&self.pose, &mut self.palette); + &self.palette + } + + /// Pull the support wrist onto the live rifle foregrip after base-clip + /// sampling. The authored right-hand weld remains untouched. + pub fn apply_rifle_support_ik( + &mut self, + template: &mut PawnTemplate, + mount: Mat4, + foregrip: Vec3, + ) { + self.apply_rifle_support_ik_weighted( + template, + mount, + foregrip, + vec3(0.0, -0.02, -0.055), + 1.0, + None, + ); + } + + /// `posture` is the model's authored `hold.support_arm` block. Absent, this + /// is the legacy solve: the wrist is dragged onto the target and, when the + /// target sits past the arm's own reach, the elbow collapses onto the + /// shoulder->wrist line. Present, it holds a floor under the elbow bend, + /// pays for that bend with a bounded clavicle swing, and rolls the elbow to + /// the authored pole. Same contract and same numbers as + /// `client-3d/src/render/anim/twoBoneIk.ts`. + pub fn apply_rifle_support_ik_weighted( + &mut self, + template: &mut PawnTemplate, + mount: Mat4, + foregrip: Vec3, + foregrip_contact: Vec3, + weight: f32, + posture: Option, + ) { + let weight = weight.clamp(0.0, 1.0); + if weight <= 0.0 { + return; + } + let Some(arm) = self.rifle_arm else { + return; + }; + + let target = template + .skeleton + .bone_global(arm.weapon_hand) + .mul(mount) + .transform_point(foregrip.add(foregrip_contact)); + let shoulder = template + .skeleton + .bone_global(arm.upper) + .transform_point(Vec3::ZERO); + let elbow = template + .skeleton + .bone_global(arm.lower) + .transform_point(Vec3::ZERO); + let wrist = template + .skeleton + .bone_global(arm.hand) + .transform_point(Vec3::ZERO); + let mut upper_vec = elbow.sub(shoulder); + let mut lower_vec = wrist.sub(elbow); + let upper_len = upper_vec.length(); + let lower_len = lower_vec.length(); + if upper_len < 1.0e-5 || lower_len < 1.0e-5 { + return; + } + + // Longest wrist reach this solve may ask for. Legacy: a hair short of a + // locked-straight arm. Under a hold posture: the chord that still + // leaves `min_bend_rad` at the elbow, so the limb can never collapse + // onto its own axis and lie along the weapon. + let mut shoulder = shoulder; + let mut reach_limit = upper_len + lower_len - 1.0e-5; + if let Some(hold) = posture { + let interior = core::f32::consts::PI - hold.min_bend_rad * weight; + let bend_limit = (upper_len * upper_len + lower_len * lower_len + - 2.0 * upper_len * lower_len * interior.cos()) + .max(0.0) + .sqrt(); + reach_limit = reach_limit.min(bend_limit); + if self.advance_shoulder(template, arm, target, reach_limit, hold, weight) { + shoulder = template + .skeleton + .bone_global(arm.upper) + .transform_point(Vec3::ZERO); + let elbow = template + .skeleton + .bone_global(arm.lower) + .transform_point(Vec3::ZERO); + let wrist = template + .skeleton + .bone_global(arm.hand) + .transform_point(Vec3::ZERO); + upper_vec = elbow.sub(shoulder); + lower_vec = wrist.sub(elbow); + } + } + + let to_target = target.sub(shoulder); + let reach = to_target + .length() + .clamp((upper_len - lower_len).abs() + 1.0e-5, reach_limit); + let cos_desired = ((upper_len * upper_len + lower_len * lower_len - reach * reach) + / (2.0 * upper_len * lower_len)) + .clamp(-1.0, 1.0); + let interior_desired = cos_desired.acos(); + let cos_current = (-upper_vec.dot(lower_vec) / (upper_len * lower_len)).clamp(-1.0, 1.0); + let interior_current = cos_current.acos(); + let mut hinge = upper_vec.cross(lower_vec); + if hinge.dot(hinge) < 1.0e-5 { + hinge = Vec3::Y.cross(upper_vec); + if hinge.dot(hinge) < 1.0e-5 { + hinge = vec3(1.0, 0.0, 0.0).cross(upper_vec); + } + } + let elbow_delta = interior_desired - interior_current; + if elbow_delta.abs() > 1.0e-5 { + let rotation = Quat::from_axis_angle(hinge.normalize(), -elbow_delta * weight); + let upper_global = template.skeleton.bone_global(arm.upper); + apply_world_rotation(&mut self.pose[arm.lower], upper_global, rotation); + template + .skeleton + .compute_palette(&self.pose, &mut self.palette); + } + + let solved_wrist = template + .skeleton + .bone_global(arm.hand) + .transform_point(Vec3::ZERO); + let from_dir = solved_wrist.sub(shoulder).normalize(); + let to_dir = to_target.normalize(); + if from_dir.dot(from_dir) > 1.0e-5 && to_dir.dot(to_dir) > 1.0e-5 { + let rotation = quat_weight(quat_between(from_dir, to_dir), weight); + let parent_global = template.skeleton.bone_global(arm.shoulder_parent); + apply_world_rotation(&mut self.pose[arm.upper], parent_global, rotation); + template + .skeleton + .compute_palette(&self.pose, &mut self.palette); + } + + if let Some(hold) = posture { + self.roll_elbow(template, arm, hold.pole_rad, weight); + } + } + + /// Swing the shoulder girdle toward the target so a retained elbow bend is + /// paid for in scapular travel instead of a hand that stops short of its + /// contact. Inert whenever the target already sits inside the bent-arm + /// reach, which is every pose of every weapon whose support socket is + /// authored within the arm. Returns true when the girdle moved. + fn advance_shoulder( + &mut self, + template: &mut PawnTemplate, + arm: RifleArm, + target: Vec3, + reach_limit: f32, + hold: SupportArmPosture, + weight: f32, + ) -> bool { + if hold.shoulder_advance_max_m <= 1.0e-5 { + return false; + } + let Some(girdle_parent) = arm.girdle_parent else { + return false; + }; + let shoulder = template + .skeleton + .bone_global(arm.upper) + .transform_point(Vec3::ZERO); + let to_target = target.sub(shoulder); + let deficit = to_target.length() - reach_limit; + if deficit <= 1.0e-5 { + return false; + } + let root = template + .skeleton + .bone_global(arm.shoulder_parent) + .transform_point(Vec3::ZERO); + let girdle = shoulder.sub(root); + let girdle_len = girdle.length(); + if girdle_len < 1.0e-5 { + return false; + } + // Rotating about girdle x to_target carries the shoulder along the arc + // toward the target; chord = 2 R sin(angle / 2). + let axis = girdle.cross(to_target); + if axis.dot(axis) < 1.0e-5 { + return false; + } + let chord = (hold.shoulder_advance_max_m * weight).min(deficit); + let angle = 2.0 * (chord / (2.0 * girdle_len)).min(1.0).asin(); + let rotation = Quat::from_axis_angle(axis.normalize(), angle); + let parent_global = template.skeleton.bone_global(girdle_parent); + apply_world_rotation(&mut self.pose[arm.shoulder_parent], parent_global, rotation); + template + .skeleton + .compute_palette(&self.pose, &mut self.palette); + true + } + + /// Roll the whole arm about the shoulder->wrist axis until the elbow sits + /// at the authored pole angle (from world-down, positive toward + /// axis x down). The wrist lies ON that axis, so the contact never moves. + fn roll_elbow( + &mut self, + template: &mut PawnTemplate, + arm: RifleArm, + pole_rad: f32, + weight: f32, + ) { + let shoulder = template + .skeleton + .bone_global(arm.upper) + .transform_point(Vec3::ZERO); + let elbow = template + .skeleton + .bone_global(arm.lower) + .transform_point(Vec3::ZERO); + let wrist = template + .skeleton + .bone_global(arm.hand) + .transform_point(Vec3::ZERO); + let axis = wrist.sub(shoulder); + if axis.dot(axis) < 1.0e-5 { + return; + } + let axis = axis.normalize(); + // World-down with its along-axis component removed: the pole's zero. + let reference = vec3(0.0, -1.0, 0.0).add(axis.scale(axis.y)); + if reference.dot(reference) < 1.0e-6 { + return; // arm points straight up or down + } + let reference = reference.normalize(); + let side = axis.cross(reference); + let radial = elbow.sub(shoulder); + let radial = radial.sub(axis.scale(radial.dot(axis))); + if radial.dot(radial) < 1.0e-6 { + return; // straight arm: no pole plane to aim + } + let mut delta = pole_rad - radial.dot(side).atan2(radial.dot(reference)); + if delta > core::f32::consts::PI { + delta -= core::f32::consts::TAU; + } else if delta < -core::f32::consts::PI { + delta += core::f32::consts::TAU; + } + delta *= weight; + if delta.abs() < 1.0e-5 { + return; + } + let rotation = Quat::from_axis_angle(axis, delta); + let parent_global = template.skeleton.bone_global(arm.shoulder_parent); + apply_world_rotation(&mut self.pose[arm.upper], parent_global, rotation); + template + .skeleton + .compute_palette(&self.pose, &mut self.palette); + } + + pub fn palette(&self) -> &[[f32; 16]] { + &self.palette + } +} + +pub(crate) fn weapon_hand_bone(template: &PawnTemplate) -> Option { + template + .skeleton + .find_bone("hand_r") + .or_else(|| template.skeleton.find_bone("RightHand")) +} + +fn find_rifle_arm(template: &PawnTemplate) -> Option { + let shoulder_parent = template.skeleton.find_bone("clavicle_l")?; + Some(RifleArm { + shoulder_parent, + girdle_parent: template + .skeleton + .parent + .get(shoulder_parent) + .copied() + .flatten(), + upper: template.skeleton.find_bone("upperarm_l")?, + lower: template.skeleton.find_bone("lowerarm_l")?, + hand: template.skeleton.find_bone("hand_l")?, + weapon_hand: weapon_hand_bone(template)?, + }) +} + +fn quat_conjugate(q: Quat) -> Quat { + Quat { + x: -q.x, + y: -q.y, + z: -q.z, + w: q.w, + } +} + +fn apply_world_rotation(pose: &mut JointTransform, parent_global: Mat4, rotation: Quat) { + let (_, parent_rotation, _) = parent_global.to_trs(); + let local_rotation = quat_conjugate(parent_rotation) + .mul(rotation) + .mul(parent_rotation); + pose.r = local_rotation.mul(pose.r).normalize(); +} + +fn quat_weight(mut rotation: Quat, weight: f32) -> Quat { + if rotation.w < 0.0 { + rotation = Quat { + x: -rotation.x, + y: -rotation.y, + z: -rotation.z, + w: -rotation.w, + }; + } + Quat { + x: rotation.x * weight, + y: rotation.y * weight, + z: rotation.z * weight, + w: 1.0 + (rotation.w - 1.0) * weight, + } + .normalize() +} + +fn quat_between(from: Vec3, to: Vec3) -> Quat { + let dot = from.dot(to).clamp(-1.0, 1.0); + if dot < -0.999_999 { + let mut axis = Vec3::Y.cross(from); + if axis.dot(axis) < 1.0e-5 { + axis = vec3(1.0, 0.0, 0.0).cross(from); + } + return Quat::from_axis_angle(axis.normalize(), core::f32::consts::PI); + } + let cross = from.cross(to); + Quat { + x: cross.x, + y: cross.y, + z: cross.z, + w: 1.0 + dot, + } + .normalize() +} + +#[cfg(test)] +mod tests { + use super::*; + + // Gait selection is independent of the template; test it directly. + fn anim() -> PawnAnimator { + PawnAnimator { + pose: Vec::new(), + base_pose: Vec::new(), + gait_from_pose: Vec::new(), + gait_target_pose: Vec::new(), + palette: Vec::new(), + cycle_phase: 0.0, + gait_blend_elapsed: 0.0, + gait_blending: false, + was_full_body_override: false, + gait: Gait::Idle, + moving: false, + running: false, + rifle_arm: None, + } + } + + #[test] + fn idle_below_start_walks_above() { + let mut a = anim(); + assert_eq!(a.resolve_gait(0.05, false, true, None), Gait::Idle); + assert_eq!(a.resolve_gait(0.5, false, true, None), Gait::WalkF); + } + + #[test] + fn idle_hysteresis_holds_moving_until_stop_threshold() { + let mut a = anim(); + a.resolve_gait(1.0, false, true, None); // moving + // Between stop (0.035) and start (0.12): stays moving (walk). + assert_eq!(a.resolve_gait(0.08, false, true, None), Gait::WalkF); + // Below stop: idle. + assert_eq!(a.resolve_gait(0.01, false, true, None), Gait::Idle); + } + + #[test] + fn run_above_threshold_with_hysteresis() { + let mut a = anim(); + assert_eq!(a.resolve_gait(1.5, false, true, None), Gait::WalkF); + assert_eq!(a.resolve_gait(3.3, false, true, None), Gait::RunF); + // Between run stop (3.08) and start (3.2): stays running. + assert_eq!(a.resolve_gait(3.15, false, true, None), Gait::RunF); + assert_eq!(a.resolve_gait(3.0, false, true, None), Gait::WalkF); + } + + #[test] + fn backpedal_and_death() { + let mut a = anim(); + assert_eq!(a.resolve_gait(1.0, true, true, None), Gait::WalkB); + assert_eq!(a.resolve_gait(1.0, false, false, None), Gait::Death); + } + + #[test] + fn lane_clip_names() { + assert_eq!(WeaponLane::Rifle.base_clips()[0], "rifle_idle"); + assert_eq!(WeaponLane::Melee.base_clips()[2], "melee_run_f"); + assert_eq!(WeaponLane::Unarmed.base_clips()[1], "walk_f"); + } + + #[test] + fn melee_sheath_transition_samples_authored_clip_phase() { + let body = std::fs::read("../../../client-3d/public/assets/pawn-pack/pawn_male.glb") + .expect("checked-in pawn body"); + let mut template = PawnTemplate::from_bytes(&body).expect("pawn template"); + let duration = template + .animation("melee_sheath") + .expect("authored sheath clip") + .duration; + let mut expected = template.rest_pose(); + template.pose_at("melee_sheath", duration * 0.5, &mut expected); + + let mut animator = PawnAnimator::new(&template); + animator.update_weapon_transition( + &mut template, + WeaponLane::Melee, + 0.5, + Some(("melee_sheath", 0.5)), + 0.0, + false, + true, + None, + 0.0, + ); + assert_eq!(animator.pose, expected); + } + + #[test] + fn rifle_support_wrist_reaches_authored_foregrip() { + let body = std::fs::read("../../../client-3d/public/assets/pawn-pack/pawn_male.glb") + .expect("checked-in pawn body"); + let attach = + std::fs::read("../../../client-3d/public/assets/pawn-pack/slugthrower_attach.json") + .expect("checked-in rifle attachment"); + let hand_spec = + crate::pawn::catalog::parse_weapon_hand_spec(&attach).expect("rifle hand spec"); + let mut template = PawnTemplate::from_bytes(&body).expect("pawn template"); + let mut animator = PawnAnimator::new(&template); + animator.update( + &mut template, + WeaponLane::Rifle, + 0.0, + false, + true, + None, + 0.0, + ); + animator.apply_rifle_support_ik(&mut template, hand_spec.mount, hand_spec.foregrip); + + let arm = animator.rifle_arm.expect("rifle arm bones"); + assert_eq!( + arm.weapon_hand, + template.skeleton.find_bone("hand_r").expect("right hand") + ); + assert_ne!( + arm.weapon_hand, + template + .skeleton + .find_bone("hand") + .expect("generic match resolves first hand") + ); + let target = template + .skeleton + .bone_global(arm.weapon_hand) + .mul(hand_spec.mount) + .transform_point(hand_spec.foregrip.add(vec3(0.0, -0.02, -0.055))); + let wrist = template + .skeleton + .bone_global(arm.hand) + .transform_point(Vec3::ZERO); + assert!( + wrist.sub(target).length() < 1.0e-3, + "support wrist missed foregrip target by {} m", + wrist.sub(target).length() + ); + } + + /// The two far-forward modular guns author `hold.support_arm` because their + /// support contact sits past the arm (0.586-0.658 m against 0.584 m of + /// upperarm+lowerarm), which made this solver clamp to full extension and + /// lay the support arm along the weapon as a rod. + fn support_arm_fixture() -> Option<( + PawnTemplate, + PawnAnimator, + crate::pawn::catalog::WeaponHandSpec, + )> { + let body = + std::fs::read("../../../client-3d/public/assets/pawn-pack/pawn_male.glb").ok()?; + let attach = std::fs::read( + "../../../client-3d/public/assets/pawn-pack/weapons/custom/wpn_launcher_flare_net_attach.json", + ) + .ok()?; + let hand_spec = crate::pawn::catalog::parse_weapon_hand_spec(&attach)?; + let template = PawnTemplate::from_bytes(&body).ok()?; + let animator = PawnAnimator::new(&template); + Some((template, animator, hand_spec)) + } + + fn elbow_off_axis(template: &PawnTemplate, arm: RifleArm) -> f32 { + let shoulder = template + .skeleton + .bone_global(arm.upper) + .transform_point(Vec3::ZERO); + let elbow = template + .skeleton + .bone_global(arm.lower) + .transform_point(Vec3::ZERO); + let wrist = template + .skeleton + .bone_global(arm.hand) + .transform_point(Vec3::ZERO); + let axis = wrist.sub(shoulder); + if axis.length() < 1.0e-9 { + return 0.0; + } + let axis = axis.normalize(); + let to_elbow = elbow.sub(shoulder); + to_elbow.sub(axis.scale(to_elbow.dot(axis))).length() + } + + #[test] + fn support_arm_block_parses_with_the_browser_clamps() { + let attach = std::fs::read( + "../../../client-3d/public/assets/pawn-pack/weapons/custom/wpn_launcher_flare_net_attach.json", + ) + .expect("checked-in launcher attachment"); + let hand_spec = + crate::pawn::catalog::parse_weapon_hand_spec(&attach).expect("launcher hand spec"); + let posture = hand_spec + .support_arm + .expect("launcher authors a hold posture"); + assert!((posture.min_bend_rad - 34.0_f32.to_radians()).abs() < 1.0e-6); + assert!((posture.pole_rad - 56.0_f32.to_radians()).abs() < 1.0e-6); + assert!((posture.shoulder_advance_max_m - 0.05).abs() < 1.0e-6); + + // Half a block is not a posture: it is ignored, not half-applied. + let partial = br#"{"mount_hand_r_local":{"pos":[0,0,0],"quat":[0,0,0,1]}, + "sockets":{"grip":[0,0,0],"foregrip":[0,0,0.1],"muzzle":[0,0,0.3]}, + "nodes":{"frame":"f"},"hold":{"support_arm":{"min_elbow_bend_deg":34}}}"#; + assert!(crate::pawn::catalog::parse_weapon_hand_spec(partial) + .expect("spec parses") + .support_arm + .is_none()); + + // Absurd authoring is clamped to anatomy, never propagated raw. + let wild = br#"{"mount_hand_r_local":{"pos":[0,0,0],"quat":[0,0,0,1]}, + "sockets":{"grip":[0,0,0],"foregrip":[0,0,0.1],"muzzle":[0,0,0.3]}, + "nodes":{"frame":"f"},"hold":{"support_arm":{"min_elbow_bend_deg":400, + "shoulder_advance_max_m":9.0,"elbow_pole_deg":56}}}"#; + let clamped = crate::pawn::catalog::parse_weapon_hand_spec(wild) + .expect("spec parses") + .support_arm + .expect("posture present"); + assert!((clamped.min_bend_rad - 80.0_f32.to_radians()).abs() < 1.0e-6); + assert!((clamped.shoulder_advance_max_m - 0.12).abs() < 1.0e-6); + } + + #[test] + fn support_arm_posture_bends_the_elbow_without_moving_the_wrist() { + let Some((mut template, mut animator, hand_spec)) = support_arm_fixture() else { + return; + }; + let posture = hand_spec + .support_arm + .expect("launcher authors a hold posture"); + + animator.update( + &mut template, + WeaponLane::Rifle, + 0.0, + false, + true, + None, + 0.0, + ); + animator.apply_rifle_support_ik_weighted( + &mut template, + hand_spec.mount, + hand_spec.foregrip, + hand_spec.foregrip_contact, + 1.0, + None, + ); + let arm = animator.rifle_arm.expect("rifle arm bones"); + let legacy_off_axis = elbow_off_axis(&template, arm); + let legacy_wrist = template + .skeleton + .bone_global(arm.hand) + .transform_point(Vec3::ZERO); + let legacy_shoulder = template + .skeleton + .bone_global(arm.upper) + .transform_point(Vec3::ZERO); + // The defect: the target is past the arm, so the elbow collapses. + assert!( + legacy_off_axis < 0.005, + "expected a collapsed legacy elbow, got {legacy_off_axis} m off axis" + ); + + animator.update( + &mut template, + WeaponLane::Rifle, + 0.0, + false, + true, + None, + 0.0, + ); + animator.apply_rifle_support_ik_weighted( + &mut template, + hand_spec.mount, + hand_spec.foregrip, + hand_spec.foregrip_contact, + 1.0, + Some(posture), + ); + let posed_off_axis = elbow_off_axis(&template, arm); + let posed_shoulder = template + .skeleton + .bone_global(arm.upper) + .transform_point(Vec3::ZERO); + assert!( + posed_off_axis > 0.06, + "posture must restore a real elbow bend, got {posed_off_axis} m off axis" + ); + // The girdle pays for the bend, bounded by the authored cap. + let advance = posed_shoulder.sub(legacy_shoulder).length(); + assert!( + advance > 0.04 && advance <= posture.shoulder_advance_max_m + 1.0e-4, + "girdle advance {advance} m outside the authored cap" + ); + // The support hand may not be traded away for the elbow: it still ends + // no further from its contact than the locked-straight arm managed. + let target = template + .skeleton + .bone_global(arm.weapon_hand) + .mul(hand_spec.mount) + .transform_point(hand_spec.foregrip.add(hand_spec.foregrip_contact)); + let posed_wrist = template + .skeleton + .bone_global(arm.hand) + .transform_point(Vec3::ZERO); + assert!( + posed_wrist.sub(target).length() <= legacy_wrist.sub(target).length() + 1.0e-4, + "posture pulled the support hand off its contact" + ); + } + + #[test] + fn posture_leaves_a_reachable_contact_alone() { + let Some((mut template, mut animator, hand_spec)) = support_arm_fixture() else { + return; + }; + let posture = hand_spec + .support_arm + .expect("launcher authors a hold posture"); + animator.update( + &mut template, + WeaponLane::Rifle, + 0.0, + false, + true, + None, + 0.0, + ); + let arm = animator.rifle_arm.expect("rifle arm bones"); + // A contact 0.3 m below the support shoulder is deep inside the arm's + // 0.584 m: this is every pose of every weapon whose support socket was + // authored within reach, and the girdle must stay out of it. + let shoulder = template + .skeleton + .bone_global(arm.upper) + .transform_point(Vec3::ZERO); + let reachable = vec3(shoulder.x, shoulder.y - 0.3, shoulder.z); + let mount = template + .skeleton + .bone_global(arm.weapon_hand) + .inverse() + .mul(Mat4::from_trs(reachable, Quat::IDENTITY, Vec3::ONE)); + + animator.apply_rifle_support_ik_weighted( + &mut template, + mount, + Vec3::ZERO, + Vec3::ZERO, + 1.0, + None, + ); + let girdle = animator.pose[arm.shoulder_parent].r; + let bare_shoulder = template + .skeleton + .bone_global(arm.upper) + .transform_point(Vec3::ZERO); + + animator.update( + &mut template, + WeaponLane::Rifle, + 0.0, + false, + true, + None, + 0.0, + ); + animator.apply_rifle_support_ik_weighted( + &mut template, + mount, + Vec3::ZERO, + Vec3::ZERO, + 1.0, + Some(posture), + ); + let posed_girdle = animator.pose[arm.shoulder_parent].r; + let posed_shoulder = template + .skeleton + .bone_global(arm.upper) + .transform_point(Vec3::ZERO); + assert!( + girdle.mul(quat_conjugate(posed_girdle)).w.abs() > 1.0 - 1.0e-6, + "girdle must not swing for a contact the arm already reaches" + ); + assert!(posed_shoulder.sub(bare_shoulder).length() < 1.0e-6); + } + fn pawn_template() -> PawnTemplate { + let bytes = std::fs::read("../../../client-3d/public/assets/pawn-pack/pawn_male.glb") + .expect("checked-in pawn body"); + PawnTemplate::from_bytes(&bytes).expect("pawn template") + } + + fn pose_delta(a: &[JointTransform], b: &[JointTransform]) -> f32 { + a.iter() + .zip(b) + .map(|(a, b)| { + (a.t.x - b.t.x).abs() + + (a.t.y - b.t.y).abs() + + (a.t.z - b.t.z).abs() + + (a.r.x - b.r.x).abs() + + (a.r.y - b.r.y).abs() + + (a.r.z - b.r.z).abs() + + (a.r.w - b.r.w).abs() + + (a.s.x - b.s.x).abs() + + (a.s.y - b.s.y).abs() + + (a.s.z - b.s.z).abs() + }) + .sum() + } + + #[test] + fn local_run_hint_overrides_oscillating_measured_speed() { + let mut animator = anim(); + assert_eq!( + animator.resolve_gait(3.3, false, true, Some(false)), + Gait::WalkF + ); + assert_eq!( + animator.resolve_gait(3.0, false, true, Some(false)), + Gait::WalkF + ); + assert_eq!( + animator.resolve_gait(3.3, false, true, Some(true)), + Gait::RunF + ); + assert_eq!( + animator.resolve_gait(3.0, false, true, Some(true)), + Gait::RunF + ); + } + + #[test] + fn gait_crossfade_preserves_endpoints_and_interruption_pose() { + let mut template = pawn_template(); + let mut animator = PawnAnimator::new(&template); + animator.update( + &mut template, + WeaponLane::Unarmed, + 1.4, + false, + true, + Some(false), + GAIT_CROSSFADE_SECONDS, + ); + let outgoing = animator.base_pose.clone(); + animator.update( + &mut template, + WeaponLane::Unarmed, + 3.5, + false, + true, + Some(true), + 0.0, + ); + assert_eq!(animator.base_pose, outgoing, "zero-time frame holds source"); + animator.update( + &mut template, + WeaponLane::Unarmed, + 3.5, + false, + true, + Some(true), + GAIT_CROSSFADE_SECONDS * 0.5, + ); + let midpoint = animator.base_pose.clone(); + assert!(pose_delta(&outgoing, &midpoint) > 1.0e-4); + animator.update( + &mut template, + WeaponLane::Unarmed, + 3.5, + false, + true, + Some(false), + 0.0, + ); + assert_eq!( + animator.base_pose, midpoint, + "interrupted fade snapshots the displayed pose" + ); + animator.update( + &mut template, + WeaponLane::Unarmed, + 1.4, + false, + true, + Some(false), + GAIT_CROSSFADE_SECONDS, + ); + assert!(!animator.gait_blending); + } + + fn transitioned_pose(hz: u32) -> Vec { + let mut template = pawn_template(); + let mut animator = PawnAnimator::new(&template); + animator.update( + &mut template, + WeaponLane::Unarmed, + 1.4, + false, + true, + Some(false), + GAIT_CROSSFADE_SECONDS, + ); + animator.update( + &mut template, + WeaponLane::Unarmed, + 3.5, + false, + true, + Some(true), + 0.0, + ); + let step = 1.0 / hz as f32; + let mut elapsed = 0.0; + while elapsed < GAIT_CROSSFADE_SECONDS { + let dt = (GAIT_CROSSFADE_SECONDS - elapsed).min(step); + animator.update( + &mut template, + WeaponLane::Unarmed, + 3.5, + false, + true, + Some(true), + dt, + ); + elapsed += dt; + } + animator.base_pose + } + + #[test] + fn gait_crossfade_is_frame_rate_independent() { + assert!(pose_delta(&transitioned_pose(30), &transitioned_pose(120)) < 1.0e-4); + } + + #[test] + fn authored_override_fades_out_and_death_holds_final_frame() { + let mut template = pawn_template(); + let mut animator = PawnAnimator::new(&template); + animator.update_weapon_transition( + &mut template, + WeaponLane::Melee, + 1.0, + Some(("melee_draw", 0.6)), + 1.4, + false, + true, + Some(false), + 0.0, + ); + let authored = animator.base_pose.clone(); + animator.update_weapon_transition( + &mut template, + WeaponLane::Melee, + 1.0, + None, + 1.4, + false, + true, + Some(false), + 0.0, + ); + assert_eq!(animator.base_pose, authored); + assert!(animator.gait_blending); + + animator.update( + &mut template, + WeaponLane::Unarmed, + 0.0, + false, + false, + None, + 0.0, + ); + let death = animator.base_pose.clone(); + animator.update( + &mut template, + WeaponLane::Unarmed, + 0.0, + false, + false, + None, + 1.0, + ); + assert_eq!(animator.base_pose, death); + assert!(!animator.gait_blending); + } +} diff --git a/client-rust/source/app/src/pawn/appearance.rs b/client-rust/source/app/src/pawn/appearance.rs new file mode 100644 index 00000000..86e64c44 --- /dev/null +++ b/client-rust/source/app/src/pawn/appearance.rs @@ -0,0 +1,122 @@ +//! Pawn appearance + weapon derivation — port of the presentation rules in +//! `client-3d/src/render/pawns.ts`: skin-tone tint (default `#cc9978`), a subtle +//! faction/relation body tint (lerp 0.3 toward the faction colour), and weapon +//! lane routing from the equipped weapon id. Pure; unit-tested. + +use super::animator::WeaponLane; + +/// Default skin tone (`pawns.ts` `defaultSkinColor`). +pub const DEFAULT_SKIN: [f32; 3] = [0.8, 0.6, 0.47]; // ~#cc9978 + +/// Parse `#rrggbb` → linear-ish 0..1 rgb; falls back to default skin. +pub fn parse_hex_rgb(s: &str) -> [f32; 3] { + let h = s.trim().trim_start_matches('#'); + if h.len() >= 6 { + let r = u8::from_str_radix(&h[0..2], 16); + let g = u8::from_str_radix(&h[2..4], 16); + let b = u8::from_str_radix(&h[4..6], 16); + if let (Ok(r), Ok(g), Ok(b)) = (r, g, b) { + return [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]; + } + } + DEFAULT_SKIN +} + +/// Body tint from an optional skin-tone hex (validated `#[0-9a-f]{6}`). +pub fn skin_tint(skin_tone: Option<&str>) -> [f32; 4] { + let rgb = match skin_tone { + Some(s) if is_hex6(s) => parse_hex_rgb(s), + _ => DEFAULT_SKIN, + }; + [rgb[0], rgb[1], rgb[2], 1.0] +} + +/// Blend a base body colour 30% toward a faction colour (`pawns.ts` decision: +/// "subtle body tint, lerp 0.3 into the matcap colour"). +pub fn faction_tinted(base: [f32; 4], faction: Option<[f32; 3]>) -> [f32; 4] { + match faction { + Some(f) => [ + base[0] + (f[0] - base[0]) * 0.3, + base[1] + (f[1] - base[1]) * 0.3, + base[2] + (f[2] - base[2]) * 0.3, + base[3], + ], + None => base, + } +} + +/// Route an equipped weapon id to an animation lane. Rifle-class ids +/// (slugthrower / rifle / gun) → Rifle; blade-class (sword / vibro / blade / +/// melee) → Melee; otherwise Unarmed. +pub fn weapon_lane(weapon_id: Option<&str>) -> WeaponLane { + let Some(id) = weapon_id else { + return WeaponLane::Unarmed; + }; + let id = id.to_ascii_lowercase(); + if id.contains("slug") + || id.contains("rifle") + || id.contains("gun") + || id.contains("scrap_rifle") + { + WeaponLane::Rifle + } else if id.contains("sword") + || id.contains("vibro") + || id.contains("blade") + || id.contains("melee") + { + WeaponLane::Melee + } else { + WeaponLane::Unarmed + } +} + +fn is_hex6(s: &str) -> bool { + let h = s.trim().trim_start_matches('#'); + h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_skin_hex() { + let t = skin_tint(Some("#cc9978")); + assert!( + (t[0] - 0.8).abs() < 0.01 && (t[1] - 0.6).abs() < 0.01 && (t[2] - 0.47).abs() < 0.02 + ); + assert_eq!(t[3], 1.0); + } + + #[test] + fn invalid_skin_falls_back() { + assert_eq!( + skin_tint(Some("not-a-color")), + [DEFAULT_SKIN[0], DEFAULT_SKIN[1], DEFAULT_SKIN[2], 1.0] + ); + assert_eq!( + skin_tint(None), + [DEFAULT_SKIN[0], DEFAULT_SKIN[1], DEFAULT_SKIN[2], 1.0] + ); + } + + #[test] + fn faction_tint_lerps_30_percent() { + let out = faction_tinted([0.0, 0.0, 0.0, 1.0], Some([1.0, 1.0, 1.0])); + assert!((out[0] - 0.3).abs() < 1e-6); + assert_eq!( + faction_tinted([0.5, 0.5, 0.5, 1.0], None), + [0.5, 0.5, 0.5, 1.0] + ); + } + + #[test] + fn weapon_lanes() { + assert_eq!(weapon_lane(Some("slugthrower")), WeaponLane::Rifle); + assert_eq!(weapon_lane(Some("weapon_scrap_rifle")), WeaponLane::Rifle); + assert_eq!(weapon_lane(Some("vibrosword")), WeaponLane::Melee); + assert_eq!(weapon_lane(Some("plasma_blade")), WeaponLane::Melee); + assert_eq!(weapon_lane(None), WeaponLane::Unarmed); + assert_eq!(weapon_lane(Some("field_bandage")), WeaponLane::Unarmed); + } +} diff --git a/client-rust/source/app/src/pawn/catalog.rs b/client-rust/source/app/src/pawn/catalog.rs new file mode 100644 index 00000000..b6afc604 --- /dev/null +++ b/client-rust/source/app/src/pawn/catalog.rs @@ -0,0 +1,1523 @@ +//! Pawn asset catalog: body routing (player/NPC humanoids, special +//! humanoids, creatures), the loaded template registry, worn-equipment +//! resolution, and weapon rig models — the Rust port of the asset side of +//! `client-3d/src/render/pawns.ts` (`pawnBodyForActor`, +//! `specialPawnBodyKeyForActor`, `CREATURE_SPECIES_BY_SPRITE`, +//! `defaultRemotePawnEquipmentIds`) over the platform asset reader. +//! +//! Degradation contract: `pawn_male` / `pawn_female` are REQUIRED — a missing +//! body fails catalog construction (world entry stops at Fatal upstream). +//! Special bodies, creatures, equipment pieces, and weapon rigs are optional: +//! a miss records a typed [`PawnAssetIssue`] exactly once and the actor stays +//! visible on its explicit fallback (base body / no attachment). + +use std::collections::HashMap; + +use serde::Deserialize; +use successor_engine_core::math::{vec3, Mat4, Quat}; +use successor_engine_render::components::{MaterialId, MeshId}; +use successor_engine_render::gpu::Gpu; +use successor_engine_render::primitives; +use successor_engine_render::renderer::{MaterialDesc, Renderer}; + +use super::creatures::{species_for_sprite, CreatureSpecies}; +use super::pack::PawnTemplate; +use crate::assets::model::upload_static_model; +use crate::assets::stream::{AssetStreamer, ByteSource, Streamed}; +use crate::world::area::fnv1a32; +use crate::world::ADULT_PAWN_HEIGHT_METERS; +use successor_platform::Platform; + +/// Byte provider over stable asset ids (`Platform::read_asset` adapter). +pub type AssetRead<'a> = dyn FnMut(&str) -> Option> + 'a; + +pub const MALE_BODY_ID: &str = "assets/pawn-pack/pawn_male.glb"; +pub const FEMALE_BODY_ID: &str = "assets/pawn-pack/pawn_female.glb"; + +/// Canonical segmented-skin vocabulary shared by the authored pack and both +/// runtime renderers. Material names are exactly `BodyZone_`. +pub const BODY_ZONE_NAMES: [&str; 16] = [ + "torso", + "pelvis", + "neck", + "head", + "left_upper_arm", + "right_upper_arm", + "left_forearm", + "right_forearm", + "left_hand", + "right_hand", + "left_thigh", + "right_thigh", + "left_calf", + "right_calf", + "left_foot", + "right_foot", +]; + +/// Compact union of the canonical body zones. It is parsed once per manifest +/// item and combined when a pawn is spawned or respawned. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct BodyZoneMask(u16); + +impl BodyZoneMask { + fn bit_for_zone(name: &str) -> Option { + Some(match name { + "torso" => 1 << 0, + "pelvis" => 1 << 1, + "neck" => 1 << 2, + "head" => 1 << 3, + "left_upper_arm" => 1 << 4, + "right_upper_arm" => 1 << 5, + "left_forearm" => 1 << 6, + "right_forearm" => 1 << 7, + "left_hand" => 1 << 8, + "right_hand" => 1 << 9, + "left_thigh" => 1 << 10, + "right_thigh" => 1 << 11, + "left_calf" => 1 << 12, + "right_calf" => 1 << 13, + "left_foot" => 1 << 14, + "right_foot" => 1 << 15, + _ => return None, + }) + } + + fn insert_named(&mut self, name: &str) -> bool { + let Some(bit) = Self::bit_for_zone(name) else { + return false; + }; + self.0 |= bit; + true + } + + fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + /// Returns true only for an exact, canonical segmented body material. + pub fn hides_material_name(self, material_name: Option<&str>) -> bool { + let Some(zone) = material_name.and_then(|name| name.strip_prefix("BodyZone_")) else { + return false; + }; + let Some(bit) = Self::bit_for_zone(zone) else { + return false; + }; + self.0 & bit != 0 + } +} + +/// Exact actor sprite → authored special-humanoid body (NPC-only bodies). +const SPECIAL_HUMANOID_BODY_BY_SPRITE: [(&str, &str); 1] = + [("droid-grok-humanoid", "droid_grok_humanoid")]; + +/// How an actor's visible body is sourced. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum BodyRoute { + Human { female: bool }, + Special { body_key: &'static str }, + Creature { species: CreatureSpecies }, +} + +/// `pawnBodyForActor` / `specialPawnBodyKeyForActor` / creature routing, in +/// the reference precedence: creature sprite → special sprite → sprite +/// male/female → id-hash male/female (¼ female). +pub fn route_for(sprite: Option<&str>, actor_id: &str) -> BodyRoute { + if let Some(sprite) = sprite.filter(|s| !s.is_empty()) { + if let Some(species) = species_for_sprite(sprite) { + return BodyRoute::Creature { species }; + } + for (key, body) in SPECIAL_HUMANOID_BODY_BY_SPRITE { + if key == sprite { + return BodyRoute::Special { body_key: body }; + } + } + return BodyRoute::Human { + female: sprite.contains("female"), + }; + } + BodyRoute::Human { + female: fnv1a32(actor_id).is_multiple_of(4), + } +} + +/// Weapon rig model families (socket-welded rigid meshes). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum WeaponRigKind { + Slugthrower, + Vibrosword, + PlasmaHilt, +} + +impl WeaponRigKind { + pub fn stable_id(self) -> &'static str { + match self { + WeaponRigKind::Slugthrower => "assets/pawn-pack/slugthrower.glb", + WeaponRigKind::Vibrosword => "assets/pawn-pack/vibrosword.glb", + WeaponRigKind::PlasmaHilt => "assets/pawn-pack/plasma_hilt.glb", + } + } + + pub fn attach_stable_id(self) -> &'static str { + match self { + WeaponRigKind::Slugthrower => "assets/pawn-pack/slugthrower_attach.json", + WeaponRigKind::Vibrosword | WeaponRigKind::PlasmaHilt => { + "assets/pawn-pack/vibrosword_attach.json" + } + } + } +} + +/// Equipped weapon id → rig family (None → nothing socketed). +pub fn rig_for_weapon_id(weapon_id: Option<&str>) -> Option { + let id = weapon_id?.to_ascii_lowercase(); + if id.contains("plasma") { + Some(WeaponRigKind::PlasmaHilt) + } else if id.contains("sword") || id.contains("vibro") || id.contains("blade") { + Some(WeaponRigKind::Vibrosword) + } else if id.contains("slug") || id.contains("rifle") || id.contains("gun") { + Some(WeaponRigKind::Slugthrower) + } else { + None + } +} + +/// Typed optional-asset degradation. Each variant identifies the exact stable +/// id (or item id) that failed so the probe/bug-report path can name it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PawnAssetIssue { + MissingSpecialBody { stable_id: String }, + MissingCreature { stable_id: String }, + MissingWeaponRig { stable_id: String }, + MissingEquipment { item_id: String }, + IncompatibleEquipmentRig { item_id: String }, + UnknownEquipmentHideBodyZone { item_id: String, zone: String }, + InvalidEquipmentHideBodyZones { item_id: String }, + MissingEquipmentManifest, + MissingWeaponManifest, +} + +pub struct BodyAssets { + pub template: PawnTemplate, + pub scale: f32, + pub part_meshes: Vec<(MeshId, MaterialId)>, + pub part_material_names: Vec>, +} + +/// Material name of the authored face panel drawn over the head. +const FACE_PANEL_MATERIAL: &str = "RB_Face"; + +fn load_body( + gpu: &mut G, + renderer: &mut Renderer, + bytes: &[u8], + target_height: Option, +) -> Option { + let template = PawnTemplate::from_bytes(bytes).ok()?; + let scale = match target_height { + Some(h) => template.uniform_scale_for_height(h)?, + None => 1.0, + }; + let gpu_parts = template.upload(gpu, renderer).ok()?; + let mut part_meshes = gpu_parts.parts; + key_face_panel_material( + gpu, + renderer, + bytes, + &gpu_parts.material_names, + &mut part_meshes, + ); + Some(BodyAssets { + template, + scale, + part_meshes, + part_material_names: gpu_parts.material_names, + }) +} + +/// Replace the face panel's opaque texture with one whose baked skin canvas is +/// keyed to transparency, so the head's tinted skin shows through and only the +/// painted features remain. A body without the panel is left untouched. +fn key_face_panel_material( + gpu: &mut G, + renderer: &mut Renderer, + bytes: &[u8], + material_names: &[Option], + parts: &mut [(MeshId, MaterialId)], +) { + let Some(index) = material_names + .iter() + .position(|name| name.as_deref() == Some(FACE_PANEL_MATERIAL)) + else { + return; + }; + let Some((_, material)) = parts.get(index).copied() else { + return; + }; + let Some(mut desc) = renderer.material_desc(material) else { + return; + }; + let Ok(doc) = successor_engine_core::glb::parse(bytes) else { + return; + }; + // The panel carries exactly one image: its painted face canvas. + let Some(image) = doc.images.first() else { + return; + }; + let Ok(decoded) = successor_engine_core::image::decode_png(&image.bytes) else { + return; + }; + let keyed = crate::pawn::face::key_face_panel(&decoded); + let texture = gpu.create_texture( + &successor_engine_render::gpu::TextureDesc { + width: keyed.width, + height: keyed.height, + format: successor_engine_render::gpu::TextureFormat::Srgba8, + mag_filter: successor_engine_render::gpu::Filter::Linear, + min_filter: successor_engine_render::gpu::MinFilter::LinearMipmapLinear, + wrap_s: successor_engine_render::gpu::Wrap::ClampToEdge, + wrap_t: successor_engine_render::gpu::Wrap::ClampToEdge, + mipmaps: true, + }, + Some(&keyed.pixels), + ); + desc.base_color_texture = Some(texture); + desc.blend = true; + parts[index].1 = renderer.add_material_desc(desc); +} + +pub struct RigModel { + pub parts: Vec<(MeshId, MaterialId, Mat4)>, + pub mount: Mat4, + pub foregrip: successor_engine_core::math::Vec3, + pub grip: successor_engine_core::math::Vec3, + pub muzzle: successor_engine_core::math::Vec3, + pub foregrip_contact: successor_engine_core::math::Vec3, + pub resting_yaw_rad: f32, + pub support_arm: Option, + pub melee: bool, + pub support_hand: bool, + pub plasma_blade_part: Option, + pub stow: Option, +} + +#[derive(Deserialize)] +struct WeaponAttachSpec { + #[serde(default)] + item_id: Option, + #[serde(default)] + scale_to_pawn: Option, + #[serde(default)] + attach: Option, + mount_hand_r_local: WeaponMountSpec, + sockets: WeaponSocketSpec, + #[serde(default)] + hold: Option, + #[serde(default)] + stow_socket: Option, +} + +#[derive(Deserialize)] +struct WeaponMountSpec { + pos: [f32; 3], + quat: [f32; 4], +} + +#[derive(Deserialize)] +struct WeaponSocketSpec { + grip: [f32; 3], + foregrip: [f32; 3], + muzzle: [f32; 3], + #[serde(default)] + foregrip_contact: Option<[f32; 3]>, +} + +#[derive(Deserialize)] +struct WeaponHoldSpec { + #[serde(default)] + resting_yaw_deg: Option, + #[serde(default)] + support_arm: Option, +} + +#[derive(Deserialize)] +struct WeaponSupportArmSpec { + #[serde(default)] + min_elbow_bend_deg: Option, + #[serde(default)] + shoulder_advance_max_m: Option, + #[serde(default)] + elbow_pole_deg: Option, +} + +/// Per-model support-arm hold posture. Mirror of `SupportArmSpec` in +/// `client-3d/src/assets/pawnRigTypes.ts`; both runtimes read the same +/// `hold.support_arm` block out of the weapon's attach json, so a model +/// authored once poses identically in the browser and in native. +#[derive(Clone, Copy)] +pub struct SupportArmPosture { + pub min_bend_rad: f32, + /// Metres the clavicle may swing the shoulder toward an out-of-reach target. + pub shoulder_advance_max_m: f32, + /// Elbow roll about the shoulder->wrist axis, radians from world-down. + pub pole_rad: f32, +} + +/// Anatomical rails, identical to the browser parser's. +const MAX_SUPPORT_BEND_DEG: f32 = 80.0; +const MAX_SHOULDER_ADVANCE_M: f32 = 0.12; + +#[derive(Deserialize)] +struct WeaponStowSpec { + space: String, + pos: [f32; 3], + rot_deg: [f32; 3], + #[serde(default)] + arc_lift: Option, +} + +#[derive(Clone)] +pub struct WeaponStow { + pub bone: String, + pub mount: Mat4, + pub arc_lift: f32, +} + +#[derive(Clone)] +pub(super) struct WeaponHandSpec { + pub mount: Mat4, + pub foregrip: successor_engine_core::math::Vec3, + pub grip: successor_engine_core::math::Vec3, + pub muzzle: successor_engine_core::math::Vec3, + pub foregrip_contact: successor_engine_core::math::Vec3, + pub resting_yaw_rad: f32, + pub support_arm: Option, + pub stow: Option, + pub scale_to_pawn: Option, + pub two_handed: bool, +} + +pub(super) fn parse_weapon_hand_spec(bytes: &[u8]) -> Option { + let spec = serde_json::from_slice::(bytes).ok()?; + let pos = spec.mount_hand_r_local.pos; + let quat = spec.mount_hand_r_local.quat; + let grip = spec.sockets.grip; + let foregrip = spec.sockets.foregrip; + let muzzle = spec.sockets.muzzle; + let foregrip_contact = spec + .sockets + .foregrip_contact + .unwrap_or([0.0, -0.02, -0.055]); + let resting_yaw_rad = spec + .hold + .as_ref() + .and_then(|hold| hold.resting_yaw_deg) + .map(f32::to_radians) + .unwrap_or(0.1); + let support_arm = spec + .hold + .as_ref() + .and_then(|hold| hold.support_arm.as_ref()) + .and_then(|arm| { + // Both a bend floor and a pole angle or nothing: rolling an elbow + // with no bend holding it would spin a straight arm about itself. + let bend_deg = arm.min_elbow_bend_deg?; + let pole_deg = arm.elbow_pole_deg?; + Some(SupportArmPosture { + min_bend_rad: bend_deg.clamp(0.0, MAX_SUPPORT_BEND_DEG).to_radians(), + shoulder_advance_max_m: arm + .shoulder_advance_max_m + .unwrap_or(0.0) + .clamp(0.0, MAX_SHOULDER_ADVANCE_M), + pole_rad: pole_deg.to_radians(), + }) + }); + let scale_to_pawn = spec.scale_to_pawn; + let two_handed = spec.attach.as_deref() == Some("two_hand"); + let stow = spec.stow_socket.map(|stow| { + let radians = stow.rot_deg.map(f32::to_radians); + let rotation = Quat::from_axis_angle(vec3(1.0, 0.0, 0.0), radians[0]) + .mul(Quat::from_axis_angle( + successor_engine_core::math::Vec3::Y, + radians[1], + )) + .mul(Quat::from_axis_angle(vec3(0.0, 0.0, 1.0), radians[2])) + .normalize(); + WeaponStow { + bone: stow.space.trim_end_matches("_local").to_string(), + mount: Mat4::from_trs( + vec3(stow.pos[0], stow.pos[1], stow.pos[2]), + rotation, + vec3(1.0, 1.0, 1.0), + ), + arc_lift: stow.arc_lift.unwrap_or(0.14), + } + }); + Some(WeaponHandSpec { + mount: Mat4::from_trs( + vec3(pos[0], pos[1], pos[2]), + Quat { + x: quat[0], + y: quat[1], + z: quat[2], + w: quat[3], + } + .normalize(), + vec3(1.0, 1.0, 1.0), + ), + foregrip: vec3(foregrip[0], foregrip[1], foregrip[2]), + grip: vec3(grip[0], grip[1], grip[2]), + muzzle: vec3(muzzle[0], muzzle[1], muzzle[2]), + foregrip_contact: vec3( + foregrip_contact[0], + foregrip_contact[1], + foregrip_contact[2], + ), + resting_yaw_rad, + support_arm, + stow, + scale_to_pawn, + two_handed, + }) +} + +fn append_plasma_blade( + gpu: &mut G, + renderer: &mut Renderer, + parts: &mut Vec<(MeshId, MaterialId, Mat4)>, +) -> usize { + let (vertices, indices) = primitives::capsule(0.014, 0.75, 10, 4); + let mesh = renderer.upload_mesh(gpu, &vertices, &indices); + let material = renderer.add_material_desc(MaterialDesc { + base_color: [0.18, 0.9, 1.0, 1.0], + metallic: 0.0, + roughness: 0.22, + emissive_factor: [0.39, 0.94, 1.0], + emissive_strength: 7.5, + ..MaterialDesc::default() + }); + let local = Mat4::from_trs( + vec3(0.0, 0.0, 0.465), + Quat::from_axis_angle(vec3(1.0, 0.0, 0.0), core::f32::consts::FRAC_PI_2), + vec3(1.0, 1.0, 1.0), + ); + let index = parts.len(); + parts.push((mesh, material, local)); + index +} +pub struct EquipmentPiece { + pub part_meshes: Vec<(MeshId, MaterialId)>, + pub part_material_names: Vec>, + /// Joint count of the piece's own rig — must match the body skeleton. + pub joint_count: usize, +} + +#[derive(Clone)] +struct EquipmentPaletteZone { + slots: Vec, + default: [f32; 4], +} + +#[derive(Clone)] +struct EquipmentPaths { + default: String, + female: Option, + material: Option, + palette_zones: Vec, + hide_body_zones: BodyZoneMask, +} + +#[derive(Clone)] +struct WeaponPaths { + glb: String, + attach: String, + melee: bool, + scale: f32, +} + +pub struct PawnCatalog { + male: BodyAssets, + female: BodyAssets, + special: HashMap<&'static str, Option>, + creatures: HashMap<&'static str, Option>, + legacy_weapons: HashMap>, + custom_weapon_paths: HashMap, + custom_weapon_by_item: HashMap, + /// Parsed custom attach specs (streamed); also fills `custom_weapon_by_item`. + hand_specs: HashMap>, + /// Streamer epoch when `warm_custom_specs` last ran (skip unchanged). + spec_warm_epoch: u64, + custom_weapons: HashMap>, + /// Item id → sex-specific GLB paths (relative to the equipment directory). + equipment_paths: HashMap, + /// Cached by item id, with a `female:` prefix only for authored variants. + equipment: HashMap>, + /// Material-preset id → authored flat color from the wardrobe palette. + material_colors: HashMap, + issues: Vec, +} + +const MAX_ISSUES: usize = 64; + +fn record_issue(issues: &mut Vec, issue: PawnAssetIssue) { + if issues.len() < MAX_ISSUES && !issues.contains(&issue) { + issues.push(issue); + } +} + +fn parse_hide_body_zones( + item_id: &str, + value: Option<&serde_json::Value>, + issues: &mut Vec, +) -> BodyZoneMask { + let mut zones = BodyZoneMask::default(); + let Some(value) = value else { + return zones; + }; + let Some(entries) = value.as_array() else { + record_issue( + issues, + PawnAssetIssue::InvalidEquipmentHideBodyZones { + item_id: item_id.to_string(), + }, + ); + return zones; + }; + for entry in entries { + let Some(zone) = entry.as_str() else { + record_issue( + issues, + PawnAssetIssue::InvalidEquipmentHideBodyZones { + item_id: item_id.to_string(), + }, + ); + continue; + }; + if !zones.insert_named(zone) { + record_issue( + issues, + PawnAssetIssue::UnknownEquipmentHideBodyZone { + item_id: item_id.to_string(), + zone: zone.to_string(), + }, + ); + } + } + zones +} + +fn parse_hex_color(value: &str) -> Option<[f32; 4]> { + let hex = value.strip_prefix('#')?; + if hex.len() != 6 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return None; + } + Some([ + u8::from_str_radix(&hex[0..2], 16).ok()? as f32 / 255.0, + u8::from_str_radix(&hex[2..4], 16).ok()? as f32 / 255.0, + u8::from_str_radix(&hex[4..6], 16).ok()? as f32 / 255.0, + 1.0, + ]) +} + +fn parse_equipment_palette(item: &serde_json::Value) -> Vec { + item.get("palette") + .and_then(|value| value.get("zones")) + .and_then(|value| value.as_array()) + .into_iter() + .flatten() + .filter_map(|zone| { + let slots = zone + .get("slots")? + .as_array()? + .iter() + .filter_map(|slot| slot.as_str().map(str::to_string)) + .collect::>(); + let default = parse_hex_color(zone.get("default")?.as_str()?)?; + (!slots.is_empty()).then_some(EquipmentPaletteZone { slots, default }) + }) + .collect() +} + +fn parse_material_colors(value: &serde_json::Value) -> HashMap { + let mut colors = HashMap::new(); + let Some(palettes) = value.get("palettes").and_then(|entry| entry.as_object()) else { + return colors; + }; + for palette in palettes.values() { + let Some(swatches) = palette.get("swatches").and_then(|entry| entry.as_array()) else { + continue; + }; + for swatch in swatches { + let Some((id, color)) = swatch.get("id").and_then(|entry| entry.as_str()).zip( + swatch + .get("hex") + .and_then(|entry| entry.as_str()) + .and_then(parse_hex_color), + ) else { + continue; + }; + colors.insert(id.to_string(), color); + } + } + colors +} + +fn clean_material_name(name: &str) -> &str { + let bytes = name.as_bytes(); + if bytes.len() >= 4 + && bytes[bytes.len() - 4] == b'.' + && bytes[bytes.len() - 3..] + .iter() + .all(|byte| byte.is_ascii_digit()) + { + &name[..name.len() - 4] + } else { + name + } +} + +fn atlas_slot_suffix(name: &str) -> Option<&str> { + let clean = clean_material_name(name); + let start = clean.rfind("_c")? + 1; + let suffix = &clean[start..]; + (suffix.len() >= 2 && suffix[1..].bytes().all(|byte| byte.is_ascii_digit())).then_some(suffix) +} + +fn palette_zone_color( + paths: &EquipmentPaths, + material_name: Option<&str>, + worn_colors: &[String], +) -> Option<[f32; 4]> { + let name = material_name?; + let clean = clean_material_name(name); + let suffix = atlas_slot_suffix(name); + for (index, zone) in paths.palette_zones.iter().enumerate() { + if !zone + .slots + .iter() + .any(|slot| slot == clean || suffix.is_some_and(|suffix| slot == suffix)) + { + continue; + } + return worn_colors + .get(index) + .and_then(|color| parse_hex_color(color)) + .or(Some(zone.default)); + } + None +} + +impl PawnCatalog { + /// Load the required bodies + optional equipment manifest. A missing or + /// unparseable required body is a hard error (world entry must stop). + pub fn load( + gpu: &mut G, + renderer: &mut Renderer, + read: &mut AssetRead<'_>, + ) -> Result { + let male_bytes = read(MALE_BODY_ID) + .ok_or_else(|| format!("required body asset missing: {MALE_BODY_ID}"))?; + let male = load_body(gpu, renderer, &male_bytes, Some(ADULT_PAWN_HEIGHT_METERS)) + .ok_or_else(|| format!("required body asset invalid: {MALE_BODY_ID}"))?; + let female_bytes = read(FEMALE_BODY_ID) + .ok_or_else(|| format!("required body asset missing: {FEMALE_BODY_ID}"))?; + let female = load_body(gpu, renderer, &female_bytes, Some(ADULT_PAWN_HEIGHT_METERS)) + .ok_or_else(|| format!("required body asset invalid: {FEMALE_BODY_ID}"))?; + + let mut equipment_paths = HashMap::new(); + let mut issues = Vec::new(); + match read("assets/pawn-pack/equipment/manifest.json") + .and_then(|b| String::from_utf8(b).ok()) + .and_then(|s| serde_json::from_str::(&s).ok()) + { + Some(manifest) => { + if let Some(items) = manifest.get("items").and_then(|i| i.as_array()) { + for item in items { + let (Some(id), Some(glb)) = ( + item.get("id").and_then(|v| v.as_str()), + item.get("glb").and_then(|v| v.as_str()), + ) else { + continue; + }; + let item_key = id.to_ascii_lowercase(); + let hide_body_zones = parse_hide_body_zones( + &item_key, + item.get("hideBodyZones"), + &mut issues, + ); + equipment_paths.insert( + item_key, + EquipmentPaths { + default: glb.to_string(), + female: item + .get("glbFemale") + .and_then(|v| v.as_str()) + .map(str::to_string), + material: item + .get("mat") + .and_then(|value| value.as_str()) + .map(str::to_string), + palette_zones: parse_equipment_palette(item), + hide_body_zones, + }, + ); + } + } + } + None => record_issue(&mut issues, PawnAssetIssue::MissingEquipmentManifest), + } + + let material_colors = read("assets/pawn-pack/equipment/wardrobe_palette.json") + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .map(|value| parse_material_colors(&value)) + .unwrap_or_default(); + + let mut custom_weapon_paths = HashMap::new(); + let mut custom_weapon_by_item = HashMap::new(); + match read("assets/pawn-pack/weapons/weapons_manifest.json") + .and_then(|b| String::from_utf8(b).ok()) + .and_then(|s| serde_json::from_str::(&s).ok()) + { + Some(manifest) => { + if let Some(items) = manifest.get("items").and_then(|i| i.as_array()) { + for item in items { + let (Some(id), Some(glb), Some(attach), Some(class)) = ( + item.get("id").and_then(|v| v.as_str()), + item.get("glb").and_then(|v| v.as_str()), + item.get("attach").and_then(|v| v.as_str()), + item.get("class").and_then(|v| v.as_str()), + ) else { + continue; + }; + let key = id.to_ascii_lowercase(); + if let Some(item_ids) = item.get("item_ids").and_then(|v| v.as_array()) { + for item_id in item_ids.iter().filter_map(|v| v.as_i64()) { + custom_weapon_by_item.insert(item_id, key.clone()); + } + } + // Attach-spec `item_id` aliases resolve lazily through + // the streamer in `hand_spec_for` (they used to cost a + // synchronous ~1 MB read per weapon at scene build). + custom_weapon_paths.insert( + key, + WeaponPaths { + glb: glb.to_string(), + attach: attach.to_string(), + melee: class.eq_ignore_ascii_case("melee"), + scale: item + .get("scale") + .and_then(|value| value.as_f64()) + .map(|value| value as f32) + .unwrap_or(1.0), + }, + ); + } + } + } + None => record_issue(&mut issues, PawnAssetIssue::MissingWeaponManifest), + } + + Ok(Self { + male, + female, + special: HashMap::new(), + creatures: HashMap::new(), + legacy_weapons: HashMap::new(), + custom_weapon_paths, + custom_weapon_by_item, + hand_specs: HashMap::new(), + spec_warm_epoch: u64::MAX, + custom_weapons: HashMap::new(), + equipment_paths, + equipment: HashMap::new(), + material_colors, + issues, + }) + } + + /// Typed degradation log (bounded; each issue recorded once). + pub fn issues(&self) -> &[PawnAssetIssue] { + &self.issues + } + pub(crate) fn record(&mut self, issue: PawnAssetIssue) { + record_issue(&mut self.issues, issue); + } + + pub fn human(&self, female: bool) -> &BodyAssets { + if female { + &self.female + } else { + &self.male + } + } + + /// The body for a route; lazily loads special/creature templates through + /// the asset streamer. `Pending` means the model is still in flight — the + /// caller renders nothing for that pawn this frame and retries. `Ready` + /// with `None` means "typed fallback" — the caller renders the explicit + /// missing-asset presentation (base human body for specials, marker for + /// creatures). Human bodies are required boot assets and never pend. + pub fn body_for( + &mut self, + gpu: &mut G, + renderer: &mut Renderer, + platform: &mut dyn Platform, + streamer: &mut AssetStreamer, + route: BodyRoute, + ) -> Streamed> { + match route { + BodyRoute::Human { female } => Streamed::Ready(Some(self.human(female))), + BodyRoute::Special { body_key } => { + if !self.special.contains_key(body_key) { + let stable_id = format!("assets/pawn-pack/special/{body_key}.glb"); + match streamer.resolve(platform, &stable_id) { + ByteSource::Pending => return Streamed::Pending, + ByteSource::Missing => { + self.record(PawnAssetIssue::MissingSpecialBody { stable_id }); + self.special.insert(body_key, None); + } + ByteSource::Ready(bytes) => { + let loaded = + load_body(gpu, renderer, &bytes, Some(ADULT_PAWN_HEIGHT_METERS)); + if loaded.is_none() { + self.record(PawnAssetIssue::MissingSpecialBody { stable_id }); + } + self.special.insert(body_key, loaded); + } + } + } + Streamed::Ready(self.special.get(body_key).and_then(|b| b.as_ref())) + } + BodyRoute::Creature { species } => { + let key = species.species_id; + if !self.creatures.contains_key(key) { + // Creature GLBs are authored at world scale; per-species + // mesh_scale applies on top (no height normalization). + let stable_id = species.asset_path.trim_start_matches('/').to_string(); + match streamer.resolve(platform, &stable_id) { + ByteSource::Pending => return Streamed::Pending, + ByteSource::Missing => { + self.record(PawnAssetIssue::MissingCreature { stable_id }); + self.creatures.insert(key, None); + } + ByteSource::Ready(bytes) => { + let loaded = load_body(gpu, renderer, &bytes, None).map(|mut b| { + b.scale = species.mesh_scale; + b + }); + if loaded.is_none() { + self.record(PawnAssetIssue::MissingCreature { stable_id }); + } + self.creatures.insert(key, loaded); + } + } + } + Streamed::Ready(self.creatures.get(key).and_then(|b| b.as_ref())) + } + } + } + + /// Mutable access to an already-loaded body (animation sampling needs + /// `&mut PawnTemplate`). Never loads: `None` means the route was not + /// resolved by a prior [`Self::body_for`] call (caller falls back). + pub fn body_mut(&mut self, route: BodyRoute) -> Option<&mut BodyAssets> { + match route { + BodyRoute::Human { female } => Some(if female { + &mut self.female + } else { + &mut self.male + }), + BodyRoute::Special { body_key } => { + self.special.get_mut(body_key).and_then(|b| b.as_mut()) + } + BodyRoute::Creature { species } => self + .creatures + .get_mut(species.species_id) + .and_then(|b| b.as_mut()), + } + } + + /// The rigid weapon rig for a family; lazily streamed, typed miss. + /// `Pending` until both the attach spec and the GLB land. + pub fn weapon_rig( + &mut self, + gpu: &mut G, + renderer: &mut Renderer, + platform: &mut dyn Platform, + streamer: &mut AssetStreamer, + kind: WeaponRigKind, + ) -> Streamed> { + if !self.legacy_weapons.contains_key(&kind) { + let attach_bytes = match streamer.resolve(platform, kind.attach_stable_id()) { + ByteSource::Pending => return Streamed::Pending, + ByteSource::Missing => None, + ByteSource::Ready(bytes) => Some(bytes), + }; + let glb_bytes = match streamer.resolve(platform, kind.stable_id()) { + ByteSource::Pending => return Streamed::Pending, + ByteSource::Missing => None, + ByteSource::Ready(bytes) => Some(bytes), + }; + let hand_spec = attach_bytes.and_then(|bytes| parse_weapon_hand_spec(&bytes)); + let loaded = hand_spec.and_then(|hand_spec| { + glb_bytes + .and_then(|bytes| upload_static_model(gpu, renderer, &bytes).ok()) + .map(|model| RigModel { + parts: model + .parts + .into_iter() + .map(|part| (part.mesh, part.material, part.local)) + .collect(), + mount: hand_spec.mount, + foregrip: hand_spec.foregrip, + grip: hand_spec.grip, + muzzle: hand_spec.muzzle, + foregrip_contact: hand_spec.foregrip_contact, + resting_yaw_rad: hand_spec.resting_yaw_rad, + support_arm: hand_spec.support_arm, + melee: !matches!(kind, WeaponRigKind::Slugthrower), + support_hand: hand_spec.two_handed, + stow: hand_spec.stow, + plasma_blade_part: None, + }) + }); + if loaded.is_none() { + self.record(PawnAssetIssue::MissingWeaponRig { + stable_id: kind.stable_id().to_string(), + }); + } + self.legacy_weapons.insert(kind, loaded); + } + Streamed::Ready(self.legacy_weapons.get(&kind).and_then(|r| r.as_ref())) + } + + /// The parsed hand spec for a custom weapon key; streamed and cached. + /// A resolved spec also registers its authored `item_id` alias. + fn hand_spec_for( + &mut self, + platform: &mut dyn Platform, + streamer: &mut AssetStreamer, + key: &str, + ) -> Streamed> { + if !self.hand_specs.contains_key(key) { + let Some(paths) = self.custom_weapon_paths.get(key) else { + return Streamed::Ready(None); + }; + let attach_id = format!("assets/pawn-pack/weapons/{}", paths.attach); + let parsed = match streamer.resolve(platform, &attach_id) { + ByteSource::Pending => return Streamed::Pending, + ByteSource::Missing => None, + ByteSource::Ready(bytes) => { + // The alias table lives in the attach json, not the + // weapons manifest: register it on first resolve. + if let Ok(spec) = serde_json::from_slice::(&bytes) { + if let Some(item_id) = spec.item_id { + self.custom_weapon_by_item.insert(item_id, key.to_string()); + } + } + parse_weapon_hand_spec(&bytes) + } + }; + // Distinct weapon keys may share one attach file (manifest + // variants): one resolved fetch fills every sharer. Without this, + // the streamer's per-id `take` hands the single completion to + // one key and the others re-fetch the same bytes, which can + // livelock `custom_specs_pending` when a sharer drains the file + // before its sibling resolves. + let shared: Vec = self + .custom_weapon_paths + .iter() + .filter(|(other, other_paths)| { + other_paths.attach == paths.attach && other.as_str() != key + }) + .map(|(other, _)| other.clone()) + .collect(); + self.hand_specs.insert(key.to_string(), parsed.clone()); + for other in shared { + self.hand_specs + .entry(other) + .or_insert_with(|| parsed.clone()); + } + } + Streamed::Ready(self.hand_specs.get(key).and_then(|s| s.as_ref())) + } + + /// Whether any custom weapon's attach spec is still unresolved. While + /// true, an unmapped `weapon_item_id` cannot be ruled out of the custom + /// table — callers treat the lookup as pending rather than legacy. + pub fn custom_specs_pending(&self) -> bool { + self.custom_weapon_paths + .keys() + .any(|key| !self.hand_specs.contains_key(key.as_str())) + } + + /// Queue every unresolved custom attach spec (background fill of the + /// item-id alias table) and parse any that landed. Idempotent via the + /// streamer; after this settles `custom_specs_pending` is false. + pub fn warm_custom_specs(&mut self, platform: &mut dyn Platform, streamer: &mut AssetStreamer) { + // Allocation-free steady state: re-collect the unresolved key list + // only when the streamer delivered something since the last warm. + let epoch = streamer.ready_epoch(); + if self.spec_warm_epoch == epoch { + return; + } + self.spec_warm_epoch = epoch; + let keys: Vec = self + .custom_weapon_paths + .keys() + .filter(|key| !self.hand_specs.contains_key(key.as_str())) + .cloned() + .collect(); + for key in keys { + // hand_spec_for requests when unresolved and parses + registers + // the item-id alias once the bytes land. + let _ = self.hand_spec_for(platform, streamer, &key); + } + } + + /// Resolve an authority weapon snapshot to its exact presentation model. + /// The backing item id wins; the normalized weapon id is the legacy + /// fallback for snapshots that predate `weaponItemId`. Models and attach + /// specs stream: `Pending` until the pieces land, then the exact rig. + pub fn weapon_rig_for( + &mut self, + gpu: &mut G, + renderer: &mut Renderer, + platform: &mut dyn Platform, + streamer: &mut AssetStreamer, + weapon_id: Option<&str>, + weapon_item_id: Option, + ) -> Streamed> { + let custom_key = weapon_item_id + .and_then(|item_id| self.custom_weapon_by_item.get(&item_id).cloned()) + .or_else(|| { + weapon_id + .map(|id| id.to_ascii_lowercase().replace('-', "_")) + .filter(|id| self.custom_weapon_paths.contains_key(id)) + }); + let Some(key) = custom_key else { + // An unmapped item id may still hide in an unresolved attach spec: + // fill the alias table in the background and pend the weapon until + // the table settles (legacy rigs would otherwise mis-bind). + if weapon_item_id.is_some() && self.custom_specs_pending() { + self.warm_custom_specs(platform, streamer); + return Streamed::Pending; + } + if weapon_item_id == Some(3104) { + return self.weapon_rig( + gpu, + renderer, + platform, + streamer, + WeaponRigKind::PlasmaHilt, + ); + } + let Some(kind) = rig_for_weapon_id(weapon_id) else { + return Streamed::Ready(None); + }; + return self.weapon_rig(gpu, renderer, platform, streamer, kind); + }; + if !self.custom_weapons.contains_key(&key) { + let Some(paths) = self.custom_weapon_paths.get(&key).cloned() else { + return Streamed::Ready(None); + }; + let hand_spec = match self.hand_spec_for(platform, streamer, &key) { + Streamed::Pending => return Streamed::Pending, + Streamed::Ready(spec) => spec.cloned(), + }; + let glb_id = format!("assets/pawn-pack/weapons/{}", paths.glb); + let glb_bytes = match streamer.resolve(platform, &glb_id) { + ByteSource::Pending => return Streamed::Pending, + ByteSource::Missing => None, + ByteSource::Ready(bytes) => Some(bytes), + }; + let loaded = hand_spec.and_then(|hand_spec| { + let model_scale = hand_spec.scale_to_pawn.unwrap_or(paths.scale); + glb_bytes + .and_then(|bytes| { + crate::assets::model::upload_static_model(gpu, renderer, &bytes).ok() + }) + .map(|mut model| { + if (model_scale - 1.0).abs() > f32::EPSILON { + let scale = Mat4::from_trs( + successor_engine_core::math::Vec3::ZERO, + Quat::IDENTITY, + vec3(model_scale, model_scale, model_scale), + ); + for part in &mut model.parts { + part.local = scale.mul(part.local); + } + } + let mut parts = model + .parts + .into_iter() + .map(|part| (part.mesh, part.material, part.local)) + .collect::>(); + let plasma_blade_part = (key == "plasma_sword") + .then(|| append_plasma_blade(gpu, renderer, &mut parts)); + RigModel { + parts, + mount: hand_spec.mount, + foregrip: hand_spec.foregrip.scale(model_scale), + grip: hand_spec.grip.scale(model_scale), + muzzle: hand_spec.muzzle.scale(model_scale), + foregrip_contact: hand_spec.foregrip_contact.scale(model_scale), + resting_yaw_rad: hand_spec.resting_yaw_rad, + support_arm: hand_spec.support_arm, + melee: paths.melee, + support_hand: hand_spec.two_handed, + stow: hand_spec.stow, + plasma_blade_part, + } + }) + }); + if loaded.is_none() { + self.record(PawnAssetIssue::MissingWeaponRig { + stable_id: format!("assets/pawn-pack/weapons/{}", paths.glb), + }); + } + self.custom_weapons.insert(key.clone(), loaded); + } + Streamed::Ready(self.custom_weapons.get(&key).and_then(|rig| rig.as_ref())) + } + + /// A worn/hair equipment piece by manifest item id (case-insensitive). + /// Pieces whose rig disagrees with the body skeleton are rejected with a + /// typed issue instead of binding to the wrong palette. + pub fn equipment_piece( + &mut self, + gpu: &mut G, + renderer: &mut Renderer, + platform: &mut dyn Platform, + streamer: &mut AssetStreamer, + item_id: &str, + body_joints: usize, + female: bool, + ) -> Streamed> { + let item_key = item_id.to_ascii_lowercase(); + let Some(paths) = self.equipment_paths.get(&item_key) else { + return Streamed::Ready(None); + }; + let use_female_variant = female && paths.female.is_some(); + let cache_key = if use_female_variant { + format!("female:{item_key}") + } else { + item_key.clone() + }; + if !self.equipment.contains_key(&cache_key) { + let glb = if use_female_variant { + paths + .female + .as_ref() + .expect("checked female equipment path") + } else { + &paths.default + } + .clone(); + let stable_id = format!("assets/pawn-pack/equipment/{glb}"); + let bytes = match streamer.resolve(platform, &stable_id) { + ByteSource::Pending => return Streamed::Pending, + ByteSource::Missing => None, + ByteSource::Ready(bytes) => Some(bytes), + }; + let loaded = bytes + .and_then(|bytes| PawnTemplate::from_bytes(&bytes).ok()) + .and_then(|template| { + let joint_count = template.joint_count(); + let gpu_parts = template.upload(gpu, renderer).ok()?; + Some(EquipmentPiece { + part_meshes: gpu_parts.parts, + part_material_names: gpu_parts.material_names, + joint_count, + }) + }); + match &loaded { + None => self.record(PawnAssetIssue::MissingEquipment { + item_id: item_key.clone(), + }), + Some(piece) if piece.joint_count != body_joints => { + self.record(PawnAssetIssue::IncompatibleEquipmentRig { item_id: item_key }); + self.equipment.insert(cache_key, None); + return Streamed::Ready(None); + } + Some(_) => {} + } + self.equipment.insert(cache_key.clone(), loaded); + } + Streamed::Ready(match self.equipment.get(&cache_key) { + Some(Some(piece)) if piece.joint_count == body_joints => Some(piece), + _ => None, + }) + } + + /// Resolve an authored equipment material slot to its actor-specific color. + /// Worn colors are index-aligned with manifest palette zones; hair uses the + /// character's saved hair material, then the piece's manifest preset. + pub fn equipment_part_color( + &self, + item_id: &str, + material_name: Option<&str>, + worn_colors: &[String], + hair_material: Option<&str>, + ) -> Option<[f32; 4]> { + let item_key = item_id.to_ascii_lowercase(); + let paths = self.equipment_paths.get(&item_key)?; + if item_key.starts_with("hair_") { + return hair_material + .or(paths.material.as_deref()) + .and_then(|material| self.material_colors.get(material).copied()); + } + if let Some(color) = palette_zone_color(paths, material_name, worn_colors) { + return Some(color); + } + paths + .material + .as_deref() + .and_then(|material| self.material_colors.get(material).copied()) + } + + /// Whether the manifest knows an item id (without loading it). + pub fn knows_equipment(&self, item_id: &str) -> bool { + self.equipment_paths + .contains_key(&item_id.to_ascii_lowercase()) + } + + /// Union the declared skin coverage for the exact resolved equipment ids. + /// The manifest vocabulary is shared by male and female GLB variants; sex + /// is selected only when their equipment mesh is loaded. + pub fn hidden_body_zones<'a>( + &self, + item_ids: impl IntoIterator, + ) -> BodyZoneMask { + let mut zones = BodyZoneMask::default(); + for item_id in item_ids { + if let Some(paths) = self.equipment_paths.get(item_id) { + zones = zones.union(paths.hide_body_zones); + continue; + } + if item_id.bytes().any(|byte| byte.is_ascii_uppercase()) { + let item_key = item_id.to_ascii_lowercase(); + if let Some(paths) = self.equipment_paths.get(&item_key) { + zones = zones.union(paths.hide_body_zones); + } + } + } + zones + } + + /// The default NPC outfit (port of `defaultRemotePawnEquipmentIds`): + /// underclothes + harness set, plus a deterministic head piece when the + /// server didn't author hair. `out` is reused by the caller. + pub fn default_outfit( + &self, + actor_id: &str, + role: Option<&str>, + hair: Option<&str>, + out: &mut Vec<&'static str>, + ) { + out.clear(); + const BASE: [&str; 8] = [ + "under_tank", + "under_shorts", + "armor_harness", + "armor_nape_reinforcement", + "armor_reinforcement", + "armor_gorget", + "armor_bicep_l", + "armor_bicep_r", + ]; + for id in BASE { + if self.knows_equipment(id) { + out.push(id); + } + } + if hair.is_none() { + // Deterministic helmet/hat pick per actor id; players prefer S3. + const HEADS: [&str; 5] = ["helmet_s3", "helmet_a", "helmet_b", "helmet_c", "hat_warm"]; + let preferred = if role == Some("player") { + 0 + } else { + (fnv1a32(actor_id) % HEADS.len() as u32) as usize + }; + if self.knows_equipment(HEADS[preferred]) { + out.push(HEADS[preferred]); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn creature_sprites_route_to_species() { + let r = route_for(Some("creature-bellback-adult"), "npc:1"); + assert!(matches!(r, BodyRoute::Creature { species } if species.species_id == "bellback")); + } + + #[test] + fn special_sprite_routes_to_authored_body() { + assert_eq!( + route_for(Some("droid-grok-humanoid"), "npc:2"), + BodyRoute::Special { + body_key: "droid_grok_humanoid" + } + ); + } + + #[test] + fn sprite_gender_and_hash_fallback() { + assert_eq!( + route_for(Some("wanderer-female"), "x"), + BodyRoute::Human { female: true } + ); + assert_eq!( + route_for(Some("wanderer"), "x"), + BodyRoute::Human { female: false } + ); + // No sprite → deterministic ¼-ish female split by id hash. + let ids = ["1:1", "1:2", "1:3", "1:4", "1:5", "1:6", "1:7", "1:8"]; + let females = ids + .iter() + .filter(|id| matches!(route_for(None, id), BodyRoute::Human { female: true })) + .count(); + assert!(females < ids.len(), "hash split, not constant female"); + assert_eq!(route_for(None, "1:1"), route_for(None, "1:1")); + } + + #[test] + fn body_zone_masks_union_known_zones_and_ignore_unknowns() { + let mut issues = Vec::new(); + let torso = parse_hide_body_zones( + "cover_torso", + Some(&serde_json::json!(["torso", "not_a_zone"])), + &mut issues, + ); + let pelvis = parse_hide_body_zones( + "cover_pelvis", + Some(&serde_json::json!(["pelvis"])), + &mut issues, + ); + let union = torso.union(pelvis); + + assert!(union.hides_material_name(Some("BodyZone_torso"))); + assert!(union.hides_material_name(Some("BodyZone_pelvis"))); + assert!(torso.hides_material_name(Some("BodyZone_torso"))); + assert!(!torso.hides_material_name(Some("BodyZone_pelvis"))); + assert!(!union.hides_material_name(Some("RB_Face"))); + assert!(!union.hides_material_name(Some("BodyZone_not_a_zone"))); + assert_eq!( + issues, + vec![PawnAssetIssue::UnknownEquipmentHideBodyZone { + item_id: "cover_torso".to_string(), + zone: "not_a_zone".to_string(), + }] + ); + } + + #[test] + fn equipment_palette_matches_atlas_suffixes_and_full_material_names() { + let paths = EquipmentPaths { + default: "piece.glb".into(), + female: None, + material: None, + palette_zones: parse_equipment_palette(&serde_json::json!({ + "palette": { + "zones": [ + { "slots": ["c1"], "default": "#303030" }, + { "slots": ["PF2_Cloth"], "default": "#89cff0" } + ] + } + })), + hide_body_zones: BodyZoneMask::default(), + }; + assert_eq!( + palette_zone_color( + &paths, + Some("boots_canvas_ankle_c1.001"), + &["#102030".into()] + ), + parse_hex_color("#102030") + ); + assert_eq!( + palette_zone_color(&paths, Some("PF2_Cloth.001"), &[]), + parse_hex_color("#89cff0") + ); + assert_eq!( + palette_zone_color(&paths, Some("fixed_hardware"), &[]), + None + ); + } + + #[test] + fn weapon_rig_routing() { + assert_eq!( + rig_for_weapon_id(Some("slugthrower")), + Some(WeaponRigKind::Slugthrower) + ); + assert_eq!( + rig_for_weapon_id(Some("scrap_rifle")), + Some(WeaponRigKind::Slugthrower) + ); + assert_eq!( + rig_for_weapon_id(Some("vibrosword")), + Some(WeaponRigKind::Vibrosword) + ); + assert_eq!( + rig_for_weapon_id(Some("plasma_blade")), + Some(WeaponRigKind::PlasmaHilt) + ); + assert_eq!(rig_for_weapon_id(Some("bandage")), None); + assert_eq!(rig_for_weapon_id(None), None); + } + + #[test] + fn weapon_mount_uses_authored_hand_local_transform() { + let json = br#"{ + "scale_to_pawn": 1.25, + "attach": "two_hand", + "mount_hand_r_local": { + "pos": [-0.03788, 0.14408, 0.05585], + "quat": [-0.09279, 0.83834, 0.53009, -0.08703] + }, + "sockets": { + "grip": [0.0, 0.0, 0.0], + "foregrip": [0.0, 0.2, 0.01], + "muzzle": [0.0, 0.0, 0.8], + "foregrip_contact": [0.01, -0.03, -0.04] + }, + "hold": { + "resting_yaw_deg": 3.0 + }, + "stow_socket": { + "space": "spine_03_local", + "pos": [0.16, 0.0, -0.14], + "rot_deg": [85.0, -45.0, 0.0] + } + }"#; + let hand_spec = parse_weapon_hand_spec(json).expect("authored hand spec"); + let (pos, rotation, scale) = hand_spec.mount.to_trs(); + assert!((pos.x + 0.03788).abs() < 1.0e-5); + assert!((pos.y - 0.14408).abs() < 1.0e-5); + assert!((pos.z - 0.05585).abs() < 1.0e-5); + assert!((rotation.x.abs() - 0.09279).abs() < 1.0e-4); + assert!((rotation.y.abs() - 0.83834).abs() < 1.0e-4); + assert!((scale.x - 1.0).abs() < 1.0e-5); + assert!((hand_spec.foregrip.y - 0.2).abs() < 1.0e-5); + assert!((hand_spec.foregrip_contact.x - 0.01).abs() < 1.0e-5); + assert!((hand_spec.resting_yaw_rad.to_degrees() - 3.0).abs() < 1.0e-5); + assert_eq!(hand_spec.scale_to_pawn, Some(1.25)); + assert!(hand_spec.two_handed); + let stow = hand_spec.stow.expect("authored stow socket"); + assert_eq!(stow.bone, "spine_03"); + let (stow_pos, _, _) = stow.mount.to_trs(); + assert!((stow_pos.x - 0.16).abs() < 1.0e-5); + assert!((stow_pos.z + 0.14).abs() < 1.0e-5); + assert!((stow.arc_lift - 0.14).abs() < 1.0e-5); + assert_eq!( + WeaponRigKind::Slugthrower.attach_stable_id(), + "assets/pawn-pack/slugthrower_attach.json" + ); + } +} diff --git a/client-rust/source/app/src/pawn/creatures.rs b/client-rust/source/app/src/pawn/creatures.rs new file mode 100644 index 00000000..efa618e0 --- /dev/null +++ b/client-rust/source/app/src/pawn/creatures.rs @@ -0,0 +1,194 @@ +//! Creature lane — port of the rigged-creature routing in +//! `client-3d/src/render/pawns.ts` (`CREATURE_SPECIES_BY_SPRITE`, +//! `resolveCreatureAnimIntent`). Creatures are ordinary skinned GLBs (clips +//! `idle`/`walk`/`rest`/`feed`) so they reuse `PawnTemplate`; this adds the +//! sprite→species registry and the idle/walk/rest clip selection. + +use super::pack::PawnTemplate; + +const WALK_TIMESCALE_PER_CELLPERSEC: f32 = 1.0; +const WALK_TIMESCALE_MIN: f32 = 0.5; +const WALK_TIMESCALE_MAX: f32 = 1.6; +/// A creature is considered moving above this ground speed (cells/s). +const MOVE_START: f32 = 0.12; + +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct CreatureSpecies { + pub species_id: &'static str, + pub asset_path: &'static str, + pub mesh_scale: f32, + pub shadow_x: f32, + pub shadow_z: f32, +} + +/// Exact sprite key → species (the only creature routing table). +pub fn species_for_sprite(sprite: &str) -> Option { + Some(match sprite { + "creature-bellback-adult" => CreatureSpecies { + species_id: "bellback", + asset_path: "/assets/creatures/bellback_adult.glb", + mesh_scale: 1.0, + shadow_x: 0.5, + shadow_z: 2.2, + }, + "creature-pebblehorn-adult" => CreatureSpecies { + species_id: "pebblehorn", + asset_path: "/assets/creatures/pebblehorn_adult.glb", + mesh_scale: 1.0, + shadow_x: 1.45, + shadow_z: 1.19, + }, + "creature-snufflefin-adult" => CreatureSpecies { + species_id: "snufflefin", + asset_path: "/assets/creatures/snufflefin_adult.glb", + mesh_scale: 2.4, + shadow_x: 0.72, + shadow_z: 3.29, + }, + "creature-pocketclod-adult" => CreatureSpecies { + species_id: "pocketclod", + asset_path: "/assets/creatures/pocketclod_adult.glb", + mesh_scale: 1.5, + shadow_x: 0.95, + shadow_z: 0.96, + }, + "creature-mossmuff-adult" => CreatureSpecies { + species_id: "mossmuff", + asset_path: "/assets/creatures/mossmuff_adult.glb", + mesh_scale: 1.0, + shadow_x: 1.84, + shadow_z: 1.56, + }, + "creature-dapplepod-adult" => CreatureSpecies { + species_id: "dapplepod", + asset_path: "/assets/creatures/dapplepod_adult.glb", + mesh_scale: 1.3, + shadow_x: 0.68, + shadow_z: 1.81, + }, + _ => return None, + }) +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum CreatureClip { + Idle, + Walk, + Rest, +} + +impl CreatureClip { + pub fn name(self) -> &'static str { + match self { + CreatureClip::Idle => "idle", + CreatureClip::Walk => "walk", + CreatureClip::Rest => "rest", + } + } +} + +/// Pure state→clip mapping: dead/downed → rest, moving → walk, else idle. +pub fn resolve_creature_clip(speed_cells: f32, alive: bool) -> CreatureClip { + if !alive { + CreatureClip::Rest + } else if speed_cells >= MOVE_START { + CreatureClip::Walk + } else { + CreatureClip::Idle + } +} + +/// Walk clip time-scale: ~1× per cell/s, clamped so slow wander doesn't freeze +/// and far bursts don't strobe. +pub fn walk_timescale(speed_cells: f32) -> f32 { + (speed_cells * WALK_TIMESCALE_PER_CELLPERSEC).clamp(WALK_TIMESCALE_MIN, WALK_TIMESCALE_MAX) +} + +/// A creature instance: a `PawnTemplate` (the skinned GLB) + clip playback. +pub struct CreatureAnimator { + pose: Vec, + palette: Vec<[f32; 16]>, + time: f32, + clip: CreatureClip, +} + +impl CreatureAnimator { + pub fn new(template: &PawnTemplate) -> Self { + CreatureAnimator { + pose: template.rest_pose(), + palette: Vec::with_capacity(template.joint_count()), + time: 0.0, + clip: CreatureClip::Idle, + } + } + + pub fn update( + &mut self, + template: &mut PawnTemplate, + speed_cells: f32, + alive: bool, + dt: f32, + ) -> &[[f32; 16]] { + self.clip = resolve_creature_clip(speed_cells, alive); + let name = self.clip.name(); + let ts = if self.clip == CreatureClip::Walk { + walk_timescale(speed_cells) + } else { + 1.0 + }; + let duration = template + .animation(name) + .map(|a| a.duration.max(0.001)) + .unwrap_or(1.0); + self.time = (self.time + dt * ts) % duration; + template.pose_at(name, self.time, &mut self.pose); + template + .skeleton + .compute_palette(&self.pose, &mut self.palette); + &self.palette + } + + pub fn palette(&self) -> &[[f32; 16]] { + &self.palette + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_lookup() { + let b = species_for_sprite("creature-bellback-adult").unwrap(); + assert_eq!(b.species_id, "bellback"); + assert_eq!( + species_for_sprite("creature-snufflefin-adult") + .unwrap() + .mesh_scale, + 2.4 + ); + assert!(species_for_sprite("player").is_none()); + } + + #[test] + fn clip_selection() { + assert_eq!(resolve_creature_clip(0.0, true), CreatureClip::Idle); + assert_eq!(resolve_creature_clip(1.0, true), CreatureClip::Walk); + assert_eq!(resolve_creature_clip(1.0, false), CreatureClip::Rest); + assert!((walk_timescale(0.1) - 0.5).abs() < 1e-6); // clamped up + assert!((walk_timescale(9.0) - 1.6).abs() < 1e-6); // clamped down + } + + #[test] + fn loads_real_creature_template() { + let path = "../../../client-3d/public/assets/creatures/bellback_adult.glb"; + let Ok(bytes) = std::fs::read(path) else { + eprintln!("skip: {path} not present"); + return; + }; + let tpl = PawnTemplate::from_bytes(&bytes).expect("parse creature"); + assert!(tpl.joint_count() > 0); + let clips = tpl.clip_names(); + assert!(clips.contains(&"idle") && clips.contains(&"walk") && clips.contains(&"rest")); + } +} diff --git a/client-rust/source/app/src/pawn/face.rs b/client-rust/source/app/src/pawn/face.rs new file mode 100644 index 00000000..edde07fd --- /dev/null +++ b/client-rust/source/app/src/pawn/face.rs @@ -0,0 +1,311 @@ +//! Face-kit compositor — port of the transparent texture side of +//! `client-3d/src/render/faceDecal.ts` (+ `assets/faceKit/face-kit`). +//! It chroma-keys the atlas skin colour and returns only eyes, brows, nose, and +//! mouth as straight RGBA. The pawn's opaque head skin stays underneath; baking +//! another skin rectangle into this texture is the dark-face regression this +//! module must not reintroduce. Head-overlay geometry attachment remains a +//! follow-on render integration. +//! +//! Atlas contract from `face-kit/metadata/atlas-layout.json`: a 4×2 grid of 8 +//! named styles per feature sheet; `backgroundKey` [202,136,97] is transparent. + +use successor_engine_core::image::{decode_png, ImageError, RgbaImage}; + +pub const FACE_TEXTURE_SIZE: u32 = 256; +const GRID_COLS: u32 = 4; +const GRID_ROWS: u32 = 2; +const BG_KEY: [u8; 3] = [202, 136, 97]; + +/// The eight atlas style cells in grid order. +pub const CELL_ORDER: [&str; 8] = [ + "stoic", "rogue", "youth", "ghost", "sharp", "feral", "regal", "veteran", +]; + +/// Decoded feature sheets. +pub struct FaceKit { + pub eyes: RgbaImage, + pub brows: RgbaImage, + pub noses: RgbaImage, + pub mouths: RgbaImage, +} + +impl FaceKit { + /// Load the four feature sheets from a face-kit asset directory. + pub fn load(face_kit_dir: &str) -> Result { + let read = |name: &str| -> Result { + let path = format!("{face_kit_dir}/assets/{name}"); + let bytes = std::fs::read(&path).map_err(|_| ImageError::Truncated)?; + decode_png(&bytes) + }; + Ok(FaceKit { + eyes: read("face-eyes-v3.png")?, + brows: read("face-brows-v3.png")?, + noses: read("face-noses-v3.png")?, + mouths: read("face-mouths-v3.png")?, + }) + } +} + +/// Resolve a style name to its grid index (falls back to 0/"stoic"). +pub fn style_index(name: &str) -> usize { + CELL_ORDER.iter().position(|&c| c == name).unwrap_or(0) +} + +/// Distance from the sampled canvas colour below which a pixel is pure +/// backdrop, and the distance at which a pixel is a fully opaque feature. +/// +/// Measured against the shipped `RB_Face` panels: 93% of the texture sits +/// within 9 of its own border colour (the flat skin canvas), the 10..19 band +/// holds 0.15%, and every painted feature lies past 20. Keying at 12..40 +/// therefore erases the canvas and its shading drift while keeping brows, +/// eyes, nose, and mouth, with a soft edge in between. +const CANVAS_KEEP: f32 = 12.0; +const FEATURE_FULL: f32 = 40.0; + +/// Erase an authored face panel's baked skin canvas, leaving only the painted +/// features. +/// +/// The body GLB ships `RB_Face` as a fully opaque rectangle whose background is +/// a fixed skin colour. Drawn untinted over a tinted head that reads as a skin +/// patch layered on the face; drawn tinted it double-darkens. Keying the +/// canvas out lets the head's own tinted skin show through, so one face asset +/// serves every skin tone. +pub fn key_face_panel(panel: &RgbaImage) -> RgbaImage { + let mut out = RgbaImage { + width: panel.width, + height: panel.height, + pixels: panel.pixels.clone(), + }; + let Some(canvas) = border_colour(panel) else { + return out; + }; + for index in (0..out.pixels.len()).step_by(4) { + let distance = chebyshev(&out.pixels[index..index + 3], canvas); + let t = ((distance - CANVAS_KEEP) / (FEATURE_FULL - CANVAS_KEEP)).clamp(0.0, 1.0); + let keep = t * t * (3.0 - 2.0 * t); + let alpha = out.pixels[index + 3] as f32 * keep; + out.pixels[index + 3] = alpha.round().clamp(0.0, 255.0) as u8; + } + out +} + +/// Mean colour of the panel's one-pixel border, which is always canvas. +fn border_colour(panel: &RgbaImage) -> Option<[u8; 3]> { + let (w, h) = (panel.width as usize, panel.height as usize); + if w < 2 || h < 2 { + return None; + } + let mut sums = [0u64; 3]; + let mut count = 0u64; + let mut sample = |x: usize, y: usize| { + let i = (y * w + x) * 4; + for (channel, sum) in sums.iter_mut().enumerate() { + *sum += panel.pixels[i + channel] as u64; + } + count += 1; + }; + for x in 0..w { + sample(x, 0); + sample(x, h - 1); + } + for y in 0..h { + sample(0, y); + sample(w - 1, y); + } + (count > 0).then(|| { + [ + (sums[0] / count) as u8, + (sums[1] / count) as u8, + (sums[2] / count) as u8, + ] + }) +} + +fn chebyshev(pixel: &[u8], reference: [u8; 3]) -> f32 { + (0..3) + .map(|c| (pixel[c] as i32 - reference[c] as i32).abs()) + .max() + .unwrap_or(0) as f32 +} + +/// Composite a transparent face-component overlay for one style. Later +/// features paint over earlier ones (brows/nose/mouth over eyes). +pub fn render_face_overlay(kit: &FaceKit, style: usize) -> RgbaImage { + let size = FACE_TEXTURE_SIZE; + let mut out = RgbaImage { + width: size, + height: size, + pixels: vec![0u8; (size * size * 4) as usize], + }; + composite_cell(&mut out, &kit.eyes, style); + composite_cell(&mut out, &kit.brows, style); + composite_cell(&mut out, &kit.noses, style); + composite_cell(&mut out, &kit.mouths, style); + out +} + +/// Composite one atlas cell with a soft background key and straight-alpha over. +fn composite_cell(out: &mut RgbaImage, sheet: &RgbaImage, style: usize) { + let cell_w = sheet.width / GRID_COLS; + let cell_h = sheet.height / GRID_ROWS; + if cell_w == 0 || cell_h == 0 { + return; + } + let col = (style as u32) % GRID_COLS; + let row = ((style as u32) / GRID_COLS) % GRID_ROWS; + let src_x0 = col * cell_w; + let src_y0 = row * cell_h; + for oy in 0..out.height { + let sy = src_y0 + oy * cell_h / out.height; + for ox in 0..out.width { + let sx = src_x0 + ox * cell_w / out.width; + let si = ((sy * sheet.width + sx) * 4) as usize; + let (r, g, b, a) = ( + sheet.pixels[si], + sheet.pixels[si + 1], + sheet.pixels[si + 2], + sheet.pixels[si + 3], + ); + let source_alpha = a as f32 / 255.0 * mark_strength(r, g, b); + if source_alpha <= 0.0 { + continue; + } + let di = ((oy * out.width + ox) * 4) as usize; + over_straight(&mut out.pixels[di..di + 4], [r, g, b], source_alpha); + } + } +} + +fn mark_strength(r: u8, g: u8, b: u8) -> f32 { + let distance = [ + (r as i32 - BG_KEY[0] as i32).abs(), + (g as i32 - BG_KEY[1] as i32).abs(), + (b as i32 - BG_KEY[2] as i32).abs(), + ] + .into_iter() + .max() + .unwrap_or(0) as f32; + let t = ((distance - 4.0) / 14.0).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +fn over_straight(dst: &mut [u8], source: [u8; 3], source_alpha: f32) { + let destination_alpha = dst[3] as f32 / 255.0; + let inverse = 1.0 - source_alpha; + let output_alpha = source_alpha + destination_alpha * inverse; + if output_alpha <= 0.0 { + return; + } + for channel in 0..3 { + let premultiplied = source[channel] as f32 * source_alpha + + dst[channel] as f32 * destination_alpha * inverse; + dst[channel] = (premultiplied / output_alpha).round().clamp(0.0, 255.0) as u8; + } + dst[3] = (output_alpha * 255.0).round().clamp(0.0, 255.0) as u8; +} + +#[cfg(test)] +mod tests { + use super::*; + + const FACE_KIT: &str = "../../../client-3d/public/assets/face-kit"; + + #[test] + fn style_index_lookup() { + assert_eq!(style_index("stoic"), 0); + assert_eq!(style_index("veteran"), 7); + assert_eq!(style_index("unknown"), 0); + } + + #[test] + fn composites_background_erased_face_overlay() { + let Ok(kit) = FaceKit::load(FACE_KIT) else { + eprintln!("skip: face-kit not present"); + return; + }; + let tex = render_face_overlay(&kit, style_index("rogue")); + assert_eq!(tex.width, FACE_TEXTURE_SIZE); + assert_eq!(tex.height, FACE_TEXTURE_SIZE); + + let mut transparent = 0; + let mut painted = 0; + for pixel in tex.pixels.chunks_exact(4) { + if pixel[3] == 0 { + transparent += 1; + } else { + painted += 1; + } + } + assert!( + painted > 100, + "expected composited feature pixels, got {painted}" + ); + assert!( + transparent > (FACE_TEXTURE_SIZE * FACE_TEXTURE_SIZE / 2) as usize, + "skin-coloured atlas background survived the chroma key" + ); + for (x, y) in [ + (0, 0), + (FACE_TEXTURE_SIZE - 1, 0), + (0, FACE_TEXTURE_SIZE - 1), + (FACE_TEXTURE_SIZE - 1, FACE_TEXTURE_SIZE - 1), + ] { + let offset = ((y * FACE_TEXTURE_SIZE + x) * 4 + 3) as usize; + assert_eq!(tex.pixels[offset], 0, "corner ({x},{y}) is not transparent"); + } + } + + #[test] + fn keying_the_face_panel_erases_its_skin_canvas_and_keeps_features() { + // A shipped panel is a flat skin canvas with painted features on top. + // Keying must leave the canvas fully transparent so the head's own + // tinted skin reads through, and keep the features fully opaque. + let canvas = [204u8, 153, 120]; + let (w, h) = (32u32, 32u32); + let mut pixels = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + // A dark brow band across the middle; canvas everywhere else. + let feature = (12..20).contains(&y) && (6..26).contains(&x); + let rgb = if feature { [48u8, 32, 28] } else { canvas }; + pixels.extend_from_slice(&[rgb[0], rgb[1], rgb[2], 255]); + } + } + let panel = RgbaImage { + width: w, + height: h, + pixels, + }; + + let keyed = key_face_panel(&panel); + let alpha_at = |x: u32, y: u32| keyed.pixels[((y * w + x) * 4 + 3) as usize]; + assert_eq!(alpha_at(1, 1), 0, "canvas corner must key out"); + assert_eq!(alpha_at(16, 4), 0, "canvas above the brow must key out"); + assert_eq!(alpha_at(16, 16), 255, "the painted brow must stay opaque"); + + // Colour is untouched; only alpha changes. + let feature_index = ((16 * w + 16) * 4) as usize; + assert_eq!( + &keyed.pixels[feature_index..feature_index + 3], + &[48, 32, 28] + ); + + // Canvas shading drift inside the keep band still keys out. + let mut drifted = RgbaImage { + width: w, + height: h, + pixels: vec![0u8; (w * h * 4) as usize], + }; + for index in (0..drifted.pixels.len()).step_by(4) { + drifted.pixels[index] = 213; + drifted.pixels[index + 1] = 162; + drifted.pixels[index + 2] = 129; + drifted.pixels[index + 3] = 255; + } + let drifted = key_face_panel(&drifted); + assert!( + drifted.pixels.iter().skip(3).step_by(4).all(|a| *a == 0), + "a uniformly shaded canvas must key out entirely" + ); + } +} diff --git a/client-rust/source/app/src/pawn/lod.rs b/client-rust/source/app/src/pawn/lod.rs new file mode 100644 index 00000000..22cc4246 --- /dev/null +++ b/client-rust/source/app/src/pawn/lod.rs @@ -0,0 +1,84 @@ +//! Pawn LOD tier gating — port of `config.pawn.lod` (`render/pawns.ts`): actors +//! within `hiFiRadiusCells` of the camera focus run the full animation mixer +//! (HI-FI); beyond, they drop to SIMULATION tier (still stream/move/pick a gait +//! clip, but skip the per-frame mixer eval). A 4-cell hysteresis stops tier +//! thrash at the boundary. + +const HI_FI_RADIUS_CELLS: f32 = 40.0; +const HYSTERESIS_CELLS: f32 = 4.0; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum LodTier { + /// Full mixer + weapon IK every frame. + HiFi, + /// Streamed/positioned but skips the expensive mixer eval. + Sim, +} + +/// Per-actor LOD latch with boundary hysteresis. +#[derive(Clone, Copy, Debug)] +pub struct PawnLod { + tier: LodTier, +} + +impl Default for PawnLod { + fn default() -> Self { + PawnLod { tier: LodTier::Sim } + } +} + +impl PawnLod { + /// Update the tier from the actor's distance (cells) to the camera focus. + /// Enters HI-FI within the radius; only drops back to SIM past + /// `radius + hysteresis` so an actor loitering at the edge does not thrash. + pub fn update(&mut self, distance_cells: f32) -> LodTier { + match self.tier { + LodTier::HiFi => { + if distance_cells > HI_FI_RADIUS_CELLS + HYSTERESIS_CELLS { + self.tier = LodTier::Sim; + } + } + LodTier::Sim => { + if distance_cells <= HI_FI_RADIUS_CELLS { + self.tier = LodTier::HiFi; + } + } + } + self.tier + } + + pub fn tier(&self) -> LodTier { + self.tier + } + + /// Whether the full animation mixer should run this frame. + pub fn runs_mixer(&self) -> bool { + self.tier == LodTier::HiFi + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enters_hifi_within_radius() { + let mut lod = PawnLod::default(); + assert_eq!(lod.update(30.0), LodTier::HiFi); + assert!(lod.runs_mixer()); + } + + #[test] + fn hysteresis_holds_tier_at_boundary() { + let mut lod = PawnLod::default(); + lod.update(20.0); // HiFi + // Between radius (40) and radius+hysteresis (44): stays HiFi. + assert_eq!(lod.update(42.0), LodTier::HiFi); + // Past hysteresis: drops to Sim. + assert_eq!(lod.update(45.0), LodTier::Sim); + // Between radius and radius+hysteresis coming back: stays Sim. + assert_eq!(lod.update(41.0), LodTier::Sim); + // At/under radius: back to HiFi. + assert_eq!(lod.update(40.0), LodTier::HiFi); + } +} diff --git a/client-rust/source/app/src/pawn/mod.rs b/client-rust/source/app/src/pawn/mod.rs new file mode 100644 index 00000000..bab9895e --- /dev/null +++ b/client-rust/source/app/src/pawn/mod.rs @@ -0,0 +1,13 @@ +//! Animated humanoid pawns: body templates loaded from PawnForge GLB packs, +//! the per-actor animation lane driving the skinning palette, and (later) +//! equipment/appearance. Builds on `engine-core::{glb, anim}` and the renderer's +//! skinned-mesh path proven in Wave 1. + +pub mod animator; +pub mod appearance; +pub mod catalog; +pub mod creatures; +pub mod face; +pub mod lod; +pub mod pack; +pub mod scene; diff --git a/client-rust/source/app/src/pawn/pack.rs b/client-rust/source/app/src/pawn/pack.rs new file mode 100644 index 00000000..cb884346 --- /dev/null +++ b/client-rust/source/app/src/pawn/pack.rs @@ -0,0 +1,233 @@ +//! Pawn pack loader: a PawnForge body GLB (`pawn_male.glb` / `pawn_female.glb` / +//! special bodies) → a reusable template of skinned mesh parts + skeleton + +//! animation clips. Parsing/baking is GPU-free (unit-testable against the real +//! asset); `upload` pushes the baked parts to the renderer for per-actor draws. + +use successor_engine_core::anim::{apply_animation, JointTransform, Skeleton}; +use successor_engine_core::glb::{self, GlbDocument, GlbError}; +use successor_engine_render::components::{MaterialId, MeshId}; +use successor_engine_render::gpu::Gpu; +use successor_engine_render::renderer::Renderer; + +/// A skinned mesh part before GPU upload: interleaved `SKINNED_MESH_LAYOUT` +/// vertices + indices + base color. +pub struct BakedPart { + pub vertices: Vec, + pub indices: Vec, + pub color: [f32; 4], +} + +/// GPU-free body template: skeleton, animation clips (via the retained doc), and +/// baked skinned parts. +pub struct PawnTemplate { + pub skeleton: Skeleton, + pub parts: Vec, + doc: GlbDocument, +} + +/// GPU-resident parts (one per `BakedPart`). +pub struct PawnGpuParts { + pub parts: Vec<(MeshId, MaterialId)>, + pub material_names: Vec>, +} + +impl PawnTemplate { + pub fn from_bytes(bytes: &[u8]) -> Result { + let doc = glb::parse(bytes)?; + let skeleton = Skeleton::from_document(&doc, 0).ok_or(GlbError::Unsupported("no skin"))?; + let mut parts = Vec::new(); + for node in &doc.nodes { + let Some(mi) = node.mesh else { continue }; + let Some(mesh) = doc.meshes.get(mi) else { + continue; + }; + for prim in &mesh.primitives { + if prim.positions.is_empty() || prim.joints.is_empty() { + continue; // skinned parts only + } + let color = prim + .material + .and_then(|i| doc.materials.get(i)) + .map(|m| m.base_color) + .unwrap_or([0.72, 0.70, 0.67, 1.0]); + parts.push(BakedPart { + vertices: bake_skinned(prim), + indices: prim.indices.clone(), + color, + }); + } + } + Ok(PawnTemplate { + skeleton, + parts, + doc, + }) + } + + /// Height of the skinned source geometry in its authored units. + pub fn authored_height(&self) -> Option { + let mut min_y = f32::MAX; + let mut max_y = f32::MIN; + for part in &self.parts { + for vertex in part.vertices.chunks_exact(16) { + min_y = min_y.min(vertex[1]); + max_y = max_y.max(vertex[1]); + } + } + let height = max_y - min_y; + (height.is_finite() && height > 1.0e-4).then_some(height) + } + + /// Uniform conversion from authored units to a canonical world-space + /// height. Invalid or empty source geometry fails closed. + pub fn uniform_scale_for_height(&self, target_height: f32) -> Option { + if !target_height.is_finite() || target_height <= 0.0 { + return None; + } + Some(target_height / self.authored_height()?) + } + + pub fn clip_names(&self) -> Vec<&str> { + self.doc + .animations + .iter() + .filter_map(|a| a.name.as_deref()) + .collect() + } + + pub fn animation(&self, name: &str) -> Option<&glb::GlbAnimation> { + self.doc.animation_by_name(name) + } + + pub fn joint_count(&self) -> usize { + self.skeleton.joint_count() + } + + /// A rest pose buffer sized for this skeleton. + pub fn rest_pose(&self) -> Vec { + self.skeleton.rest_pose() + } + + /// Sample a clip at `time` into a reusable `pose` (reset to rest first). + pub fn pose_at(&self, clip: &str, time: f32, pose: &mut Vec) { + pose.clear(); + pose.extend_from_slice(&self.skeleton.rest); + if let Some(anim) = self.animation(clip) { + apply_animation(anim, time, pose); + } + } + + /// Upload the baked parts to the renderer (skinned meshes + materials). + pub fn upload( + &self, + gpu: &mut G, + renderer: &mut Renderer, + ) -> Result { + let prepared = successor_engine_render::model::prepare_glb(&self.doc) + .map_err(|_| GlbError::Unsupported("pawn model upload"))?; + let uploaded = + successor_engine_render::model::upload_prepared_glb(renderer, gpu, &self.doc, prepared) + .map_err(|_| GlbError::Unsupported("pawn model upload"))?; + let (parts, material_names) = uploaded + .primitives + .into_iter() + .filter_map(|part| { + let primitive = self + .doc + .meshes + .get(part.source_mesh)? + .primitives + .get(part.source_primitive)?; + if primitive.joints.is_empty() { + return None; + } + let material_name = primitive + .material + .and_then(|index| self.doc.materials.get(index)) + .and_then(|material| material.name.clone()); + Some(((part.mesh, part.material), material_name)) + }) + .unzip(); + Ok(PawnGpuParts { + parts, + material_names, + }) + } +} + +/// Interleave one primitive into `SKINNED_MESH_LAYOUT` (pos3,norm3,uv2,joints4,weights4). +fn bake_skinned(prim: &glb::GlbPrimitive) -> Vec { + let n = prim.positions.len(); + let mut out = Vec::with_capacity(n * 16); + for i in 0..n { + let p = prim.positions[i]; + let nrm = prim.normals.get(i).copied().unwrap_or([0.0, 1.0, 0.0]); + let uv = prim.uvs.get(i).copied().unwrap_or([0.0, 0.0]); + let j = prim.joints.get(i).copied().unwrap_or([0, 0, 0, 0]); + let w = prim.weights.get(i).copied().unwrap_or([1.0, 0.0, 0.0, 0.0]); + out.extend_from_slice(&[ + p[0], + p[1], + p[2], + nrm[0], + nrm[1], + nrm[2], + uv[0], + uv[1], + j[0] as f32, + j[1] as f32, + j[2] as f32, + j[3] as f32, + w[0], + w[1], + w[2], + w[3], + ]); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + // Path from the app crate root (client-rust/source/app) to the repo asset. + const PAWN_MALE: &str = "../../../client-3d/public/assets/pawn-pack/pawn_male.glb"; + + #[test] + fn loads_pawn_male_template() { + let Ok(bytes) = std::fs::read(PAWN_MALE) else { + eprintln!("skip: {PAWN_MALE} not present"); + return; + }; + let tpl = PawnTemplate::from_bytes(&bytes).expect("parse pawn"); + assert_eq!(tpl.joint_count(), 50, "pawn rig joint count"); + assert!(!tpl.parts.is_empty(), "has skinned parts"); + let clips = tpl.clip_names(); + assert!(clips.contains(&"idle"), "idle clip present"); + assert!( + clips.contains(&"walk_f") || clips.iter().any(|c| c.contains("walk")), + "a walk clip present" + ); + // Skinned vertices are 16 floats each. + assert_eq!(tpl.parts[0].vertices.len() % 16, 0); + let authored_height = tpl.authored_height().expect("finite pawn height"); + let scale = tpl + .uniform_scale_for_height(crate::world::ADULT_PAWN_HEIGHT_METERS) + .expect("pawn can normalize to world units"); + assert!(authored_height > 0.0); + assert!((authored_height * scale - crate::world::ADULT_PAWN_HEIGHT_METERS).abs() < 1.0e-5); + } + + #[test] + fn pose_at_resets_to_rest_then_animates() { + let Ok(bytes) = std::fs::read(PAWN_MALE) else { + return; + }; + let tpl = PawnTemplate::from_bytes(&bytes).expect("parse"); + let mut pose = tpl.rest_pose(); + let len = pose.len(); + tpl.pose_at("idle", 0.5, &mut pose); + assert_eq!(pose.len(), len, "pose stays skeleton-sized"); + } +} diff --git a/client-rust/source/app/src/pawn/scene.rs b/client-rust/source/app/src/pawn/scene.rs new file mode 100644 index 00000000..e0888b51 --- /dev/null +++ b/client-rust/source/app/src/pawn/scene.rs @@ -0,0 +1,363 @@ +//! `--demo pawns`: load a pawn body pack and render a row of animated pawns at +//! different gaits (idle/walk/run) and skin/faction tints, exercising the +//! template + animator + appearance integration end-to-end. Native visual QA. + +use successor_engine_core::ecs::{Entity, WorldOps}; +use successor_engine_core::math::{vec3, Mat4, Quat, Vec3}; +use successor_engine_render::components::{ + CamTarget, Camera, DirectionalLight, MeshRenderer, Projection, RectNorm, SkinRef, Transform, +}; +use successor_engine_render::gi::GiOccluder; +use successor_engine_render::gpu::{ClearSpec, Gpu}; +use successor_engine_render::primitives; +use successor_engine_render::renderer::Renderer; + +use super::animator::{weapon_hand_bone, PawnAnimator, WeaponLane}; +use super::appearance::{faction_tinted, skin_tint}; +use super::catalog::parse_weapon_hand_spec; +use super::pack::{PawnGpuParts, PawnTemplate}; +use crate::GameWorld; + +struct PawnActor { + animator: PawnAnimator, + entities: Vec, + speed: f32, + lane: WeaponLane, + against_facing: bool, + alive: bool, + pos_x: f32, +} + +/// A weapon mesh socketed to a pawn's hand bone. +struct WeaponRig { + entities: Vec<(Entity, Mat4)>, + actor_index: usize, + hand: usize, + mount: Mat4, + foregrip: Vec3, +} + +pub struct PawnScene { + pub world: GameWorld, + pub renderer: Renderer, + template: PawnTemplate, + pawn_scale: f32, + actors: Vec, + weapon: Option, + camera: Entity, + center: Vec3, + view: PawnView, +} + +#[derive(Clone, Copy)] +pub struct PawnView { + pub distance: f32, + pub height: f32, + pub yaw_radians: f32, + pub orbit_speed: f32, +} + +impl PawnView { + fn eye(self, center: Vec3, frame: u64) -> Vec3 { + let angle = self.yaw_radians + frame as f32 * self.orbit_speed; + center.add(vec3( + angle.sin() * self.distance, + self.height, + angle.cos() * self.distance, + )) + } +} + +impl PawnScene { + #[allow(clippy::result_unit_err)] + pub fn build(gpu: &mut G, bytes: &[u8], view: PawnView) -> Result { + let template = PawnTemplate::from_bytes(bytes).map_err(|_| ())?; + let pawn_scale = template + .uniform_scale_for_height(crate::world::ADULT_PAWN_HEIGHT_METERS) + .ok_or(())?; + let mut renderer = crate::configured_renderer(gpu).expect("renderer initialization failed"); + renderer.set_ambient(0.45); + renderer.set_fog([0.09, 0.10, 0.12], 40.0, 80.0); + let mut world = GameWorld::new(); + let gpu_parts: PawnGpuParts = template.upload(gpu, &mut renderer).map_err(|_| ())?; + + // A row of pawns, each with a gait + tint. + #[allow(clippy::type_complexity)] + let specs: [(f32, WeaponLane, bool, Option<[f32; 3]>, Option<&str>); 5] = [ + (0.0, WeaponLane::Unarmed, false, None, Some("#cc9978")), + (1.0, WeaponLane::Unarmed, false, None, Some("#8d5a3c")), + ( + 3.0, + WeaponLane::Rifle, + false, + Some([0.8, 0.2, 0.2]), + Some("#e0b48a"), + ), + (1.0, WeaponLane::Unarmed, true, None, Some("#5b3a29")), + (0.0, WeaponLane::Unarmed, false, None, None), + ]; + let mut actors = Vec::new(); + for (i, (speed, lane, against, faction, skin)) in specs.iter().enumerate() { + let base = skin_tint(*skin); + let color = faction_tinted(base, *faction); + let x = i as f32 * 1.6 - (specs.len() as f32 - 1.0) * 0.8; + let mut entities = Vec::new(); + for ((mesh, authored_material), material_name) in + gpu_parts.parts.iter().zip(&gpu_parts.material_names) + { + let material = if material_name.as_deref() == Some("RB_Face") { + *authored_material + } else { + let mut desc = renderer + .material_desc(*authored_material) + .unwrap_or_default(); + desc.base_color = color; + desc.blend = color[3] < 1.0; + renderer.add_material_desc(desc) + }; + let e = world.spawn(); + world.set_component( + e, + Transform { + pos: vec3(x, 0.0, 0.0), + rot: Quat::IDENTITY, + scale: vec3(pawn_scale, pawn_scale, pawn_scale), + }, + ); + world.set_component( + e, + MeshRenderer { + mesh: *mesh, + material, + viewport_mask: 0b1, + skin: SkinRef::NONE, + }, + ); + entities.push(e); + } + actors.push(PawnActor { + animator: PawnAnimator::new(&template), + entities, + speed: *speed, + lane: *lane, + against_facing: *against, + alive: true, + pos_x: x, + }); + } + + let center = vec3(0.0, 1.0, 0.0); + renderer.gi_set_focus([center.x, center.y, center.z]); + renderer.gi_set_ground_albedo([0.38, 0.40, 0.44]); + let (cube_vertices, cube_indices) = primitives::cube(); + let cube = renderer.upload_mesh(gpu, &cube_vertices, &cube_indices); + let ground_material = + renderer.add_material_desc(successor_engine_render::renderer::MaterialDesc { + base_color: [0.38, 0.40, 0.44, 1.0], + metallic: 0.0, + roughness: 0.92, + ..successor_engine_render::renderer::MaterialDesc::default() + }); + let ground = world.spawn(); + world.set_component( + ground, + Transform { + pos: vec3(0.0, -0.1, 0.0), + rot: Quat::IDENTITY, + scale: vec3(18.0, 0.2, 14.0), + }, + ); + world.set_component( + ground, + MeshRenderer { + mesh: cube, + material: ground_material, + viewport_mask: 0b1, + ..Default::default() + }, + ); + let wall_center = vec3(-4.5, 1.5, -1.5); + let wall_half = vec3(0.35, 1.5, 2.5); + let wall_material = + renderer.add_material_desc(successor_engine_render::renderer::MaterialDesc { + base_color: [0.16, 0.38, 0.78, 1.0], + metallic: 0.0, + roughness: 0.82, + ..successor_engine_render::renderer::MaterialDesc::default() + }); + let wall = world.spawn(); + world.set_component( + wall, + Transform { + pos: wall_center, + rot: Quat::IDENTITY, + scale: vec3(wall_half.x * 2.0, wall_half.y * 2.0, wall_half.z * 2.0), + }, + ); + world.set_component( + wall, + MeshRenderer { + mesh: cube, + material: wall_material, + viewport_mask: 0b1, + ..Default::default() + }, + ); + renderer.gi_set_occluders(&[GiOccluder { + center: [wall_center.x, wall_center.y, wall_center.z], + half_extents: [wall_half.x, wall_half.y, wall_half.z], + yaw: 0.0, + albedo: [0.16, 0.38, 0.78], + }]); + + let sun = world.spawn(); + world.set_component( + sun, + DirectionalLight { + // Side-lit at a forty-five-degree elevation from the initial view. + dir: vec3(-1.0, -1.0, 0.0).normalize(), + color: [1.0, 0.98, 0.92], + cast_shadows: true, + }, + ); + + let camera = world.spawn(); + world.set_component( + camera, + Camera { + viewport_id: 0, + order: 0, + projection: Projection::Perspective { + fovy: 45.0_f32.to_radians(), + near: 0.05, + far: 200.0, + }, + target: CamTarget::Screen(RectNorm::FULL), + clear: ClearSpec { + color: Some([0.09, 0.10, 0.12, 1.0]), + depth: Some(1.0), + }, + eye: view.eye(center, 0), + look_at: center, + up: Vec3::Y, + }, + ); + + // Socket a slugthrower to the rifle pawn's hand (best-effort). + let weapon = load_weapon(gpu, &mut renderer, &mut world, &template, &actors); + + Ok(PawnScene { + world, + renderer, + template, + pawn_scale, + actors, + weapon, + camera, + center, + view, + }) + } + + pub fn animate(&mut self, frame: u64) { + let dt = 1.0 / 60.0; + let eye = self.view.eye(self.center, frame); + if let Some(cam) = self.world.get_component::(self.camera) { + cam.eye = eye; + } + self.renderer.begin_skin_frame(); + for (idx, actor) in self.actors.iter_mut().enumerate() { + actor.animator.update( + &mut self.template, + actor.lane, + actor.speed, + actor.against_facing, + actor.alive, + None, + dt, + ); + if actor.alive { + if let Some(rig) = &self.weapon { + if rig.actor_index == idx { + actor.animator.apply_rifle_support_ik( + &mut self.template, + rig.mount, + rig.foregrip, + ); + } + } + } + let palette = actor.animator.palette(); + let count = palette.len() as u32; + let offset = self.renderer.push_skin_palette(palette); + for &e in &actor.entities { + if let Some(mr) = self.world.get_component::(e) { + mr.skin = SkinRef { offset, count }; + } + } + // Socket the weapon while this actor's bone globals are current. + if let Some(rig) = &self.weapon { + if rig.actor_index == idx { + let bone = self.template.skeleton.bone_global(rig.hand); + let world_mat = successor_engine_core::math::Mat4::from_trs( + vec3(actor.pos_x, 0.0, 0.0), + Quat::IDENTITY, + vec3(self.pawn_scale, self.pawn_scale, self.pawn_scale), + ) + .mul(bone) + .mul(rig.mount); + for &(entity, local) in &rig.entities { + let (t, r, s) = world_mat.mul(local).to_trs(); + if let Some(tr) = self.world.get_component::(entity) { + tr.pos = t; + tr.rot = r; + tr.scale = s; + } + } + } + } + } + } +} + +/// Load `slugthrower.glb` and spawn its parts for the first Rifle-lane pawn, +/// resolving the hand socket bone by name. Best-effort: returns `None` if the +/// asset is missing, no rifle pawn exists, or no hand bone is found. +fn load_weapon( + gpu: &mut G, + renderer: &mut Renderer, + world: &mut GameWorld, + template: &PawnTemplate, + actors: &[PawnActor], +) -> Option { + let actor_index = actors.iter().position(|a| a.lane == WeaponLane::Rifle)?; + let hand = weapon_hand_bone(template)?; + let bytes = std::fs::read("../client-3d/public/assets/pawn-pack/slugthrower.glb").ok()?; + let mount_bytes = + std::fs::read("../client-3d/public/assets/pawn-pack/slugthrower_attach.json").ok()?; + let hand_spec = parse_weapon_hand_spec(&mount_bytes)?; + let model = crate::assets::model::upload_static_model(gpu, renderer, &bytes).ok()?; + let mut entities = Vec::new(); + for part in model.parts { + let e = world.spawn(); + let (pos, rot, scale) = part.local.to_trs(); + world.set_component(e, Transform { pos, rot, scale }); + world.set_component( + e, + MeshRenderer { + mesh: part.mesh, + material: part.material, + viewport_mask: 0b1, + skin: SkinRef::NONE, + }, + ); + entities.push((e, part.local)); + } + Some(WeaponRig { + entities, + actor_index, + hand, + mount: hand_spec.mount, + foregrip: hand_spec.foregrip, + }) +} diff --git a/client-rust/source/app/src/persist.rs b/client-rust/source/app/src/persist.rs new file mode 100644 index 00000000..0b3d1470 --- /dev/null +++ b/client-rust/source/app/src/persist.rs @@ -0,0 +1,188 @@ +//! Scoped settings-document helper. +//! +//! Each [`SettingsScope`] maps to ONE platform blob (`Platform::load_settings` +//! / `save_settings`). Multiple subsystems persist into the same scope, so the +//! blob is a JSON object where every subsystem owns exactly one top-level key +//! (contract agreed with the shared-app owner): +//! +//! - `runtime` — `RuntimeSettings` (shared app owner) +//! - `theme` — UI theme id (Local) +//! - `toolbar` — toolbar doc, schema 3 (Local) +//! - `chat` — chat pane prefs (Local) +//! - `splitSnap` — inventory split-snap step (Local) +//! - `waypoints` — waypoint store (Character) +//! - `macros` — character-owned macros (Character) +//! - `firstSteps` — first-steps record (Character) +//! +//! Writers MUST read-modify-write and preserve unknown keys; a corrupt blob +//! resets only on write (the unreadable payload is replaced by a fresh object +//! carrying the new section — the documented per-field reset policy). + +use serde_json::{Map, Value}; +use successor_platform::{Platform, SettingsScope}; + +/// Parse a scope blob into its top-level object; corrupt/missing → empty. +fn parse_doc(bytes: Option>) -> Map { + bytes + .and_then(|b| serde_json::from_slice::(&b).ok()) + .and_then(|v| match v { + Value::Object(m) => Some(m), + _ => None, + }) + .unwrap_or_default() +} + +/// Read one subsystem section from a scope document. +pub fn load_section(platform: &P, scope: SettingsScope, key: &str) -> Option { + parse_doc(platform.load_settings(scope)).remove(key) +} + +/// Write one subsystem section, preserving every other key in the scope doc. +pub fn store_section( + platform: &mut P, + scope: SettingsScope, + key: &str, + value: Value, +) -> Result<(), String> { + let mut doc = parse_doc(platform.load_settings(scope)); + doc.insert(key.to_string(), value); + let bytes = serde_json::to_vec(&Value::Object(doc)).map_err(|e| e.to_string())?; + platform.save_settings(scope, &bytes) +} + +/// Remove one subsystem section (used by two-step deletes / resets). +pub fn remove_section( + platform: &mut P, + scope: SettingsScope, + key: &str, +) -> Result<(), String> { + let mut doc = parse_doc(platform.load_settings(scope)); + if doc.remove(key).is_none() { + return Ok(()); + } + let bytes = serde_json::to_vec(&Value::Object(doc)).map_err(|e| e.to_string())?; + platform.save_settings(scope, &bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use successor_platform::AssetError; + + struct MemPlatform { + blobs: HashMap<&'static str, Vec>, + } + impl MemPlatform { + fn new() -> Self { + Self { + blobs: HashMap::new(), + } + } + fn name(scope: SettingsScope) -> &'static str { + match scope { + SettingsScope::Local => "local", + SettingsScope::Account => "account", + SettingsScope::Character => "character", + } + } + } + impl Platform for MemPlatform { + fn monotonic_ms(&self) -> u64 { + 0 + } + fn logical_size(&self) -> (u32, u32) { + (1280, 720) + } + fn read_asset(&self, _stable_id: &str) -> Result, AssetError> { + Err(AssetError::Unreadable) + } + fn load_settings(&self, scope: SettingsScope) -> Option> { + self.blobs.get(Self::name(scope)).cloned() + } + fn save_settings(&mut self, scope: SettingsScope, bytes: &[u8]) -> Result<(), String> { + self.blobs.insert(Self::name(scope), bytes.to_vec()); + Ok(()) + } + fn report_fatal(&mut self, _message: &str) {} + } + + #[test] + fn sections_are_independent_and_preserved() { + let mut p = MemPlatform::new(); + store_section( + &mut p, + SettingsScope::Local, + "toolbar", + serde_json::json!({"schema": 3}), + ) + .unwrap(); + store_section( + &mut p, + SettingsScope::Local, + "theme", + serde_json::json!("amber"), + ) + .unwrap(); + // Unknown keys written by another subsystem survive our writes. + let mut doc = parse_doc(p.load_settings(SettingsScope::Local)); + doc.insert("runtime".into(), serde_json::json!({"zoom": 100})); + let bytes = serde_json::to_vec(&Value::Object(doc)).unwrap(); + p.save_settings(SettingsScope::Local, &bytes).unwrap(); + store_section( + &mut p, + SettingsScope::Local, + "theme", + serde_json::json!("oxide"), + ) + .unwrap(); + + assert_eq!( + load_section(&p, SettingsScope::Local, "theme"), + Some(serde_json::json!("oxide")) + ); + assert_eq!( + load_section(&p, SettingsScope::Local, "toolbar"), + Some(serde_json::json!({"schema": 3})) + ); + assert_eq!( + load_section(&p, SettingsScope::Local, "runtime"), + Some(serde_json::json!({"zoom": 100})) + ); + } + + #[test] + fn corrupt_blob_reads_empty_and_recovers_on_write() { + let mut p = MemPlatform::new(); + p.save_settings(SettingsScope::Character, b"{not json") + .unwrap(); + assert_eq!( + load_section(&p, SettingsScope::Character, "waypoints"), + None + ); + store_section( + &mut p, + SettingsScope::Character, + "waypoints", + serde_json::json!([]), + ) + .unwrap(); + assert_eq!( + load_section(&p, SettingsScope::Character, "waypoints"), + Some(serde_json::json!([])) + ); + } + + #[test] + fn remove_section_leaves_others() { + let mut p = MemPlatform::new(); + store_section(&mut p, SettingsScope::Local, "a", serde_json::json!(1)).unwrap(); + store_section(&mut p, SettingsScope::Local, "b", serde_json::json!(2)).unwrap(); + remove_section(&mut p, SettingsScope::Local, "a").unwrap(); + assert_eq!(load_section(&p, SettingsScope::Local, "a"), None); + assert_eq!( + load_section(&p, SettingsScope::Local, "b"), + Some(serde_json::json!(2)) + ); + } +} diff --git a/client-rust/source/app/src/render_settings.rs b/client-rust/source/app/src/render_settings.rs new file mode 100644 index 00000000..3b3632a4 --- /dev/null +++ b/client-rust/source/app/src/render_settings.rs @@ -0,0 +1,611 @@ +//! Versioned render tuning loaded from `assets/render/settings.json`. +//! +//! The app owns JSON and filesystem policy; the no_std renderer receives only +//! validated, copyable runtime values through `RendererSettings`. + +use serde::{Deserialize, Serialize}; +use std::sync::{OnceLock, RwLock}; +use successor_engine_render::renderer::{ + AaSettings as EngineAaSettings, ColorGradeSettings as EngineColorGradeSettings, + PaletteSettings as EnginePaletteSettings, RenderQuality, RendererSettings, + ShadowSettings as EngineShadowSettings, SunSettings as EngineSunSettings, +}; + +pub const SETTINGS_SCHEMA: &str = "successor.render-settings.v1"; +pub const SETTINGS_VERSION: u32 = 1; +#[cfg(any(target_arch = "wasm32", test))] +const EMBEDDED_DEFAULT: &str = include_str!("../../../assets/render/settings.json"); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum QualityPreset { + Low, + Medium, + High, +} + +impl QualityPreset { + pub const ALL: [Self; 3] = [Self::Low, Self::Medium, Self::High]; + + pub const fn label(self) -> &'static str { + match self { + Self::Low => "LOW", + Self::Medium => "MEDIUM", + Self::High => "HIGH", + } + } + + pub const fn render_quality(self) -> RenderQuality { + match self { + Self::Low => RenderQuality::Low, + Self::Medium => RenderQuality::Medium, + Self::High => RenderQuality::High, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RenderSettingsDocument { + pub schema: String, + pub version: u32, + pub selected_preset: QualityPreset, + pub presets: QualityPresets, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct QualityPresets { + pub low: PresetSettings, + pub medium: PresetSettings, + pub high: PresetSettings, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PresetSettings { + pub ambient_intensity: f32, + pub emissive_scalar: f32, + /// Linear master scale on point lights auto-added from GLB-embedded + /// descriptors (explicit `KHR_lights_punctual` and name-inferred emitters). + pub auto_point_light_scale: f32, + pub exposure: f32, + pub bloom: BloomSettings, + pub sun: SunSettings, + pub aa: AaSettings, + pub ao: AoSettings, + pub shadows: ShadowSettings, + pub color_grading: ColorGradeSettings, + pub palette: PaletteSettings, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BloomSettings { + pub threshold: f32, + pub intensity: f32, + pub radius: f32, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SunSettings { + pub azimuth_degrees: f32, + pub elevation_degrees: f32, + pub color: [f32; 3], + pub intensity: f32, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AaSettings { + pub enabled: bool, + pub edge_threshold_min: f32, + pub edge_threshold: f32, + pub subpixel_blend: f32, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AoSettings { + pub intensity: f32, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ShadowSettings { + pub map_size: u32, + pub world_radius: f32, + pub depth_bias: f32, + pub normal_bias: f32, + pub penumbra: f32, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ColorGradeSettings { + pub saturation: f32, + pub contrast: f32, + pub gamma: f32, + pub temperature: f32, + pub tint: f32, + pub lift: [f32; 3], + pub color_gamma: [f32; 3], + pub gain: [f32; 3], +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PaletteSettings { + pub enabled: bool, + pub levels: u32, + pub strength: f32, + pub dither: f32, +} + +impl RenderSettingsDocument { + pub fn builtin() -> Self { + Self { + schema: SETTINGS_SCHEMA.to_string(), + version: SETTINGS_VERSION, + selected_preset: QualityPreset::Medium, + presets: QualityPresets { + low: PresetSettings::low(), + medium: PresetSettings::medium(), + high: PresetSettings::high(), + }, + } + } + + pub fn parse(source: &str) -> Result { + let parsed: Self = serde_json::from_str(source) + .map_err(|error| format!("parse render settings: {error}"))?; + parsed.validate()?; + Ok(parsed) + } + + pub fn validate(&self) -> Result<(), String> { + if self.schema != SETTINGS_SCHEMA { + return Err(format!( + "unsupported render settings schema: {}", + self.schema + )); + } + if self.version != SETTINGS_VERSION { + return Err(format!( + "unsupported render settings version: {}", + self.version + )); + } + self.presets.low.validate("low")?; + self.presets.medium.validate("medium")?; + self.presets.high.validate("high")?; + Ok(()) + } + + pub fn preset(&self, preset: QualityPreset) -> &PresetSettings { + match preset { + QualityPreset::Low => &self.presets.low, + QualityPreset::Medium => &self.presets.medium, + QualityPreset::High => &self.presets.high, + } + } + + pub fn selected(&self) -> &PresetSettings { + self.preset(self.selected_preset) + } + + pub fn selected_mut(&mut self) -> &mut PresetSettings { + match self.selected_preset { + QualityPreset::Low => &mut self.presets.low, + QualityPreset::Medium => &mut self.presets.medium, + QualityPreset::High => &mut self.presets.high, + } + } + + pub fn select(&mut self, preset: QualityPreset) { + self.selected_preset = preset; + } + + pub fn reset_selected(&mut self) { + *self.selected_mut() = match self.selected_preset { + QualityPreset::Low => PresetSettings::low(), + QualityPreset::Medium => PresetSettings::medium(), + QualityPreset::High => PresetSettings::high(), + }; + } + + pub fn to_pretty_json(&self) -> Result { + self.validate()?; + let mut json = serde_json::to_string_pretty(self) + .map_err(|error| format!("serialize render settings: {error}"))?; + json.push('\n'); + Ok(json) + } +} + +impl PresetSettings { + fn base() -> Self { + Self { + ambient_intensity: 0.5, + emissive_scalar: 1.0, + auto_point_light_scale: 1.0, + exposure: 1.0, + bloom: BloomSettings { + threshold: 1.0, + intensity: 0.55, + radius: 1.0, + }, + sun: SunSettings { + azimuth_degrees: -45.0, + elevation_degrees: 55.0, + color: [1.0, 0.97, 0.9], + intensity: 1.0, + }, + aa: AaSettings { + enabled: true, + edge_threshold_min: 0.0312, + edge_threshold: 0.125, + subpixel_blend: 0.75, + }, + ao: AoSettings { intensity: 1.0 }, + shadows: ShadowSettings { + map_size: 2048, + world_radius: 48.0, + depth_bias: 0.0015, + normal_bias: 1.5, + penumbra: 40.0, + }, + color_grading: ColorGradeSettings { + saturation: 1.0, + contrast: 1.0, + gamma: 1.0, + temperature: 0.0, + tint: 0.0, + lift: [0.0; 3], + color_gamma: [1.0; 3], + gain: [1.0; 3], + }, + palette: PaletteSettings { + enabled: false, + levels: 16, + strength: 0.0, + dither: 0.0, + }, + } + } + + pub fn low() -> Self { + let mut value = Self::base(); + value.bloom.intensity = 0.25; + value.bloom.radius = 0.75; + value.shadows.map_size = 1024; + value.shadows.penumbra = 24.0; + value.aa.subpixel_blend = 0.5; + value + } + + pub fn medium() -> Self { + Self::base() + } + + pub fn high() -> Self { + let mut value = Self::base(); + value.bloom.intensity = 0.7; + value.bloom.radius = 1.35; + value.shadows.map_size = 4096; + value.shadows.penumbra = 55.0; + value.aa.edge_threshold_min = 0.02; + value.aa.edge_threshold = 0.09; + value.aa.subpixel_blend = 0.85; + value + } + + pub fn validate(&self, name: &str) -> Result<(), String> { + finite_range(name, "ambient_intensity", self.ambient_intensity, 0.0, 2.0)?; + finite_range(name, "emissive_scalar", self.emissive_scalar, 0.0, 8.0)?; + finite_range( + name, + "auto_point_light_scale", + self.auto_point_light_scale, + 0.0, + 8.0, + )?; + finite_range(name, "exposure", self.exposure, 0.1, 4.0)?; + finite_range(name, "bloom.threshold", self.bloom.threshold, 0.0, 8.0)?; + finite_range(name, "bloom.intensity", self.bloom.intensity, 0.0, 4.0)?; + finite_range(name, "bloom.radius", self.bloom.radius, 0.25, 4.0)?; + finite_range( + name, + "sun.azimuth_degrees", + self.sun.azimuth_degrees, + -180.0, + 180.0, + )?; + finite_range( + name, + "sun.elevation_degrees", + self.sun.elevation_degrees, + 5.0, + 89.0, + )?; + finite_vec(name, "sun.color", self.sun.color, 0.0, 4.0)?; + finite_range(name, "sun.intensity", self.sun.intensity, 0.0, 8.0)?; + finite_range( + name, + "aa.edge_threshold_min", + self.aa.edge_threshold_min, + 0.001, + 0.5, + )?; + finite_range(name, "aa.edge_threshold", self.aa.edge_threshold, 0.01, 0.5)?; + finite_range(name, "aa.subpixel_blend", self.aa.subpixel_blend, 0.0, 1.0)?; + finite_range(name, "ao.intensity", self.ao.intensity, 0.0, 4.0)?; + if !matches!(self.shadows.map_size, 512 | 1024 | 2048 | 4096) { + return Err(format!( + "{name}.shadows.map_size must be 512, 1024, 2048, or 4096" + )); + } + finite_range( + name, + "shadows.world_radius", + self.shadows.world_radius, + 8.0, + 256.0, + )?; + finite_range( + name, + "shadows.depth_bias", + self.shadows.depth_bias, + 0.0, + 0.05, + )?; + finite_range( + name, + "shadows.normal_bias", + self.shadows.normal_bias, + 0.0, + 8.0, + )?; + finite_range(name, "shadows.penumbra", self.shadows.penumbra, 0.0, 100.0)?; + let grade = &self.color_grading; + finite_range(name, "color_grading.saturation", grade.saturation, 0.0, 2.0)?; + finite_range(name, "color_grading.contrast", grade.contrast, 0.0, 2.0)?; + finite_range(name, "color_grading.gamma", grade.gamma, 0.5, 2.5)?; + finite_range( + name, + "color_grading.temperature", + grade.temperature, + -1.0, + 1.0, + )?; + finite_range(name, "color_grading.tint", grade.tint, -1.0, 1.0)?; + finite_vec(name, "color_grading.lift", grade.lift, -1.0, 1.0)?; + finite_vec( + name, + "color_grading.color_gamma", + grade.color_gamma, + 0.1, + 4.0, + )?; + finite_vec(name, "color_grading.gain", grade.gain, 0.0, 4.0)?; + if !(2..=64).contains(&self.palette.levels) { + return Err(format!("{name}.palette.levels must be between 2 and 64")); + } + finite_range(name, "palette.strength", self.palette.strength, 0.0, 1.0)?; + finite_range(name, "palette.dither", self.palette.dither, 0.0, 1.0)?; + Ok(()) + } + + pub fn renderer_settings(&self) -> RendererSettings { + RendererSettings { + ambient_intensity: self.ambient_intensity, + emissive_scalar: self.emissive_scalar, + point_light_scale: self.auto_point_light_scale, + exposure: self.exposure, + ao_intensity: self.ao.intensity, + bloom_threshold: self.bloom.threshold, + bloom_intensity: self.bloom.intensity, + bloom_radius: self.bloom.radius, + sun: EngineSunSettings { + azimuth_degrees: self.sun.azimuth_degrees, + elevation_degrees: self.sun.elevation_degrees, + color: self.sun.color, + intensity: self.sun.intensity, + }, + aa: EngineAaSettings { + enabled: self.aa.enabled, + edge_threshold_min: self.aa.edge_threshold_min, + edge_threshold: self.aa.edge_threshold, + subpixel_blend: self.aa.subpixel_blend, + }, + shadows: EngineShadowSettings { + map_size: self.shadows.map_size, + world_radius: self.shadows.world_radius, + depth_bias: self.shadows.depth_bias, + normal_bias: self.shadows.normal_bias, + penumbra: self.shadows.penumbra, + }, + color_grade: EngineColorGradeSettings { + saturation: self.color_grading.saturation, + contrast: self.color_grading.contrast, + gamma: self.color_grading.gamma, + temperature: self.color_grading.temperature, + tint: self.color_grading.tint, + lift: self.color_grading.lift, + color_gamma: self.color_grading.color_gamma, + gain: self.color_grading.gain, + }, + palette: EnginePaletteSettings { + enabled: self.palette.enabled, + levels: self.palette.levels, + strength: self.palette.strength, + dither: self.palette.dither, + }, + } + } +} + +fn finite_range(scope: &str, field: &str, value: f32, min: f32, max: f32) -> Result<(), String> { + if value.is_finite() && value >= min && value <= max { + Ok(()) + } else { + Err(format!( + "{scope}.{field} must be finite and in [{min}, {max}]" + )) + } +} + +fn finite_vec(scope: &str, field: &str, value: [f32; 3], min: f32, max: f32) -> Result<(), String> { + for channel in value { + finite_range(scope, field, channel, min, max)?; + } + Ok(()) +} + +static SETTINGS: OnceLock> = OnceLock::new(); +static SETTINGS_PATH: OnceLock = OnceLock::new(); + +pub fn initialize() { + if SETTINGS.get().is_some() { + return; + } + let (document, path) = match load_document() { + Ok(loaded) => loaded, + Err(error) => { + eprintln!("render settings: {error}; using built-in defaults"); + (RenderSettingsDocument::builtin(), default_path()) + } + }; + let _ = SETTINGS_PATH.set(path); + let _ = SETTINGS.set(RwLock::new(document)); +} + +pub fn document() -> RenderSettingsDocument { + initialize(); + SETTINGS + .get() + .expect("render settings initialized") + .read() + .expect("render settings lock poisoned") + .clone() +} + +pub fn replace(document: RenderSettingsDocument) -> Result<(), String> { + document.validate()?; + initialize(); + *SETTINGS + .get() + .expect("render settings initialized") + .write() + .map_err(|_| "render settings lock poisoned".to_string())? = document; + Ok(()) +} + +pub fn selected() -> PresetSettings { + document().selected().clone() +} + +pub fn selected_preset() -> QualityPreset { + document().selected_preset +} + +pub fn reload() -> Result { + let (document, _) = load_document()?; + replace(document.clone())?; + Ok(document) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn save(document: &RenderSettingsDocument) -> Result<(), String> { + let json = document.to_pretty_json()?; + initialize(); + let path = SETTINGS_PATH.get().cloned().unwrap_or_else(default_path); + successor_platform::fs_write_atomic(&path, json.as_bytes())?; + replace(document.clone()) +} + +#[cfg(target_arch = "wasm32")] +pub fn save(_document: &RenderSettingsDocument) -> Result<(), String> { + Err("render settings are read-only in the browser build".to_string()) +} + +fn load_document() -> Result<(RenderSettingsDocument, String), String> { + #[cfg(not(target_arch = "wasm32"))] + { + let path = discover_path(); + let bytes = successor_platform::fs_read(&path)?; + let source = std::str::from_utf8(&bytes) + .map_err(|error| format!("render settings are not UTF-8: {error}"))?; + Ok((RenderSettingsDocument::parse(source)?, path)) + } + #[cfg(target_arch = "wasm32")] + { + Ok(( + RenderSettingsDocument::parse(EMBEDDED_DEFAULT)?, + default_path(), + )) + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn discover_path() -> String { + for candidate in [ + "assets/render/settings.json", + "client-rust/assets/render/settings.json", + ] { + if successor_platform::fs_exists(candidate) { + return candidate.to_string(); + } + } + default_path() +} + +fn default_path() -> String { + #[cfg(not(target_arch = "wasm32"))] + if successor_platform::fs_exists("client-rust") { + return "client-rust/assets/render/settings.json".to_string(); + } + "assets/render/settings.json".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_settings_match_builtin_contract() { + let parsed = RenderSettingsDocument::parse(EMBEDDED_DEFAULT).expect("embedded settings"); + assert_eq!(parsed.schema, SETTINGS_SCHEMA); + assert_eq!(parsed.version, SETTINGS_VERSION); + assert_eq!(parsed.selected_preset, QualityPreset::Medium); + assert_eq!(parsed.presets.low.shadows.map_size, 1024); + assert_eq!(parsed.presets.medium.shadows.map_size, 2048); + assert_eq!(parsed.presets.high.shadows.map_size, 4096); + } + + #[test] + fn rejects_invalid_shadow_option_and_unknown_schema() { + let mut doc = RenderSettingsDocument::builtin(); + doc.presets.medium.shadows.map_size = 3000; + assert!(doc.validate().is_err()); + doc = RenderSettingsDocument::builtin(); + doc.schema = "other".to_string(); + assert!(doc.validate().is_err()); + } + + #[test] + fn json_round_trip_preserves_every_preset() { + let doc = RenderSettingsDocument::builtin(); + let json = doc.to_pretty_json().expect("serialize"); + let parsed = RenderSettingsDocument::parse(&json).expect("parse"); + assert_eq!( + parsed.presets.high.palette.levels, + doc.presets.high.palette.levels + ); + assert_eq!( + parsed.presets.low.bloom.radius, + doc.presets.low.bloom.radius + ); + } +} diff --git a/client-rust/source/app/src/rss.rs b/client-rust/source/app/src/rss.rs new file mode 100644 index 00000000..b29dc652 --- /dev/null +++ b/client-rust/source/app/src/rss.rs @@ -0,0 +1,52 @@ +//! Peak resident-set-size sampling for the runtime memory budget. +//! +//! Uses `getrusage(RUSAGE_SELF).ru_maxrss` — in `libSystem`/`libc`, always +//! linked, no crate dependency. macOS reports bytes; Linux reports kibibytes. +//! The `_rest` padding is deliberately oversized so the kernel never writes +//! past our buffer. + +#[cfg(not(target_arch = "wasm32"))] +#[repr(C)] +#[derive(Clone, Copy)] +struct Timeval { + sec: i64, + usec: i64, +} + +#[cfg(not(target_arch = "wasm32"))] +#[repr(C)] +struct Rusage { + ru_utime: Timeval, + ru_stime: Timeval, + ru_maxrss: i64, + _rest: [i64; 32], +} + +#[cfg(not(target_arch = "wasm32"))] +extern "C" { + fn getrusage(who: i32, usage: *mut Rusage) -> i32; +} + +/// Peak resident set size in bytes since process start (0 if unavailable). +#[cfg(not(target_arch = "wasm32"))] +pub fn peak_rss_bytes() -> u64 { + // SAFETY: `Rusage` is oversized vs the real struct, so `getrusage` writes + // within bounds; we only read the leading `ru_maxrss` field. + unsafe { + let mut u: Rusage = core::mem::zeroed(); + if getrusage(0 /* RUSAGE_SELF */, &mut u) != 0 { + return 0; + } + let maxrss = u.ru_maxrss.max(0) as u64; + if cfg!(target_os = "macos") { + maxrss // bytes + } else { + maxrss * 1024 // kibibytes -> bytes + } + } +} + +#[cfg(target_arch = "wasm32")] +pub fn peak_rss_bytes() -> u64 { + 0 +} diff --git a/client-rust/source/app/src/screens.rs b/client-rust/source/app/src/screens.rs new file mode 100644 index 00000000..eb5fac73 --- /dev/null +++ b/client-rust/source/app/src/screens.rs @@ -0,0 +1,3484 @@ +//! Pregame surfaces — Successor's rendition of the original client's +//! entry → roster → creation flow. +//! +//! Scope contract: these screens are **presentation only**. They emit +//! [`ScreenAction`] intents and never open a socket, hold a credential, or +//! resolve a roster. The endpoint/player fields are a *development entry +//! mechanic*; ordinary launch still requires a signed launch context +//! (`--launch-context`), and nothing here is an alternate authenticated path. +//! +//! Original client bitmap assets are never loaded or copied. These screens +//! combine Successor-owned image-2 destination art and tintable shape accents +//! with sparse vector chrome in the shared runtime atlas. The visual grammar +//! keeps the original's deep-space field, saturated teal hierarchy, industrial +//! hardware points, and far-edge BACK/NEXT anchors without reproducing its +//! imagery or stacking borders around every element. +//! +//! Stages are observable so a host (or `--demo pregame`) can drive and capture +//! each one: [`EntryStage`] on [`EntryScreen`], [`CharacterStage`] on +//! [`CharacterScreen`]. + +use crate::net::connect::JoinOptions; +use successor_engine_render::font::text_advance; +use successor_engine_render::ui::{Response, TextField, UiBuilder}; + +// ── Design tokens ─────────────────────────────────────────────────────────── +// Sampled from the refreshed original login/creation captures: the pregame +// pages are saturated teal (#004858 body, #007890/#008098 inner fields, +// #005060 wells) closed by a bright cyan edge (#18F8F8), with sparing +// industrial gray hardware (#98A090/#A8A898). Deep space stays near-black. +// Ink tones are lifted from the HUD SIGNAL ramp so pregame and connected text +// read as one family against the brighter teal. + +const VOID: [u8; 4] = [3, 7, 10, 255]; +const INK: [u8; 4] = [232, 248, 250, 255]; +const INK_SOFT: [u8; 4] = [190, 228, 236, 255]; +const INK_DIM: [u8; 4] = [132, 186, 198, 255]; +const INK_GHOST: [u8; 4] = [88, 138, 150, 255]; +/// Bright edge cyan (#18F8F8) — borders, focus, active rails. +const ACCENT: [u8; 4] = [24, 248, 248, 255]; +/// Selected-row wash (#008098). +const ACCENT_SOFT: [u8; 4] = [0, 128, 152, 232]; +const HAIRLINE: [u8; 4] = [0, 62, 76, 236]; +const HAIRLINE_LIT: [u8; 4] = [0, 132, 152, 220]; +/// Page body (#004858), translucent so the schematic reads behind it. +const PANEL_FILL: [u8; 4] = [0, 72, 88, 218]; +/// Title rail (#003844). +const BAR_FILL: [u8; 4] = [0, 56, 68, 232]; +/// Inner field well (#007890) — brighter than its page, as in the original. +const FIELD_FILL: [u8; 4] = [0, 120, 144, 214]; +const ROW_HOVER: [u8; 4] = [0, 128, 152, 132]; +/// Outer hardware (#A8A898 / #98A090) — corner brackets and wedge points only. +const RAIL: [u8; 4] = [168, 168, 152, 236]; +const RAIL_DIM: [u8; 4] = [152, 160, 144, 168]; +const DANGER: [u8; 4] = [255, 122, 108, 255]; +const SCRIM: [u8; 4] = [1, 10, 14, 200]; + +// Type scale. Rendered glyph height is `8.75 * px` (see `UiBuilder::text`), so +// these read as 21 / 15 / 13 / 10.5 / 9 px at the 1280x720 reference size. +const T_TITLE: f32 = 2.35; +const T_PANEL: f32 = 1.75; +const T_BODY: f32 = 1.45; +const T_LABEL: f32 = 1.2; +const T_MICRO: f32 = 1.02; + +/// Far-edge navigation anchor from the original client: the NEXT control keeps +/// the point `(W-109, H-38)` at every framebuffer size. +const NAV_INSET_X: f32 = 109.0; +const NAV_INSET_Y: f32 = 38.0; +const NAV_W: f32 = 150.0; +const NAV_H: f32 = 26.0; +const EDGE: f32 = 52.0; + +/// Uniform control scale. Proportions stay fixed (controls never distort with +/// aspect) while the whole surface tracks viewport height. +fn scale(h: f32) -> f32 { + (h / 720.0).clamp(0.8, 1.75) +} + +fn glyph_h(px: f32) -> f32 { + 8.75 * px +} + +fn measure(ui: &UiBuilder, text: &str, px: f32) -> f32 { + ui.measure_text(text, px) +} + +// ── Actions ───────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ScreenAction { + /// Development entry: the host may begin a link attempt with these options. + Connect(JoinOptions), + /// The in-flight link attempt was cancelled from the connecting dialog. + CancelConnect, + SelectCharacter(usize), + CreateCharacter(String), + /// Leave the pregame flow (a screen's own sub-stages never emit this). + Back, + Quit, +} + +// ── Shared primitives ─────────────────────────────────────────────────────── + +fn hairline(ui: &mut UiBuilder, x: f32, y: f32, w: f32, s: f32, c: [u8; 4]) { + ui.rect(x, y, w, (1.0 * s).max(1.0), c); +} + +/// Four L-shaped corner brackets — the pregame frame chrome. Deliberately not +/// a full border: only the corners are drawn. +fn corner_brackets(ui: &mut UiBuilder, r: [f32; 4], len: f32, t: f32, c: [u8; 4]) { + let [x, y, w, h] = r; + ui.rect(x, y, len, t, c); + ui.rect(x, y, t, len, c); + ui.rect(x + w - len, y, len, t, c); + ui.rect(x + w - t, y, t, len, c); + ui.rect(x, y + h - t, len, t, c); + ui.rect(x, y + h - len, t, len, c); + ui.rect(x + w - len, y + h - t, len, t, c); + ui.rect(x + w - t, y + h - len, t, len, c); +} + +fn text_center(ui: &mut UiBuilder, text: &str, cx: f32, y: f32, px: f32, c: [u8; 4]) { + let width = measure(ui, text, px); + ui.text(text, cx - width * 0.5, y, px, c); +} + +fn text_right(ui: &mut UiBuilder, text: &str, right: f32, y: f32, px: f32, c: [u8; 4]) { + let width = measure(ui, text, px); + ui.text(text, right - width, y, px, c); +} + +/// Word-wrapped paragraph. Measures incrementally (no per-line re-measure, no +/// intermediate `String`s) and returns the y cursor after the last line. +#[allow(clippy::too_many_arguments)] +fn draw_wrapped( + ui: &mut UiBuilder, + text: &str, + x: f32, + y: f32, + max_w: f32, + px: f32, + line_h: f32, + max_lines: usize, + c: [u8; 4], +) -> f32 { + let mut rest = text.trim_start(); + let mut cy = y; + let mut drawn = 0usize; + while !rest.is_empty() && drawn < max_lines { + let mut cut = rest.len(); + let mut acc = 0.0f32; + let mut last_space = 0usize; + for (i, ch) in rest.char_indices() { + let adv = text_advance(ch) * px; + if acc + adv > max_w { + cut = if last_space > 0 { last_space } else { i.max(1) }; + break; + } + if ch == ' ' { + last_space = i; + } + acc += adv; + } + ui.text(&rest[..cut], x, cy, px, c); + cy += line_h; + drawn += 1; + rest = rest[cut..].trim_start(); + } + cy +} + +fn ellipse(ui: &mut UiBuilder, cx: f32, cy: f32, r: [f32; 2], segs: u32, t: f32, c: [u8; 4]) { + let [rx, ry] = r; + let segs = segs.max(6); + let (mut px, mut py) = (cx + rx, cy); + for i in 1..=segs { + let a = i as f32 / segs as f32 * std::f32::consts::TAU; + let (nx, ny) = (cx + a.cos() * rx, cy + a.sin() * ry); + ui.line(px, py, nx, ny, t, c); + px = nx; + py = ny; + } +} + +/// Filled ellipse assembled from horizontal spans — the only way to get a soft +/// round mass out of a quad-only draw list. +fn filled_ellipse(ui: &mut UiBuilder, cx: f32, cy: f32, r: [f32; 2], rows: u32, c: [u8; 4]) { + let [rx, ry] = r; + let rows = rows.max(2); + let step = 2.0 * ry / rows as f32; + for i in 0..rows { + let y0 = cy - ry + i as f32 * step; + let t = (y0 + step * 0.5 - cy) / ry; + let hw = rx * (1.0 - t * t).max(0.0).sqrt(); + if hw > 0.4 { + ui.rect(cx - hw, y0, hw * 2.0, step + 0.6, c); + } + } +} + +#[inline] +fn xorshift(state: &mut u32) -> u32 { + let mut x = *state; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + *state = x; + x +} + +#[inline] +fn unit(state: &mut u32) -> f32 { + (xorshift(state) >> 8) as f32 / 16_777_216.0 +} + +// ── Backdrop: deep space + orbital schematic ──────────────────────────────── + +/// Deterministic starfield, nebula bloom, blueprint station and distant hulls. +/// Fixed seed ⇒ identical every frame and every run (screenshot-stable). +fn draw_backdrop(ui: &mut UiBuilder, w: f32, h: f32, phase: f32) { + ui.rect(0.0, 0.0, w, h, VOID); + + // Nebula bloom behind the schematic (nested translucent masses). + let gx = w * 0.30; + let gy = h * 0.46; + for i in (0..9).rev() { + let t = i as f32 / 8.0; + filled_ellipse( + ui, + gx, + gy, + [w * (0.13 + t * 0.36), h * (0.16 + t * 0.40)], + 16, + [9, 33, 41, 13], + ); + } + + // Starfield. + let mut rng: u32 = 0x9E37_79B9; + for i in 0..168u32 { + let x = unit(&mut rng) * w; + let y = unit(&mut rng) * h; + let roll = unit(&mut rng); + let twinkle = 0.62 + 0.38 * (phase * 1.4 + i as f32 * 0.7).sin(); + let a = ((36.0 + roll * 150.0) * twinkle).clamp(8.0, 235.0) as u8; + let sz = if roll > 0.93 { 2.0 } else { 1.0 }; + ui.rect(x, y, sz, sz, [188, 226, 236, a]); + if roll > 0.985 { + ui.rect(x - 2.0, y + 0.5, 5.0, 1.0, [188, 226, 236, a / 3]); + ui.rect(x + 0.5, y - 2.0, 1.0, 5.0, [188, 226, 236, a / 3]); + } + } + + draw_station(ui, gx, gy, h / 720.0, phase); + + // Distant hulls: three small arrowheads at very low alpha. + draw_hull(ui, w * 0.70, h * 0.24, 26.0 * (h / 720.0), -0.35, 34); + draw_hull(ui, w * 0.13, h * 0.74, 34.0 * (h / 720.0), 0.22, 28); + draw_hull(ui, w * 0.62, h * 0.79, 20.0 * (h / 720.0), 0.05, 22); + + // Lower haze band grounds the composition. + for i in 0..10u32 { + let a = 3 + i / 2; + ui.rect( + 0.0, + h - (i as f32 + 1.0) * h * 0.018, + w, + h * 0.018, + [12, 44, 52, a as u8], + ); + } +} + +/// Successor orbital platform, blueprint-style: two rim discs, a spoked hub, a +/// dorsal mast with a sensor crown, docking pylons, and radiator fins. +fn draw_station(ui: &mut UiBuilder, cx: f32, cy: f32, k: f32, phase: f32) { + let line_a = [86, 206, 224, 44]; + let line_b = [86, 206, 224, 30]; + let lit = [120, 232, 246, 70]; + + let rim = 300.0 * k; + let rim_y = 88.0 * k; + let mid = 212.0 * k; + let mid_y = 62.0 * k; + let hub = 60.0 * k; + let hub_y = 18.0 * k; + + ellipse(ui, cx, cy, [rim, rim_y], 44, 1.3, line_a); + ellipse( + ui, + cx, + cy, + [rim - 12.0 * k, rim_y - 3.5 * k], + 44, + 1.0, + line_b, + ); + ellipse(ui, cx, cy, [mid, mid_y], 38, 1.0, line_b); + ellipse(ui, cx, cy, [hub, hub_y], 26, 1.3, lit); + + // Spokes hub → mid rim. + for i in 0..14u32 { + let a = i as f32 / 14.0 * std::f32::consts::TAU; + let (c, s) = (a.cos(), a.sin()); + ui.line( + cx + c * hub, + cy + s * hub_y, + cx + c * mid, + cy + s * mid_y, + 1.0, + line_b, + ); + } + // Rim ticks. + for i in 0..30u32 { + let a = i as f32 / 30.0 * std::f32::consts::TAU; + let (c, s) = (a.cos(), a.sin()); + ui.line( + cx + c * (rim - 14.0 * k), + cy + s * (rim_y - 4.0 * k), + cx + c * rim, + cy + s * rim_y, + 1.0, + line_a, + ); + } + + // Dorsal mast + sensor crown. + let top = cy - 196.0 * k; + ui.line(cx, cy - 14.0 * k, cx, top, 1.6, line_a); + ui.line(cx, cy + 12.0 * k, cx, cy + 126.0 * k, 1.4, line_b); + for i in 1..5u32 { + let y = cy - i as f32 * 40.0 * k; + let half = (26.0 - i as f32 * 3.5) * k; + ui.line(cx - half, y, cx + half, y, 1.0, line_b); + } + ellipse(ui, cx, top, [84.0 * k, 24.0 * k], 26, 1.1, line_a); + ellipse(ui, cx, top, [46.0 * k, 13.0 * k], 20, 1.0, line_b); + ui.line(cx, top, cx - 96.0 * k, top - 40.0 * k, 1.0, line_b); + ui.line(cx, top, cx + 104.0 * k, top - 30.0 * k, 1.0, line_b); + ui.rect(cx - 100.0 * k, top - 44.0 * k, 8.0 * k, 8.0 * k, lit); + ui.rect(cx + 100.0 * k, top - 34.0 * k, 8.0 * k, 8.0 * k, lit); + + // Docking pylons off the rim. + for (i, a) in [0.55f32, 2.15, 3.75, 5.35].iter().enumerate() { + let (c, s) = (a.cos(), a.sin()); + let x0 = cx + c * rim; + let y0 = cy + s * rim_y; + let x1 = cx + c * (rim + 70.0 * k); + let y1 = cy + s * (rim_y + 24.0 * k) + 26.0 * k; + ui.line(x0, y0, x1, y1, 1.2, line_a); + ui.line(x1, y1, x1 + 34.0 * k, y1 - 8.0 * k, 1.0, line_b); + let pulse = if (phase * 0.6 + i as f32 * 0.9) as i32 % 3 == 0 { + lit + } else { + line_b + }; + ui.rect(x1 - 4.0 * k, y1 - 4.0 * k, 9.0 * k, 9.0 * k, pulse); + } + + // Radiator fins. + ui.line( + cx - rim * 0.72, + cy - 96.0 * k, + cx - rim * 1.05, + cy - 150.0 * k, + 7.0 * k, + [70, 176, 196, 18], + ); + ui.line( + cx + rim * 0.70, + cy - 88.0 * k, + cx + rim * 1.02, + cy - 142.0 * k, + 7.0 * k, + [70, 176, 196, 18], + ); +} + +/// Distant ship: an arrowhead outline with two swept wings. +fn draw_hull(ui: &mut UiBuilder, cx: f32, cy: f32, len: f32, tilt: f32, alpha: u8) { + let c = [104, 200, 218, alpha]; + let (dx, dy) = (tilt.cos(), tilt.sin()); + let (px, py) = (-dy, dx); + let nose = (cx + dx * len, cy + dy * len); + let tail = (cx - dx * len * 0.6, cy - dy * len * 0.6); + let lw = (tail.0 + px * len * 0.42, tail.1 + py * len * 0.42); + let rw = (tail.0 - px * len * 0.42, tail.1 - py * len * 0.42); + ui.line(nose.0, nose.1, lw.0, lw.1, 1.2, c); + ui.line(nose.0, nose.1, rw.0, rw.1, 1.2, c); + ui.line(lw.0, lw.1, rw.0, rw.1, 1.0, c); + ui.line( + tail.0, + tail.1, + nose.0, + nose.1, + 1.0, + [104, 200, 218, alpha / 2], + ); +} + +// ── Screen identity + edge navigation ─────────────────────────────────────── + +const STAGE_LABELS: [&str; 4] = ["LINK", "ROSTER", "LINEAGE", "IDENTITY"]; + +/// Top-left wordmark and the four-tick stage rail. This is the screen-level +/// hierarchy the original carried in its panel title alone. +fn draw_identity(ui: &mut UiBuilder, s: f32, active: usize) { + let x = EDGE * s; + let y = 26.0 * s; + ui.rect( + x, + y + 2.0 * s, + 3.0 * s, + glyph_h(T_TITLE * s) - 4.0 * s, + ACCENT, + ); + ui.text("SUCCESSOR", x + 12.0 * s, y, T_TITLE * s, INK); + + let rail_y = y + glyph_h(T_TITLE * s) + 14.0 * s; + let step = 94.0 * s; + let span = step * (STAGE_LABELS.len() - 1) as f32; + hairline(ui, x, rail_y, span, s, HAIRLINE); + for (i, label) in STAGE_LABELS.iter().enumerate() { + let tx = x + i as f32 * step; + let on = i == active; + let tint = if on { + ACCENT + } else if i < active { + INK_DIM + } else { + INK_GHOST + }; + if on { + hairline(ui, x, rail_y, tx - x, s, ACCENT); + ui.rect(tx - 3.0 * s, rail_y - 3.0 * s, 7.0 * s, 7.0 * s, ACCENT); + } else { + ui.rect(tx - 2.0 * s, rail_y - 2.0 * s, 5.0 * s, 5.0 * s, tint); + } + ui.text(label, tx - 2.0 * s, rail_y + 9.0 * s, T_MICRO * s, tint); + } +} + +/// Centred instruction line above the navigation row, matching the original's +/// sentence-case helper copy. +fn draw_footer(ui: &mut UiBuilder, w: f32, h: f32, s: f32, line: &str, note: Option<&str>) { + let y = h - 74.0 * s; + text_center(ui, line, w * 0.5, y, T_BODY * s, INK_SOFT); + if let Some(note) = note { + text_center(ui, note, w * 0.5, y + 17.0 * s, T_MICRO * s, INK_DIM); + } +} + +/// One-line host status under the instruction copy. `bad` switches the tick and +/// text to the danger tone for a rejection; otherwise it reads as a quiet +/// pending/informational note. Same grammar as the login panel foot: a short +/// accent tick, then micro caps. +fn draw_status_line(ui: &mut UiBuilder, w: f32, h: f32, s: f32, text: &str, bad: bool) { + if text.is_empty() { + return; + } + let px = T_MICRO * s; + let y = h - 74.0 * s + 17.0 * s; + let tint = if bad { DANGER } else { ACCENT }; + let ink = if bad { DANGER } else { INK_DIM }; + let tw = measure(ui, text, px); + let x = w * 0.5 - tw * 0.5; + ui.rect(x - 8.0 * s, y + 1.0 * s, 2.0 * s, glyph_h(px), tint); + ui.text(text, x, y, px, ink); +} + +/// Far-edge navigation plates are **not** scaled: the original keeps the same +/// pixel inset at every framebuffer size, verified at 800x600 (NEXT centre +/// 691,562) and 1280x1024 (1171,986). +fn nav_back_rect(h: f32) -> [f32; 4] { + [ + NAV_INSET_X - NAV_W * 0.5, + h - NAV_INSET_Y - NAV_H * 0.5, + NAV_W, + NAV_H, + ] +} + +fn nav_next_rect(w: f32, h: f32) -> [f32; 4] { + [ + w - NAV_INSET_X - NAV_W * 0.5, + h - NAV_INSET_Y - NAV_H * 0.5, + NAV_W, + NAV_H, + ] +} + +/// Far-edge navigation wedge: a teal label bar closed by a generated, +/// tintable hardware point. Metrics are fixed because the hit area is the bar, +/// so the decoration never moves the target. +fn nav_plate(ui: &mut UiBuilder, r: [f32; 4], label: &str, forward: bool, enabled: bool) -> bool { + let [x, y, w, h] = r; + let resp = ui.interact(x, y, w, h); + let fill = match (enabled, resp.held, resp.hovered) { + (false, _, _) => [0, 44, 54, 200], + (true, true, _) => [0, 160, 186, 246], + (true, false, true) => [0, 136, 160, 242], + _ => [0, 104, 126, 232], + }; + ui.rect(x, y, w, h, fill); + hairline(ui, x, y, w, 1.0, if enabled { ACCENT } else { HAIRLINE }); + hairline( + ui, + x, + y + h - 1.0, + w, + 1.0, + if enabled { HAIRLINE_LIT } else { HAIRLINE }, + ); + + // Image-2 hardware point on the leading edge; flat tail bar opposite. + let point = 22.0; + let hw = if enabled { RAIL } else { RAIL_DIM }; + if forward { + ui.mask_uv( + x + w, + y - 2.0, + point, + h + 4.0, + crate::hud::NAV_WEDGE_RIGHT_UV, + hw, + ); + ui.rect(x - 5.0, y - 2.0, 1.5, h + 4.0, hw); + } else { + ui.mask_uv( + x - point, + y - 2.0, + point, + h + 4.0, + crate::hud::NAV_WEDGE_LEFT_UV, + hw, + ); + ui.rect(x + w + 3.5, y - 2.0, 1.5, h + 4.0, hw); + } + + let px = T_LABEL * 1.12; + let tint = if enabled { INK } else { INK_GHOST }; + text_center( + ui, + label, + x + w * 0.5, + y + (h - glyph_h(px)) * 0.5, + px, + tint, + ); + enabled && resp.clicked +} + +// ── Controls (palette-local; the shared widgets carry a gold accent) ──────── + +#[allow(clippy::too_many_arguments)] +fn draw_field( + ui: &mut UiBuilder, + field: &mut TextField, + r: [f32; 4], + s: f32, + phase: f32, + press_anywhere: bool, + placeholder: &str, +) -> Response { + let [x, y, w, h] = r; + let resp = ui.interact(x, y, w, h); + if resp.clicked { + field.focused = true; + } else if press_anywhere && !resp.hovered { + field.focused = false; + } + if field.focused { + ui.rect(x, y, w, h, [0, 82, 98, 104]); + } else if resp.hovered { + ui.rect(x, y, w, h, [0, 56, 68, 72]); + } + let edge = if field.focused { ACCENT } else { HAIRLINE_LIT }; + hairline( + ui, + x, + y + h - (if field.focused { 2.0 } else { 1.0 }) * s, + w, + s, + edge, + ); + if field.focused { + ui.rect(x, y + 7.0 * s, 2.0 * s, h - 14.0 * s, ACCENT); + } + let px = T_BODY * s; + let ty = y + (h - glyph_h(px)) * 0.5; + if field.text.is_empty() { + ui.text(placeholder, x + 9.0 * s, ty, px, INK_GHOST); + } else { + ui.text(&field.text, x + 9.0 * s, ty, px, INK); + } + if field.focused && (phase * 2.0) as i32 % 2 == 0 { + let end = x + 9.0 * s + measure(ui, &field.text, px); + ui.rect(end + 1.5 * s, ty, 1.6 * s, glyph_h(px), ACCENT); + } + resp +} + +/// Flat action control: fill + hairline underline, no full outline. +fn action_bar(ui: &mut UiBuilder, r: [f32; 4], s: f32, label: &str, enabled: bool) -> bool { + let [x, y, w, h] = r; + let resp = ui.interact(x, y, w, h); + let fill = match (enabled, resp.held, resp.hovered) { + (false, _, _) => [9, 19, 23, 190], + (true, true, _) => [26, 92, 104, 240], + (true, false, true) => [17, 66, 76, 232], + _ => [11, 33, 39, 224], + }; + ui.rect(x, y, w, h, fill); + hairline( + ui, + x, + y + h - 1.0 * s, + w, + s, + if enabled { ACCENT } else { HAIRLINE }, + ); + let px = T_LABEL * s * 1.05; + text_center( + ui, + label, + x + w * 0.5, + y + (h - glyph_h(px)) * 0.5, + px, + if enabled { INK } else { INK_GHOST }, + ); + enabled && resp.clicked +} + +/// Small segmented chip used for presentation and vocation choices. +fn chip(ui: &mut UiBuilder, r: [f32; 4], s: f32, label: &str, on: bool) -> bool { + let [x, y, w, h] = r; + let resp = ui.interact(x, y, w, h); + let fill = if on { + ACCENT_SOFT + } else if resp.hovered { + ROW_HOVER + } else { + [8, 20, 24, 210] + }; + ui.rect(x, y, w, h, fill); + if on { + ui.rect(x, y, 3.0 * s, h, ACCENT); + } + hairline( + ui, + x, + y + h - 1.0 * s, + w, + s, + if on { ACCENT } else { HAIRLINE }, + ); + let px = T_LABEL * s; + ui.text( + label, + x + 9.0 * s, + y + (h - glyph_h(px)) * 0.5, + px, + if on { INK } else { INK_DIM }, + ); + resp.clicked +} + +/// Horizontal value track. Returns true while the pointer is driving it. +fn track(ui: &mut UiBuilder, r: [f32; 4], s: f32, value: &mut f32) -> bool { + let [x, y, w, h] = r; + let resp = ui.interact(x, y, w, h); + let mut changed = false; + if resp.pressed || resp.held { + let (mx, _) = ui.mouse(); + let next = ((mx - x) / w).clamp(0.0, 1.0); + changed = (next - *value).abs() > f32::EPSILON; + *value = next; + } + let ty = y + h * 0.5 - 1.5 * s; + ui.rect(x, ty, w, 3.0 * s, [16, 34, 40, 240]); + ui.rect(x, ty, w * *value, 3.0 * s, ACCENT); + for i in 0..5u32 { + let tx = x + i as f32 / 4.0 * w; + ui.rect( + tx - 0.5 * s, + y + h * 0.5 + 4.0 * s, + 1.0 * s, + 4.0 * s, + HAIRLINE_LIT, + ); + } + let thumb = 9.0 * s; + ui.rect( + x + w * *value - thumb * 0.5, + y + (h - thumb) * 0.5, + thumb, + thumb, + if resp.held { INK } else { ACCENT }, + ); + changed +} + +fn check_row(ui: &mut UiBuilder, r: [f32; 4], s: f32, label: &str, value: &mut bool) -> bool { + let [x, y, w, h] = r; + let resp = ui.interact(x, y, w, h); + if resp.clicked { + *value = !*value; + } + let box_s = 12.0 * s; + let by = y + (h - box_s) * 0.5; + ui.rect(x, by, box_s, box_s, FIELD_FILL); + ui.border( + x, + by, + box_s, + box_s, + (1.0 * s).max(1.0), + if resp.hovered { ACCENT } else { HAIRLINE_LIT }, + ); + if *value { + ui.rect( + x + 3.0 * s, + by + 3.0 * s, + box_s - 6.0 * s, + box_s - 6.0 * s, + ACCENT, + ); + } + let px = T_LABEL * s; + ui.text( + label, + x + box_s + 9.0 * s, + y + (h - glyph_h(px)) * 0.5, + px, + if *value { INK } else { INK_DIM }, + ); + resp.clicked +} + +/// Label/value pair closed by a faint rule — the readout grammar used for +/// character detail and the creation summary. +fn kv_row(ui: &mut UiBuilder, x: f32, y: f32, w: f32, s: f32, key: &str, value: &str) { + let kp = T_LABEL * s; + let vp = T_BODY * s; + ui.text(key, x, y + (glyph_h(vp) - glyph_h(kp)) * 0.5, kp, INK_DIM); + text_right(ui, value, x + w, y, vp, INK); + hairline(ui, x, y + glyph_h(vp) + 6.0 * s, w, s, [22, 38, 44, 200]); +} + +/// Panel shell: translucent charcoal body, brass corner brackets, a title bar +/// closed by one hairline. Returns the y of the content origin. +fn draw_panel(ui: &mut UiBuilder, r: [f32; 4], s: f32, title: &str, tag: Option<&str>) -> f32 { + let [x, y, w, h] = r; + ui.rect(x, y, w, h, PANEL_FILL); + corner_brackets(ui, r, 24.0 * s, (1.5 * s).max(1.0), RAIL); + let bar = 30.0 * s; + ui.rect(x, y, w, bar, BAR_FILL); + hairline(ui, x, y + bar - 1.0 * s, w, s, HAIRLINE_LIT); + ui.rect(x + 12.0 * s, y + 8.0 * s, 2.0 * s, bar - 16.0 * s, ACCENT); + let px = T_PANEL * s; + ui.text(title, x + 22.0 * s, y + (bar - glyph_h(px)) * 0.5, px, INK); + if let Some(tag) = tag { + let tp = T_MICRO * s; + text_right( + ui, + tag, + x + w - 12.0 * s, + y + (bar - glyph_h(tp)) * 0.5, + tp, + INK_DIM, + ); + } + y + bar +} + +// ── Retained full-body viewer ─────────────────────────────────────────────── + +/// Subject of the left-hand viewer. Proportions are driven by the creation +/// draft so the figure visibly answers lineage/build/presentation changes. +pub struct Figure { + /// 0 = slight frame, 1 = heavy frame. + pub build: f32, + pub female: bool, +} + +/// Projection pad, volumetric figure and scan sweep — an authored abstract +/// silhouette assembled from primitives, retained across every character +/// stage exactly like the original's live avatar viewer. A host that can supply +/// a live pawn renders into [`CharacterLayout::paperdoll_rect`] and suppresses +/// the silhouette with [`CharacterScreen::set_paperdoll_hosted`]; the pad, +/// frame and caption stay so the composition does not move. +fn draw_viewer(ui: &mut UiBuilder, r: [f32; 4], s: f32, phase: f32, fig: Option<&Figure>) { + let [rx, ry, rw, rh] = r; + let cx = rx + rw * 0.5; + let u = rh * 0.70; + let feet = ry + rh * 0.87; + let top = feet - u; + + corner_brackets(ui, r, 26.0 * s, (1.5 * s).max(1.0), RAIL_DIM); + + // Projection pad. + let pad_y = feet + u * 0.022; + let pad_rx = u * 0.27; + let pad_ry = pad_rx * 0.27; + filled_ellipse(ui, cx, pad_y, [pad_rx, pad_ry], 14, [30, 132, 150, 30]); + ellipse(ui, cx, pad_y, [pad_rx, pad_ry], 40, 1.3, [96, 218, 236, 96]); + ellipse( + ui, + cx, + pad_y, + [pad_rx * 0.66, pad_ry * 0.66], + 32, + 1.0, + [96, 218, 236, 56], + ); + for i in 0..12u32 { + let a = i as f32 / 12.0 * std::f32::consts::TAU; + let (c, sn) = (a.cos(), a.sin()); + ui.line( + cx + c * pad_rx * 0.70, + pad_y + sn * pad_ry * 0.70, + cx + c * pad_rx, + pad_y + sn * pad_ry, + 1.0, + [96, 218, 236, 62], + ); + } + // Projection cone. + ui.line( + cx - pad_rx, + pad_y, + cx - u * 0.10, + top, + 1.0, + [80, 200, 220, 20], + ); + ui.line( + cx + pad_rx, + pad_y, + cx + u * 0.10, + top, + 1.0, + [80, 200, 220, 20], + ); + + let Some(fig) = fig else { + return; + }; + + let fill = [74, 196, 218, 54]; + let edge = [150, 236, 248, 176]; + let build = fig.build.clamp(0.0, 1.0); + let fem = if fig.female { 1.0f32 } else { 0.0 }; + + let head_r = u * 0.058; + let head_cy = top + u * 0.064; + let neck_y = top + u * 0.126; + let sh_y = top + u * 0.178; + let sh_hw = u * (0.098 + 0.028 * build) * (1.0 - 0.08 * fem); + let waist_y = top + u * 0.398; + let waist_hw = u * (0.055 + 0.026 * build) * (1.0 - 0.10 * fem); + let hip_y = top + u * 0.472; + let hip_hw = u * (0.072 + 0.020 * build) * (1.0 + 0.10 * fem); + let knee_y = top + u * 0.716; + let ankle_y = top + u * 0.952; + let elbow_y = top + u * 0.362; + let wrist_y = top + u * 0.524; + + // Torso mass: shoulder → waist → hip interpolation as horizontal spans. + let rows = 26u32; + let step = (hip_y - sh_y) / rows as f32; + for i in 0..rows { + let t = i as f32 / (rows - 1) as f32; + let y = sh_y + (hip_y - sh_y) * t; + let hw = if y < waist_y { + let k = (y - sh_y) / (waist_y - sh_y); + sh_hw + (waist_hw - sh_hw) * k + } else { + let k = (y - waist_y) / (hip_y - waist_y); + waist_hw + (hip_hw - waist_hw) * k + }; + ui.rect(cx - hw, y, hw * 2.0, step + 0.7, fill); + } + // Torso contour. + ui.line(cx - sh_hw, sh_y, cx - waist_hw, waist_y, 1.4 * s, edge); + ui.line(cx + sh_hw, sh_y, cx + waist_hw, waist_y, 1.4 * s, edge); + ui.line(cx - waist_hw, waist_y, cx - hip_hw, hip_y, 1.4 * s, edge); + ui.line(cx + waist_hw, waist_y, cx + hip_hw, hip_y, 1.4 * s, edge); + ui.line(cx - sh_hw, sh_y, cx + sh_hw, sh_y, 1.6 * s, edge); + ui.line( + cx, + sh_y + u * 0.02, + cx, + waist_y, + 1.0 * s, + [150, 236, 248, 70], + ); + ui.line(cx - hip_hw, hip_y, cx + hip_hw, hip_y, 1.3 * s, edge); + + // Head + neck. + filled_ellipse(ui, cx, head_cy, [head_r * 0.86, head_r], 12, fill); + ellipse(ui, cx, head_cy, [head_r * 0.86, head_r], 26, 1.3 * s, edge); + ui.rect( + cx - u * 0.019, + head_cy + head_r * 0.82, + u * 0.038, + neck_y - head_cy - head_r * 0.7, + fill, + ); + ui.line(cx - u * 0.02, neck_y, cx + u * 0.02, neck_y, 1.2 * s, edge); + + // Arms. + let arm_t = u * 0.030 + u * 0.008 * build; + for side in [-1.0f32, 1.0] { + let sx = cx + side * sh_hw * 0.94; + let ex = cx + side * (sh_hw * 1.10); + let wx = cx + side * (sh_hw * 0.96); + ui.line(sx, sh_y + u * 0.012, ex, elbow_y, arm_t, fill); + ui.line(ex, elbow_y, wx, wrist_y, arm_t * 0.86, fill); + ui.line(sx, sh_y + u * 0.012, ex, elbow_y, 1.2 * s, edge); + ui.line(ex, elbow_y, wx, wrist_y, 1.2 * s, edge); + ui.rect(wx - u * 0.016, wrist_y, u * 0.032, u * 0.042, fill); + } + + // Legs + feet. + let leg_t = u * 0.046 + u * 0.010 * build; + for side in [-1.0f32, 1.0] { + let hx = cx + side * hip_hw * 0.52; + let kx = cx + side * hip_hw * 0.56; + let ax = cx + side * hip_hw * 0.50; + ui.line(hx, hip_y, kx, knee_y, leg_t, fill); + ui.line(kx, knee_y, ax, ankle_y, leg_t * 0.84, fill); + ui.line(hx, hip_y, kx, knee_y, 1.2 * s, edge); + ui.line(kx, knee_y, ax, ankle_y, 1.2 * s, edge); + ui.line(ax - u * 0.030, feet, ax + u * 0.036, feet, 2.4 * s, edge); + } + + // Scan lines across the figure box, plus one travelling sweep. + let box_top = head_cy - head_r * 1.4; + let box_h = feet - box_top; + let mut y = box_top; + while y < feet { + ui.rect(cx - sh_hw * 1.35, y, sh_hw * 2.7, 1.0, [140, 232, 246, 16]); + y += 4.0 * s; + } + let sweep = box_top + (phase * 0.24).fract() * box_h; + ui.rect( + cx - sh_hw * 1.35, + sweep, + sh_hw * 2.7, + 2.0 * s, + [176, 244, 252, 54], + ); + ui.rect( + cx - sh_hw * 1.35, + sweep + 3.0 * s, + sh_hw * 2.7, + 5.0 * s, + [176, 244, 252, 18], + ); +} + +// ── Entry screen ──────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EntryStage { + /// Endpoint/identity entry. + Login, + /// A link attempt is in flight; the dialog owns input. + Connecting, +} + +pub struct EntryLayout; +impl EntryLayout { + /// Original `ui_loginscreen.inc` geometry, reproduced 1:1. The flow is a + /// **fixed** 330×200 page — never scaled — centred horizontally and pinned + /// to 33.7% of viewport height. Verified against the refreshed captures at + /// 800×600 (x225..556, y203..403) and 1280×1024 (x466..795, y345..545). + /// Its two text boxes are 280×40 at flow-relative y 56 and y 113, matching + /// the source's 284×40 boxes at `(26,56)` / `(26,113)`. + pub const PANEL_W: f32 = 330.0; + pub const PANEL_H: f32 = 200.0; + pub const FIELD_H: f32 = 40.0; + pub const PAD: f32 = 25.0; + const FIELD_Y: [f32; 2] = [56.0, 113.0]; + + pub fn panel_rect(w: f32, h: f32) -> [f32; 4] { + [ + (w - Self::PANEL_W) * 0.5, + h * 0.337, + Self::PANEL_W, + Self::PANEL_H, + ] + } + + pub fn field_rect(w: f32, h: f32, index: usize) -> [f32; 4] { + let [px, py, pw, _] = Self::panel_rect(w, h); + [ + px + Self::PAD, + py + Self::FIELD_Y[index.min(1)], + pw - Self::PAD * 2.0, + Self::FIELD_H, + ] + } + + pub fn endpoint_rect(w: f32, h: f32) -> [f32; 4] { + Self::field_rect(w, h, 0) + } + + pub fn player_rect(w: f32, h: f32) -> [f32; 4] { + Self::field_rect(w, h, 1) + } + + /// Far-edge navigation plate that begins the development link attempt. + pub fn connect_rect(w: f32, h: f32) -> [f32; 4] { + nav_next_rect(w, h) + } + + /// Far-edge navigation plate that leaves the pregame flow. + pub fn back_rect(_w: f32, h: f32) -> [f32; 4] { + nav_back_rect(h) + } + + /// Top-right exit affordance. + pub fn quit_rect(w: f32, h: f32) -> [f32; 4] { + let s = scale(h); + let bw = 96.0 * s; + [w - EDGE * s - bw, 27.0 * s, bw, 20.0 * s] + } + + pub fn dialog_rect(w: f32, h: f32) -> [f32; 4] { + let s = scale(h); + let dw = 372.0 * s; + let dh = 118.0 * s; + [(w - dw) * 0.5, h * 0.46 - dh * 0.5, dw, dh] + } + + pub fn cancel_rect(w: f32, h: f32) -> [f32; 4] { + let s = scale(h); + let [dx, dy, dw, dh] = Self::dialog_rect(w, h); + let bw = 122.0 * s; + [dx + (dw - bw) * 0.5, dy + dh - 32.0 * s, bw, 22.0 * s] + } +} + +pub struct EntryScreen { + pub endpoint: TextField, + pub player: TextField, + stage: EntryStage, + status: Option, + phase: f32, +} + +impl Default for EntryScreen { + fn default() -> Self { + Self::new() + } +} + +impl EntryScreen { + pub fn new() -> Self { + let mut endpoint = TextField::new(128); + endpoint.text = "ws://127.0.0.1:28093/".to_string(); + endpoint.caret = endpoint.text.len(); + + let mut player = TextField::new(32); + player.text = "dev-1".to_string(); + player.caret = player.text.len(); + + Self { + endpoint, + player, + stage: EntryStage::Login, + status: None, + phase: 0.0, + } + } + + pub fn stage(&self) -> EntryStage { + self.stage + } + + pub fn status(&self) -> Option<&str> { + self.status.as_deref() + } + + /// Advance animation clocks (starfield twinkle, caret blink, link marquee). + pub fn tick(&mut self, dt: f32) { + self.phase = (self.phase + dt) % 3600.0; + } + + /// Show the connecting dialog without emitting an action — host-driven, or + /// for isolated capture of the stage. + pub fn begin_connecting(&mut self) { + self.stage = EntryStage::Connecting; + self.status = None; + } + + /// Abandon the in-flight attempt and surface why. + pub fn fail(&mut self, reason: impl Into) { + self.stage = EntryStage::Login; + self.status = Some(reason.into()); + } + + pub fn reset(&mut self) { + self.stage = EntryStage::Login; + self.status = None; + } + + pub fn join_options(&self) -> JoinOptions { + JoinOptions { + endpoint: self.endpoint.text.clone(), + player_id: self.player.text.clone(), + actor_id: self.player.text.clone(), + ticket: None, + release: None, + } + } + + fn can_connect(&self) -> bool { + !self.endpoint.text.trim().is_empty() && !self.player.text.trim().is_empty() + } + + /// Route a typed character into the focused field. Returns whether it was + /// consumed. + pub fn input_char(&mut self, c: char) -> bool { + if self.stage != EntryStage::Login { + return false; + } + if self.endpoint.focused { + self.endpoint.insert(c); + true + } else if self.player.focused { + self.player.insert(c); + true + } else { + false + } + } + + pub fn backspace(&mut self) -> bool { + if self.stage != EntryStage::Login { + return false; + } + if self.endpoint.focused { + self.endpoint.backspace(); + true + } else if self.player.focused { + self.player.backspace(); + true + } else { + false + } + } + + /// Tab traversal across the development fields (the shared `TextField` has + /// no focus model of its own). + pub fn focus_next(&mut self) { + self.focus_step(true); + } + + /// Shift+Tab traversal. + pub fn focus_prev(&mut self) { + self.focus_step(false); + } + + fn focus_step(&mut self, forward: bool) { + if self.stage != EntryStage::Login { + return; + } + // Two stops, so forward and backward land on the same other field; the + // direction still matters from the unfocused state. + let to_player = if self.endpoint.focused { + true + } else if self.player.focused { + false + } else { + !forward + }; + self.endpoint.focused = !to_player; + self.player.focused = to_player; + self.endpoint.caret = self.endpoint.text.chars().count(); + self.player.caret = self.player.text.chars().count(); + } + + pub fn draw(&mut self, ui: &mut UiBuilder, w: f32, h: f32) -> Option { + let s = scale(h); + let connecting = self.stage == EntryStage::Connecting; + let press = !connecting && ui.interact(0.0, 0.0, w, h).pressed; + + draw_backdrop(ui, w, h, self.phase); + draw_identity(ui, s, 0); + + if connecting { + ui.set_input_enabled(false); + } + let mut action = self.draw_login(ui, w, h, s, press); + if connecting { + ui.set_input_enabled(true); + if let Some(a) = self.draw_dialog(ui, w, h, s) { + action = Some(a); + } + } + action + } + + fn draw_login( + &mut self, + ui: &mut UiBuilder, + w: f32, + h: f32, + s: f32, + press: bool, + ) -> Option { + let panel = EntryLayout::panel_rect(w, h); + let [px, py, pw, ph] = panel; + draw_panel(ui, panel, s, "LOG IN", Some("DEV ENTRY")); + + let pad = EntryLayout::PAD * s; + let inner_w = pw - pad * 2.0; + let lp = T_LABEL * s; + + for (index, (label, hint)) in [("ENDPOINT", "ws://host:port/"), ("PLAYER ID", "identity")] + .iter() + .enumerate() + { + let rect = EntryLayout::field_rect(w, h, index); + ui.text(label, rect[0] + 9.0 * s, rect[1] - 10.0 * s, lp, INK_DIM); + let field = if index == 0 { + &mut self.endpoint + } else { + &mut self.player + }; + draw_field(ui, field, rect, s, self.phase, press, hint); + } + + // Status / development disclosure in the panel foot. + let foot_y = py + ph - 26.0 * s; + hairline(ui, px + pad, foot_y - 10.0 * s, inner_w, s, HAIRLINE); + match &self.status { + Some(reason) => { + ui.rect(px + pad, foot_y + s, 2.0 * s, glyph_h(T_MICRO * s), DANGER); + ui.text(reason, px + pad + 8.0 * s, foot_y, T_MICRO * s, DANGER); + } + None => { + ui.text( + "DEVELOPMENT ENTRY - NOT AN AUTHENTICATED PATH", + px + pad, + foot_y, + T_MICRO * s, + INK_DIM, + ); + } + } + + // Top-right exit. + let quit = EntryLayout::quit_rect(w, h); + let qr = ui.interact(quit[0], quit[1], quit[2], quit[3]); + let qp = T_LABEL * s; + let qw = measure(ui, "EXIT CLIENT", qp); + text_right( + ui, + "EXIT CLIENT", + quit[0] + quit[2], + quit[1] + (quit[3] - glyph_h(qp)) * 0.5, + qp, + if qr.hovered { DANGER } else { INK_DIM }, + ); + if qr.hovered { + hairline( + ui, + quit[0] + quit[2] - qw, + quit[1] + quit[3] - 2.0 * s, + qw, + s, + DANGER, + ); + } + + draw_footer( + ui, + w, + h, + s, + "Enter a development endpoint and identity, then continue to link.", + Some("ORDINARY LAUNCH USES A SIGNED LAUNCH CONTEXT"), + ); + + let can = self.can_connect(); + let next = EntryLayout::connect_rect(w, h); + if !can { + text_right( + ui, + "ENDPOINT AND IDENTITY REQUIRED", + next[0] + next[2], + next[1] - 16.0 * s, + T_MICRO * s, + DANGER, + ); + } + if nav_plate(ui, next, "CONNECT", true, can) { + self.stage = EntryStage::Connecting; + self.status = None; + return Some(ScreenAction::Connect(self.join_options())); + } + if nav_plate(ui, EntryLayout::back_rect(w, h), "BACK", false, true) { + return Some(ScreenAction::Back); + } + if qr.clicked { + return Some(ScreenAction::Quit); + } + None + } + + fn draw_dialog(&mut self, ui: &mut UiBuilder, w: f32, h: f32, s: f32) -> Option { + ui.rect(0.0, 0.0, w, h, SCRIM); + let r = EntryLayout::dialog_rect(w, h); + let [dx, dy, dw, dh] = r; + ui.rect(dx, dy, dw, dh, [12, 48, 56, 244]); + ui.border(dx, dy, dw, dh, (1.0 * s).max(1.0), ACCENT); + corner_brackets(ui, r, 18.0 * s, (1.5 * s).max(1.0), RAIL); + + text_center( + ui, + "ESTABLISHING LINK", + dx + dw * 0.5, + dy + 14.0 * s, + T_PANEL * s * 0.94, + INK, + ); + + // Indeterminate marquee: a three-block group travelling the track. The + // client has no progress signal here, so it must not fake one. + let cells = 12u32; + let gap = 4.0 * s; + let cw = (dw - 40.0 * s - gap * (cells - 1) as f32) / cells as f32; + let cy = dy + 46.0 * s; + let head = ((self.phase * 7.0) as u32) % (cells + 3); + for i in 0..cells { + let lit = i + 3 > head && i <= head; + ui.rect( + dx + 20.0 * s + i as f32 * (cw + gap), + cy, + cw, + 10.0 * s, + if lit { ACCENT } else { [10, 34, 40, 232] }, + ); + } + + text_center( + ui, + self.endpoint.text.as_str(), + dx + dw * 0.5, + cy + 16.0 * s, + T_MICRO * s, + [150, 214, 226, 220], + ); + + if action_bar(ui, EntryLayout::cancel_rect(w, h), s, "CANCEL", true) { + self.stage = EntryStage::Login; + self.status = Some("LINK ATTEMPT CANCELLED".to_string()); + return Some(ScreenAction::CancelConnect); + } + None + } +} + +// ── Character screens ─────────────────────────────────────────────────────── + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CharacterStage { + /// Roster-focused selection with the selected character presented left. + Roster, + /// Creation step one — lineage, presentation and build. + CreateProfile, + /// Creation step two — naming and the final summary. + CreateIdentity, +} + +/// One roster row. `id` is an opaque host-owned stable identifier; screens +/// only retain it so their selection intent can be routed without deriving an +/// identity from display text. Meta fields are optional: a host that only knows +/// names leaves them empty and the detail block says so rather than inventing +/// data. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RosterEntry { + pub id: String, + /// Safe body projection for the retained roster viewer. + pub female: bool, + pub name: String, + pub lineage: String, + pub vocation: String, + pub location: String, + pub played: String, +} + +impl RosterEntry { + pub fn named(name: impl Into) -> Self { + Self { + name: name.into(), + ..Default::default() + } + } +} + +/// A Successor-owned playable lineage. Names, homeworlds and copy are original +/// to this project — the original client's species table is not reproduced. +pub struct Lineage { + pub id: &'static str, + pub name: &'static str, + pub home: &'static str, + pub blurb: &'static str, +} + +pub const LINEAGES: [Lineage; 6] = [ + Lineage { + id: "terran", + name: "TERRAN", + home: "CORE WORLDS", + blurb: "Baseline settlers of the inner colonies, found on every registered world in the sector. Terran recruits carry no innate bonus and no innate penalty, which gives them the widest starting spread of any lineage and the shortest path into a second vocation.", + }, + Lineage { + id: "voskan", + name: "VOSKAN", + home: "HEAVY BELT", + blurb: "Raised under crushing gravity in the belt foundries. Dense musculature, deliberate movement, and a frame built to hold ground. Voskan carry loads no other lineage will attempt, and pay for it in sprint endurance.", + }, + Lineage { + id: "iridian", + name: "IRIDIAN", + home: "SALT FLATS", + blurb: "Nomads of the dry basins, built for heat and glare, with reflective scale patterning across the shoulders and forearms. Iridian scouts read terrain and weather earlier than the instruments issued to them do.", + }, + Lineage { + id: "kelmari", + name: "KELMARI", + home: "TIDEWORKS", + blurb: "Amphibious dockhands out of the flooded works. Broad hands, a sealed airway, and a pressure tolerance that makes salvage diving routine labour rather than hazard pay.", + }, + Lineage { + id: "thalsi", + name: "THALSI", + home: "CANOPY REACH", + blurb: "Arboreal climbers with long limbs and a low centre of mass. Thalsi read vertical space instinctively, take falls that would ground a Terran, and dislike open ground for exactly that reason.", + }, + Lineage { + id: "ogrim", + name: "OGRIM", + home: "ASH TERRACES", + blurb: "Ash-terrace labourers with thickened hide and a slow metabolism. Ogrim endure exposure, airborne toxins and hunger well past the point where any other recruit files for extraction.", + }, +]; + +pub const VOCATIONS: [&str; 4] = ["TECHNICIAN", "SCOUT", "MEDIC", "MARKSMAN"]; + +/// Roster ceiling; the original capped characters per galaxy, and the creation +/// entry point goes inert at the cap rather than disappearing. +pub const ROSTER_CAP: usize = 8; + +const NAME_HEADS: [&str; 12] = [ + "Sar", "Vel", "Tor", "Mek", "Ash", "Kel", "Dru", "Bel", "Nyx", "Ora", "Tav", "Zin", +]; +const NAME_TAILS: [&str; 12] = [ + "ath", "ira", "on", "us", "el", "ka", "dan", "ris", "mo", "vek", "tia", "ur", +]; +const SURNAMES: [&str; 12] = [ + "Halvex", "Draymond", "Okoro", "Vantel", "Sarn", "Ilbrecht", "Moss", "Karrow", "Deleon", + "Ferrick", "Ostrand", "Yuel", +]; + +/// Deterministic Successor name from a seed — same seed, same name. +pub fn generated_name(seed: u32) -> (String, String) { + let mut state = seed.wrapping_mul(0x9E37_79B9) | 1; + let a = (xorshift(&mut state) % NAME_HEADS.len() as u32) as usize; + let b = (xorshift(&mut state) % NAME_TAILS.len() as u32) as usize; + let c = (xorshift(&mut state) % SURNAMES.len() as u32) as usize; + ( + format!("{}{}", NAME_HEADS[a], NAME_TAILS[b]), + SURNAMES[c].to_string(), + ) +} + +pub struct CharacterLayout; +impl CharacterLayout { + /// Creation stages composition: one tall framed controls pane sits flush + /// right at roughly a quarter of the viewport, top-anchored. + pub const PANEL_W: f32 = 340.0; + pub const PANEL_MARGIN: f32 = 24.0; + pub const PANEL_TOP: f32 = 34.0; + pub const PANEL_BOTTOM_GAP: f32 = 118.0; + pub const PAD: f32 = 14.0; + pub const FIELD_H: f32 = 32.0; + /// Roster selection frame from `character-selection.png`: a compact 527×273 + /// frame flush top-right (x487, y6 at 1024×768). + pub const ROSTER_PANEL_W: f32 = 527.0; + pub const ROSTER_PANEL_H: f32 = 273.0; + + pub fn panel_rect(w: f32, h: f32) -> [f32; 4] { + let s = scale(h); + let pw = Self::PANEL_W * s; + let py = Self::PANEL_TOP * s; + [ + w - pw - Self::PANEL_MARGIN * s, + py, + pw, + h - py - Self::PANEL_BOTTOM_GAP * s, + ] + } + + pub fn roster_panel_rect(w: f32, h: f32) -> [f32; 4] { + let s = scale(h); + let pw = Self::ROSTER_PANEL_W * s; + let ph = Self::ROSTER_PANEL_H * s; + [w - pw - 10.0 * s, 6.0 * s, pw, ph] + } + + /// Viewport reserved for the retained full-body presentation. A host with a + /// live pawn renders into exactly this rect. + /// + /// Derived from the panel's left edge rather than a fixed viewport + /// fraction, so the viewer keeps clear of the rail at 4:3 as well as 16:9. + pub fn paperdoll_rect(w: f32, h: f32) -> [f32; 4] { + let s = scale(h); + let left = 40.0 * s; + let right = Self::panel_rect(w, h)[0] - 24.0 * s; + let avail = (right - left).max(1.0); + let pw = (360.0 * s).min(avail); + [left + (avail - pw) * 0.5, h * 0.10, pw, h * 0.76] + } + + pub fn content_x(w: f32, h: f32) -> f32 { + Self::panel_rect(w, h)[0] + Self::PAD * scale(h) + } + + pub fn content_w(h: f32) -> f32 { + let s = scale(h); + Self::PANEL_W * s - Self::PAD * s * 2.0 + } + + pub fn roster_rows_visible(_w: f32, _h: f32) -> usize { + 6 + } + + /// Rect for the `slot`-th *visible* row (0 = topmost on screen). + pub fn roster_row_rect(w: f32, h: f32, slot: usize) -> [f32; 4] { + let s = scale(h); + let [px, py, pw, _] = Self::roster_panel_rect(w, h); + [ + px + 10.0 * s, + py + 58.0 * s + slot as f32 * 26.0 * s, + pw - 20.0 * s, + 24.0 * s, + ] + } + + pub fn page_up_rect(w: f32, h: f32) -> [f32; 4] { + let s = scale(h); + let [px, py, pw, _] = Self::roster_panel_rect(w, h); + [px + pw - 42.0 * s, py + 6.0 * s, 20.0 * s, 20.0 * s] + } + + pub fn page_down_rect(w: f32, h: f32) -> [f32; 4] { + let s = scale(h); + let [px, py, pw, _] = Self::roster_panel_rect(w, h); + [px + pw - 20.0 * s, py + 6.0 * s, 20.0 * s, 20.0 * s] + } + + pub fn new_character_rect(w: f32, h: f32) -> [f32; 4] { + let s = scale(h); + let [px, py, pw, ph] = Self::roster_panel_rect(w, h); + let bw = (pw - 28.0 * s) * 0.5; + [px + pw * 0.5 + 4.0 * s, py + ph - 34.0 * s, bw, 24.0 * s] + } + + pub fn delete_character_rect(w: f32, h: f32) -> [f32; 4] { + let s = scale(h); + let [px, py, pw, ph] = Self::roster_panel_rect(w, h); + let bw = (pw - 28.0 * s) * 0.5; + [px + 10.0 * s, py + ph - 34.0 * s, bw, 24.0 * s] + } + + pub fn lineage_row_rect(w: f32, h: f32, index: usize) -> [f32; 4] { + let s = scale(h); + [ + Self::content_x(w, h), + Self::panel_rect(w, h)[1] + 232.0 * s + index as f32 * 26.0 * s, + Self::content_w(h), + 26.0 * s, + ] + } + + pub fn gender_rect(w: f32, h: f32, female: bool) -> [f32; 4] { + let s = scale(h); + let cw = 92.0 * s; + [ + Self::content_x(w, h) + if female { cw + 8.0 * s } else { 0.0 }, + Self::panel_rect(w, h)[1] + 424.0 * s, + cw, + 24.0 * s, + ] + } + + pub fn build_rect(w: f32, h: f32) -> [f32; 4] { + let s = scale(h); + let label = 74.0 * s; + [ + Self::content_x(w, h) + label, + Self::panel_rect(w, h)[1] + 460.0 * s, + Self::content_w(h) - label, + 22.0 * s, + ] + } + + pub fn vocation_rect(w: f32, h: f32, index: usize) -> [f32; 4] { + let s = scale(h); + let cw = (Self::content_w(h) - 8.0 * s) * 0.5; + [ + Self::content_x(w, h) + (index % 2) as f32 * (cw + 8.0 * s), + Self::panel_rect(w, h)[1] + 508.0 * s + (index / 2) as f32 * 28.0 * s, + cw, + 24.0 * s, + ] + } + + pub fn name_field_rect(w: f32, h: f32) -> [f32; 4] { + let s = scale(h); + [ + Self::content_x(w, h), + Self::panel_rect(w, h)[1] + 57.0 * s, + Self::content_w(h), + Self::FIELD_H * s, + ] + } + + pub fn surname_field_rect(w: f32, h: f32) -> [f32; 4] { + let s = scale(h); + [ + Self::content_x(w, h), + Self::panel_rect(w, h)[1] + 116.0 * s, + Self::content_w(h), + Self::FIELD_H * s, + ] + } + + pub fn generate_rect(w: f32, h: f32) -> [f32; 4] { + let s = scale(h); + let bw = 190.0 * s; + [ + Self::content_x(w, h) + (Self::content_w(h) - bw) * 0.5, + Self::panel_rect(w, h)[1] + 160.0 * s, + bw, + 22.0 * s, + ] + } + + pub fn tutorial_rect(w: f32, h: f32) -> [f32; 4] { + let s = scale(h); + let [_, py, _, ph] = Self::panel_rect(w, h); + [ + Self::content_x(w, h), + py + ph - 30.0 * s, + Self::content_w(h), + 20.0 * s, + ] + } + + pub fn next_rect(w: f32, h: f32) -> [f32; 4] { + nav_next_rect(w, h) + } + + pub fn back_rect(_w: f32, h: f32) -> [f32; 4] { + nav_back_rect(h) + } +} + +/// Mutable creation state carried across the two creation stages. +pub struct CreationDraft { + pub lineage: usize, + pub vocation: usize, + pub female: bool, + pub build: f32, + pub tutorial: bool, + pub name: TextField, + pub surname: TextField, + seed: u32, +} + +impl Default for CreationDraft { + fn default() -> Self { + Self { + lineage: 0, + vocation: 0, + female: false, + build: 0.5, + tutorial: true, + name: TextField::new(24), + surname: TextField::new(24), + seed: 1, + } + } +} + +impl CreationDraft { + /// `NAME SURNAME`, trimmed; empty when no given name has been entered. + pub fn full_name(&self) -> String { + let first = self.name.text.trim(); + let last = self.surname.text.trim(); + if first.is_empty() { + String::new() + } else if last.is_empty() { + first.to_string() + } else { + format!("{first} {last}") + } + } + + pub fn build_label(&self) -> &'static str { + match self.build { + b if b < 0.2 => "SLIGHT", + b if b < 0.4 => "LEAN", + b if b < 0.6 => "STANDARD", + b if b < 0.8 => "SOLID", + _ => "HEAVY", + } + } + + pub fn lineage(&self) -> &'static Lineage { + &LINEAGES[self.lineage.min(LINEAGES.len() - 1)] + } + + pub fn vocation(&self) -> &'static str { + VOCATIONS[self.vocation.min(VOCATIONS.len() - 1)] + } + + /// Fill both name fields with the next deterministic suggestion. + pub fn generate(&mut self) { + let (first, last) = generated_name(self.seed.wrapping_add(self.lineage as u32 * 977)); + self.seed = self.seed.wrapping_add(1); + self.name.text = first; + self.name.caret = self.name.text.chars().count(); + self.surname.text = last; + self.surname.caret = self.surname.text.chars().count(); + } +} + +pub struct CharacterScreen { + pub roster: Vec, + pub draft: CreationDraft, + stage: CharacterStage, + selected: usize, + scroll: usize, + paperdoll_hosted: bool, + status: Option, + status_bad: bool, + phase: f32, +} + +impl CharacterScreen { + /// Name-only roster (the host has not projected character detail yet). + pub fn new(roster: Vec) -> Self { + Self::with_entries(roster.into_iter().map(RosterEntry::named).collect()) + } + + pub fn with_entries(roster: Vec) -> Self { + Self { + roster, + draft: CreationDraft::default(), + stage: CharacterStage::Roster, + selected: 0, + scroll: 0, + paperdoll_hosted: false, + status: None, + status_bad: false, + phase: 0.0, + } + } + pub fn selected(&self) -> usize { + self.selected + } + + /// The opaque stable id of the highlighted roster entry, when the host + /// projected one. Presentation-only/demo rows intentionally have none. + pub fn selected_stable_id(&self) -> Option<&str> { + self.roster + .get(self.selected) + .filter(|entry| !entry.id.is_empty()) + .map(|entry| entry.id.as_str()) + } + + /// Replace the host projection while preserving the selected stable id when + /// it remains present. This lets a refreshed roster reorder safely. + pub fn replace_roster(&mut self, roster: Vec) { + let selected_id = self.selected_stable_id().map(str::to_owned); + self.roster = roster; + self.selected = selected_id + .as_deref() + .and_then(|id| self.roster.iter().position(|entry| entry.id == id)) + .unwrap_or_else(|| self.selected.min(self.roster.len().saturating_sub(1))); + self.scroll = self.scroll.min(self.selected); + } + + /// Highlight a projected stable id. A host calls this after a successful + /// creation refresh so the new authoritative row remains the subject. + pub fn select_stable_id(&mut self, id: &str) -> bool { + let Some(index) = self.roster.iter().position(|entry| entry.id == id) else { + return false; + }; + self.selected = index; + self.scroll = index; + true + } + + pub fn scroll(&self) -> usize { + self.scroll + } + + pub fn stage(&self) -> CharacterStage { + self.stage + } + + pub fn set_stage(&mut self, stage: CharacterStage) { + self.stage = stage; + } + + /// Suppress the abstract silhouette because the host draws a live pawn into + /// [`CharacterLayout::paperdoll_rect`]. Pad, frame and caption stay. + pub fn set_paperdoll_hosted(&mut self, hosted: bool) { + self.paperdoll_hosted = hosted; + } + + pub fn paperdoll_hosted(&self) -> bool { + self.paperdoll_hosted + } + + /// One-line status under the instruction copy — a pending/handoff note. + /// Rendered on every character stage, so a hosted create that is waiting or + /// rejected is visible on the surface that produced it. + pub fn set_status(&mut self, status: impl Into) { + self.status = Some(status.into()); + self.status_bad = false; + } + + /// Same line in the danger tone — for a bounded rejection. + pub fn set_status_error(&mut self, status: impl Into) { + self.status = Some(status.into()); + self.status_bad = true; + } + + pub fn status(&self) -> Option<&str> { + self.status.as_deref() + } + + pub fn status_is_error(&self) -> bool { + self.status_bad + } + + pub fn clear_status(&mut self) { + self.status = None; + self.status_bad = false; + } + + pub fn tick(&mut self, dt: f32) { + self.phase = (self.phase + dt) % 3600.0; + } + + /// Move the roster highlight, keeping it inside the scrolled viewport. + pub fn move_selection(&mut self, delta: i32, w: f32, h: f32) { + if self.roster.is_empty() { + return; + } + let last = self.roster.len() - 1; + self.selected = (self.selected as i32 + delta).clamp(0, last as i32) as usize; + let visible = CharacterLayout::roster_rows_visible(w, h); + if self.selected < self.scroll { + self.scroll = self.selected; + } else if self.selected >= self.scroll + visible { + self.scroll = self.selected + 1 - visible; + } + } + + fn focused_field(&mut self) -> Option<&mut TextField> { + if self.stage != CharacterStage::CreateIdentity { + return None; + } + if self.draft.name.focused { + Some(&mut self.draft.name) + } else if self.draft.surname.focused { + Some(&mut self.draft.surname) + } else { + None + } + } + + pub fn input_char(&mut self, c: char) -> bool { + match self.focused_field() { + Some(field) => { + field.insert(c); + true + } + None => false, + } + } + + pub fn backspace(&mut self) -> bool { + match self.focused_field() { + Some(field) => { + field.backspace(); + true + } + None => false, + } + } + + /// Tab traversal across the naming fields. + pub fn focus_next(&mut self) { + self.focus_step(true); + } + + /// Shift+Tab traversal. + pub fn focus_prev(&mut self) { + self.focus_step(false); + } + + fn focus_step(&mut self, forward: bool) { + if self.stage != CharacterStage::CreateIdentity { + return; + } + let to_surname = if self.draft.name.focused { + true + } else if self.draft.surname.focused { + false + } else { + !forward + }; + self.draft.name.focused = !to_surname; + self.draft.surname.focused = to_surname; + self.draft.name.caret = self.draft.name.text.chars().count(); + self.draft.surname.caret = self.draft.surname.text.chars().count(); + } + + pub fn draw(&mut self, ui: &mut UiBuilder, w: f32, h: f32) -> Option { + let s = scale(h); + let press = ui.interact(0.0, 0.0, w, h).pressed; + draw_backdrop(ui, w, h, self.phase); + draw_identity( + ui, + s, + match self.stage { + CharacterStage::Roster => 1, + CharacterStage::CreateProfile => 2, + CharacterStage::CreateIdentity => 3, + }, + ); + self.draw_presentation(ui, w, h, s); + + match self.stage { + CharacterStage::Roster => self.draw_roster(ui, w, h, s), + CharacterStage::CreateProfile => self.draw_profile(ui, w, h, s), + CharacterStage::CreateIdentity => self.draw_summary(ui, w, h, s, press), + } + } + + /// Retained left-hand presentation: viewer plus the subject's caption. Runs + /// on every stage so the figure never disappears between steps. + fn draw_presentation(&mut self, ui: &mut UiBuilder, w: f32, h: f32, s: f32) { + let rect = CharacterLayout::paperdoll_rect(w, h); + let creating = self.stage != CharacterStage::Roster; + let has_subject = creating || !self.roster.is_empty(); + let figure = (!self.paperdoll_hosted && has_subject).then(|| Figure { + build: if creating { self.draft.build } else { 0.5 }, + female: if creating { + self.draft.female + } else { + self.roster + .get(self.selected) + .is_some_and(|entry| entry.female) + }, + }); + draw_viewer(ui, rect, s, self.phase, figure.as_ref()); + + let cx = rect[0] + rect[2] * 0.5; + let cy = rect[1] + rect[3] * 0.95; + let (title, sub) = if creating { + let name = self.draft.full_name(); + ( + if name.is_empty() { + "UNNAMED".to_string() + } else { + name.to_uppercase() + }, + format!( + "{} / {} / {}", + self.draft.lineage().name, + if self.draft.female { "FEMALE" } else { "MALE" }, + self.draft.build_label() + ), + ) + } else if let Some(entry) = self.roster.get(self.selected) { + (entry.name.clone(), String::new()) + } else { + ( + "NO CHARACTER".to_string(), + "CREATE ONE TO CONTINUE".to_string(), + ) + }; + let tp = T_TITLE * s * 0.86; + text_center(ui, &title, cx, cy, tp, INK); + if !sub.is_empty() { + hairline( + ui, + cx - 60.0 * s, + cy + glyph_h(tp) + 7.0 * s, + 120.0 * s, + s, + HAIRLINE_LIT, + ); + text_center( + ui, + &sub, + cx, + cy + glyph_h(tp) + 14.0 * s, + T_MICRO * s, + INK_DIM, + ); + } + } + + fn draw_roster(&mut self, ui: &mut UiBuilder, w: f32, h: f32, s: f32) -> Option { + let panel = CharacterLayout::roster_panel_rect(w, h); + let [px, py, pw, _ph] = panel; + let count = self.roster.len(); + draw_panel( + ui, + panel, + s, + "SELECT A CHARACTER", + Some("AVAILABLE CHARACTERS"), + ); + + let visible = CharacterLayout::roster_rows_visible(w, h); + self.scroll = self.scroll.min(count.saturating_sub(visible)); + + // 4-column header bar (pill-like header buttons) + let hy = py + 34.0 * s; + let hw = pw - 20.0 * s; + let hx = px + 10.0 * s; + let cols = [ + ("Name", 0.38f32), + ("Galaxy", 0.20), + ("Planet", 0.24), + ("Server", 0.18), + ]; + ui.rect(hx, hy, hw, 18.0 * s, [0, 56, 68, 152]); + hairline(ui, hx, hy + 18.0 * s, hw, s, HAIRLINE_LIT); + let mut cur_x = hx; + for (column, (name, ratio)) in cols.into_iter().enumerate() { + let col_w = hw * ratio; + // Column rhythm comes from the same left anchors as each row, not + // four bordered cells. + ui.text( + name, + cur_x + if column == 0 { 6.0 * s } else { 4.0 * s }, + hy + 3.0 * s, + T_MICRO * s, + INK_SOFT, + ); + cur_x += col_w; + } + let mut action = None; + + // Paging chevrons, drawn only when the list overflows the viewport. + if count > visible { + for (r, up, live) in [ + (CharacterLayout::page_up_rect(w, h), true, self.scroll > 0), + ( + CharacterLayout::page_down_rect(w, h), + false, + self.scroll + visible < count, + ), + ] { + let resp = ui.interact(r[0], r[1], r[2], r[3]); + let tint = if !live { + INK_GHOST + } else if resp.hovered { + ACCENT + } else { + INK_DIM + }; + let mid = r[0] + r[2] * 0.5; + let (y0, y1) = if up { + (r[1] + r[3] * 0.64, r[1] + r[3] * 0.36) + } else { + (r[1] + r[3] * 0.36, r[1] + r[3] * 0.64) + }; + ui.line(mid - 5.0 * s, y0, mid, y1, 1.6 * s, tint); + ui.line(mid, y1, mid + 5.0 * s, y0, 1.6 * s, tint); + if live && resp.clicked { + self.scroll = if up { + self.scroll.saturating_sub(visible) + } else { + (self.scroll + visible).min(count - visible) + }; + } + } + } + + if count == 0 { + let list_y = py + 58.0 * s; + text_center( + ui, + "NO CHARACTERS ON THIS SHARD", + px + pw * 0.5, + list_y + 26.0 * s, + T_BODY * s, + INK_DIM, + ); + text_center( + ui, + "Create one to continue.", + px + pw * 0.5, + list_y + 46.0 * s, + T_MICRO * s, + INK_GHOST, + ); + } + + for slot in 0..visible.min(count.saturating_sub(self.scroll)) { + let index = self.scroll + slot; + let r = CharacterLayout::roster_row_rect(w, h, slot); + let on = index == self.selected; + let resp = ui.interact(r[0], r[1], r[2], r[3]); + if on { + ui.rect(r[0], r[1], r[2], r[3], ACCENT_SOFT); + ui.rect(r[0], r[1], 3.0 * s, r[3], ACCENT); + ui.mask_uv( + r[0] + r[2] - 11.0 * s, + r[1] + (r[3] - 13.0 * s) * 0.5, + 8.0 * s, + 13.0 * s, + crate::hud::ROSTER_CHEVRON_UV, + INK, + ); + } else if resp.hovered { + ui.rect(r[0], r[1], r[2], r[3], ROW_HOVER); + } else if slot % 2 == 1 { + ui.rect(r[0], r[1], r[2], r[3], [0, 44, 54, 68]); + } + let entry = &self.roster[index]; + let np = T_MICRO * s * 1.15; + let col_y = r[1] + (r[3] - glyph_h(np)) * 0.5; + + // Col 0: Name + ui.text( + &entry.name, + r[0] + 6.0 * s, + col_y, + np, + if on { INK } else { INK_SOFT }, + ); + + // Col 1: Galaxy + let galaxy = if entry.lineage.is_empty() { + "Core3" + } else { + &entry.lineage + }; + ui.text( + galaxy, + r[0] + hw * 0.38 + 4.0 * s, + col_y, + np, + if on { INK } else { INK_DIM }, + ); + + // Col 2: Planet + let planet = if entry.location.is_empty() { + "Aboard a Space Station" + } else { + &entry.location + }; + ui.text( + planet, + r[0] + hw * 0.58 + 4.0 * s, + col_y, + np, + if on { INK } else { INK_DIM }, + ); + + // Col 3: Server + let server = if entry.vocation.is_empty() { + "Online" + } else { + &entry.vocation + }; + ui.text( + server, + r[0] + hw * 0.82 + 4.0 * s, + col_y, + np, + if on { INK } else { INK_DIM }, + ); + + if resp.clicked { + if on { + action = Some(ScreenAction::SelectCharacter(index)); + } else { + self.selected = index; + } + } + } + + // Delete & Create buttons on the roster panel's bottom band + let delete_rect = CharacterLayout::delete_character_rect(w, h); + if action_bar(ui, delete_rect, s, "Delete", count > 0) && count > 0 { + self.status = Some(format!( + "DELETE REQUESTED FOR {}", + self.roster[self.selected].name + )); + } + + let create_rect = CharacterLayout::new_character_rect(w, h); + if action_bar(ui, create_rect, s, "Create", count < ROSTER_CAP) { + self.stage = CharacterStage::CreateProfile; + self.status = None; + return None; + } + + // Bottom footer instructions + // A host status (create rejected / awaiting authority) takes the second + // line; otherwise the static helper copy keeps it. + draw_footer( + ui, + w, + h, + s, + "Choose the Character you would like to play now.", + self.status.is_none().then_some( + "You may also choose to Create a new Character, or Delete one of your existing Characters.", + ), + ); + if let Some(status) = self.status.as_deref() { + draw_status_line(ui, w, h, s, status, self.status_bad); + } + + // Exit button at bottom-left corner + let exit_rect = [ + 16.0 * s, + h - NAV_INSET_Y * s - NAV_H * s * 0.5, + 120.0 * s, + NAV_H * s, + ]; + if action_bar(ui, exit_rect, s, "Exit", true) { + action = Some(ScreenAction::Back); + } + + // NEXT wedge at bottom-right corner + if nav_plate( + ui, + CharacterLayout::next_rect(w, h), + "NEXT", + true, + count > 0, + ) { + action = Some(ScreenAction::SelectCharacter(self.selected)); + } + + action + } + + fn draw_profile(&mut self, ui: &mut UiBuilder, w: f32, h: f32, s: f32) -> Option { + let panel = CharacterLayout::panel_rect(w, h); + let py = panel[1]; + draw_panel(ui, panel, s, "LINEAGE & BUILD", Some("STEP 1 OF 2")); + + let cx = CharacterLayout::content_x(w, h); + let cw = CharacterLayout::content_w(h); + let lineage = self.draft.lineage(); + + ui.text(lineage.home, cx, py + 40.0 * s, T_LABEL * s, ACCENT); + draw_wrapped( + ui, + lineage.blurb, + cx, + py + 58.0 * s, + cw, + T_BODY * s, + 15.0 * s, + 8, + INK_SOFT, + ); + + hairline(ui, cx, py + 200.0 * s, cw, s, HAIRLINE_LIT); + ui.text("LINEAGE", cx, py + 210.0 * s, T_LABEL * s, INK_DIM); + for (i, item) in LINEAGES.iter().enumerate() { + let r = CharacterLayout::lineage_row_rect(w, h, i); + let on = i == self.draft.lineage; + let resp = ui.interact(r[0], r[1], r[2], r[3]); + if on { + ui.rect(r[0], r[1], r[2], r[3], ACCENT_SOFT); + ui.rect(r[0], r[1], 3.0 * s, r[3], ACCENT); + } else if resp.hovered { + ui.rect(r[0], r[1], r[2], r[3], ROW_HOVER); + } + let np = T_BODY * s; + ui.text( + item.name, + r[0] + 12.0 * s, + r[1] + (r[3] - glyph_h(np)) * 0.5, + np, + if on { INK } else { INK_SOFT }, + ); + let mp = T_MICRO * s; + text_right( + ui, + item.home, + r[0] + r[2] - 10.0 * s, + r[1] + (r[3] - glyph_h(mp)) * 0.5, + mp, + INK_DIM, + ); + hairline(ui, r[0], r[1] + r[3] - 1.0 * s, r[2], s, [20, 34, 40, 190]); + if resp.clicked { + self.draft.lineage = i; + } + } + + ui.text("PRESENTATION", cx, py + 402.0 * s, T_LABEL * s, INK_DIM); + if chip( + ui, + CharacterLayout::gender_rect(w, h, false), + s, + "MALE", + !self.draft.female, + ) { + self.draft.female = false; + } + if chip( + ui, + CharacterLayout::gender_rect(w, h, true), + s, + "FEMALE", + self.draft.female, + ) { + self.draft.female = true; + } + + let build_rect = CharacterLayout::build_rect(w, h); + let bp = T_LABEL * s; + ui.text( + "BUILD", + cx, + build_rect[1] + (build_rect[3] - glyph_h(bp)) * 0.5, + bp, + INK_DIM, + ); + track(ui, build_rect, s, &mut self.draft.build); + text_right( + ui, + self.draft.build_label(), + cx + cw, + build_rect[1] - 15.0 * s, + T_MICRO * s, + ACCENT, + ); + + ui.text( + "STARTING VOCATION", + cx, + py + 490.0 * s, + T_LABEL * s, + INK_DIM, + ); + for (i, name) in VOCATIONS.iter().enumerate() { + if chip( + ui, + CharacterLayout::vocation_rect(w, h, i), + s, + name, + i == self.draft.vocation, + ) { + self.draft.vocation = i; + } + } + + draw_footer( + ui, + w, + h, + s, + "Choose a lineage, presentation and build for your new character.", + None, + ); + if let Some(status) = self.status.as_deref() { + draw_status_line(ui, w, h, s, status, self.status_bad); + } + + if nav_plate(ui, CharacterLayout::next_rect(w, h), "NEXT", true, true) { + self.stage = CharacterStage::CreateIdentity; + return None; + } + if nav_plate(ui, CharacterLayout::back_rect(w, h), "BACK", false, true) { + self.stage = CharacterStage::Roster; + } + None + } + + fn draw_summary( + &mut self, + ui: &mut UiBuilder, + w: f32, + h: f32, + s: f32, + press: bool, + ) -> Option { + let panel = CharacterLayout::panel_rect(w, h); + let [_, py, _, ph] = panel; + draw_panel(ui, panel, s, "CHARACTER SUMMARY", Some("STEP 2 OF 2")); + + let cx = CharacterLayout::content_x(w, h); + let cw = CharacterLayout::content_w(h); + let centre = cx + cw * 0.5; + let lp = T_LABEL * s; + + // Centred labels above centred fields, as the original summary page. + let name_rect = CharacterLayout::name_field_rect(w, h); + text_center(ui, "NAME", centre, name_rect[1] - 15.0 * s, lp, INK_SOFT); + draw_field( + ui, + &mut self.draft.name, + name_rect, + s, + self.phase, + press, + "given name", + ); + + let sur_rect = CharacterLayout::surname_field_rect(w, h); + text_center(ui, "SURNAME", centre, sur_rect[1] - 15.0 * s, lp, INK_SOFT); + draw_field( + ui, + &mut self.draft.surname, + sur_rect, + s, + self.phase, + press, + "optional", + ); + + if action_bar( + ui, + CharacterLayout::generate_rect(w, h), + s, + "GENERATE A NAME", + true, + ) { + self.draft.generate(); + } + + hairline(ui, cx, py + 196.0 * s, cw, s, HAIRLINE_LIT); + ui.text("PROFILE", cx, py + 206.0 * s, T_LABEL * s, INK_DIM); + let lineage = self.draft.lineage(); + let mut ry = py + 226.0 * s; + for (key, value) in [ + ("LINEAGE", lineage.name), + ("HOMEWORLD", lineage.home), + ( + "PRESENTATION", + if self.draft.female { "FEMALE" } else { "MALE" }, + ), + ("BUILD", self.draft.build_label()), + ("VOCATION", self.draft.vocation()), + ] { + kv_row(ui, cx, ry, cw, s, key, value); + ry += 21.0 * s; + } + + hairline(ui, cx, ry + 6.0 * s, cw, s, HAIRLINE_LIT); + ui.text("DOSSIER", cx, ry + 16.0 * s, T_LABEL * s, INK_DIM); + let dossier_top = ry + 34.0 * s; + let dossier_h = py + ph - 46.0 * s - dossier_top; + ui.rect(cx, dossier_top, cw, dossier_h, [6, 15, 19, 190]); + let full = self.draft.full_name(); + let dossier = if full.is_empty() { + format!( + "Awaiting a name. This recruit will file out of the {}, {} build, trained toward {}.", + lineage.home.to_lowercase(), + self.draft.build_label().to_lowercase(), + self.draft.vocation().to_lowercase() + ) + } else { + format!( + "{full} files as {} out of the {}, {} build, trained toward {}. The record opens on first landfall and follows this recruit for the length of service.", + lineage.name.to_lowercase(), + lineage.home.to_lowercase(), + self.draft.build_label().to_lowercase(), + self.draft.vocation().to_lowercase() + ) + }; + let line_h = 15.0 * s; + let max_lines = ((dossier_h - 12.0 * s) / line_h).floor().max(1.0) as usize; + draw_wrapped( + ui, + &dossier, + cx + 8.0 * s, + dossier_top + 7.0 * s, + cw - 22.0 * s, + T_BODY * s, + line_h, + max_lines, + INK_SOFT, + ); + // Scroll rail sized to the fraction the area shows. + ui.rect( + cx + cw - 5.0 * s, + dossier_top, + 3.0 * s, + dossier_h, + [14, 30, 36, 220], + ); + ui.rect( + cx + cw - 5.0 * s, + dossier_top, + 3.0 * s, + dossier_h * 0.72, + HAIRLINE_LIT, + ); + + check_row( + ui, + CharacterLayout::tutorial_rect(w, h), + s, + "SHOW NEW PLAYER TUTORIAL", + &mut self.draft.tutorial, + ); + + draw_footer( + ui, + w, + h, + s, + "Review the profile and name your character, then continue to create.", + None, + ); + if let Some(status) = self.status.as_deref() { + draw_status_line(ui, w, h, s, status, self.status_bad); + } + + let can = !full.is_empty(); + let next = CharacterLayout::next_rect(w, h); + if !can { + text_right( + ui, + "A GIVEN NAME IS REQUIRED", + next[0] + next[2], + next[1] - 16.0 * s, + T_MICRO * s, + DANGER, + ); + } + if nav_plate(ui, next, "CREATE", true, can) { + return Some(ScreenAction::CreateCharacter(full)); + } + if nav_plate(ui, CharacterLayout::back_rect(w, h), "BACK", false, true) { + self.stage = CharacterStage::CreateProfile; + } + None + } +} +// ── Loading screen ────────────────────────────────────────────────────────── + +/// Replayable loading presentation — bridges roster/creation selection into the +/// connected world. +pub struct LoadingScreen { + pub zone_name: String, + pub zone_sub: String, + pub tip: String, + pub progress: f32, + pub indeterminate: bool, + pub status_text: String, + phase: f32, +} + +impl Default for LoadingScreen { + fn default() -> Self { + Self::new("OPEN DESERT", "PLANETFALL // OPEN-DESERT-OVERWORLD") + } +} + +impl LoadingScreen { + pub fn new(zone_name: impl Into, zone_sub: impl Into) -> Self { + Self { + zone_name: zone_name.into(), + zone_sub: zone_sub.into(), + tip: "Planetary waypoints can be created from your datapad or shared with group members via chat.".to_string(), + progress: 0.68, + indeterminate: false, + status_text: "INITIALIZING ENVIRONMENT".to_string(), + phase: 0.0, + } + } + + pub fn tick(&mut self, dt: f32) { + self.phase = (self.phase + dt) % 3600.0; + } + + pub fn set_progress(&mut self, progress: f32) { + self.progress = progress.clamp(0.0, 1.0); + } + + pub fn set_indeterminate(&mut self, indeterminate: bool) { + self.indeterminate = indeterminate; + if indeterminate && self.status_text == "INITIALIZING ENVIRONMENT" { + self.status_text = "AWAITING WORLD SNAPSHOT".to_string(); + } + } + pub fn draw(&mut self, ui: &mut UiBuilder, w: f32, h: f32) -> Option { + let s = scale(h); + draw_backdrop(ui, w, h, self.phase); + + // Minimal, unboxed spatial presentation: open typography plus one + // authored destination image, without nested outlines or panel stacks. + let margin = (32.0 * s).max(16.0); + let top_y = 28.0 * s; + + ui.text( + "SUCCESSOR / TRANSFER CONTROL", + margin, + top_y, + T_LABEL * s, + INK, + ); + text_right(ui, "WORLD LINK 01", w - margin, top_y, T_LABEL * s, INK_DIM); + hairline( + ui, + margin, + top_y + 18.0 * s, + w - margin * 2.0, + s, + HAIRLINE_LIT, + ); + + let content_y = top_y + 36.0 * s; + let content_h = h - content_y - 84.0 * s; + let aperture_x = margin; + let aperture_w = (w - margin * 2.0) * 0.46; + + let art_size = aperture_w.min(content_h - 32.0 * s).max(1.0); + let art_x = aperture_x + (aperture_w - art_size) * 0.5; + let art_y = content_y + 10.0 * s; + ui.image_uv( + art_x, + art_y, + art_size, + art_size, + crate::hud::LOADING_DESTINATION_UV, + [255, 255, 255, 250], + ); + ui.text( + "DESTINATION VISUAL", + aperture_x, + content_y, + T_LABEL * s, + ACCENT, + ); + ui.text( + &self.zone_sub, + aperture_x, + content_y + content_h - 16.0 * s, + T_LABEL * s, + INK_SOFT, + ); + + let info_x = aperture_x + aperture_w + 36.0 * s; + let info_y = content_y; + let info_w = w - margin - info_x; + + ui.text("TRANSFER DOSSIER", info_x, info_y, T_LABEL * s, ACCENT); + ui.text( + &self.zone_name, + info_x, + info_y + 24.0 * s, + T_TITLE * 1.3 * s, + INK, + ); + ui.text( + &self.zone_sub, + info_x, + info_y + 56.0 * s, + T_BODY * s, + INK_SOFT, + ); + hairline(ui, info_x, info_y + 80.0 * s, info_w, s, HAIRLINE_LIT); + + let field_y = info_y + 96.0 * s; + ui.text("TRANSFER", info_x, field_y, T_LABEL * s, INK_DIM); + text_right( + ui, + if self.indeterminate { + "STREAMING" + } else { + "STAGED" + }, + info_x + info_w, + field_y, + T_BODY * s, + INK, + ); + ui.text( + "AUTHORITY", + info_x, + field_y + 24.0 * s, + T_LABEL * s, + INK_DIM, + ); + text_right( + ui, + "WORLD SNAPSHOT", + info_x + info_w, + field_y + 24.0 * s, + T_BODY * s, + INK, + ); + ui.text( + "FIELD BRIEFING", + info_x, + field_y + 58.0 * s, + T_LABEL * s, + ACCENT, + ); + draw_wrapped( + ui, + &self.tip, + info_x, + field_y + 80.0 * s, + info_w, + T_BODY * s, + 18.0 * s, + 6, + INK_SOFT, + ); + + // Segmented radial progress instrument at bottom right. + let progress_cx = info_x + info_w * 0.5; + let progress_cy = h - 68.0 * s; + let radius = 30.0 * s; + let segments = 18u32; + let filled = ((self.progress * segments as f32).round() as u32).min(segments); + let head = ((self.phase * 12.0) as u32) % segments; + ui.mask_uv( + progress_cx - radius, + progress_cy - radius, + radius * 2.0, + radius * 2.0, + crate::hud::RADIAL_TICK_CROWN_UV, + [8, 70, 82, 220], + ); + let arc_radius = radius - 10.0 * s; + ui.mask_uv( + progress_cx - arc_radius, + progress_cy - arc_radius, + arc_radius * 2.0, + arc_radius * 2.0, + crate::hud::PROGRESS_ARC_UV, + [80, 224, 244, 160], + ); + for i in 0..segments { + let a = + -std::f32::consts::FRAC_PI_2 + i as f32 / segments as f32 * std::f32::consts::TAU; + let (c, sn) = (a.cos(), a.sin()); + let active = if self.indeterminate { + (head + segments - i) % segments < 7 + } else { + i < filled + }; + let marker = !self.indeterminate && i == filled && self.progress < 1.0; + if !active && !marker { + continue; + } + let color = if active { [98, 255, 21, 255] } else { ACCENT }; + ui.line( + progress_cx + c * (radius - 8.0 * s), + progress_cy + sn * (radius - 8.0 * s), + progress_cx + c * radius, + progress_cy + sn * radius, + (2.0 * s).max(1.0), + color, + ); + } + if self.indeterminate { + text_center( + ui, + "SYNC", + progress_cx, + progress_cy - 5.0 * s, + T_LABEL * s, + INK, + ); + } else { + let percent = format!("{}%", (self.progress * 100.0) as u32); + text_center( + ui, + &percent, + progress_cx, + progress_cy - 5.0 * s, + T_LABEL * s, + INK, + ); + } + text_center( + ui, + &self.status_text, + progress_cx, + progress_cy + radius + 8.0 * s, + T_LABEL * s, + INK_SOFT, + ); + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use successor_engine_render::ui::AtlasMeta; + + const ATLAS: AtlasMeta = AtlasMeta { + cell: 32, + cols: 8, + width: 256, + height: 160, + }; + const W: f32 = 1280.0; + const H: f32 = 720.0; + + fn builder() -> UiBuilder { + UiBuilder::new(ATLAS) + } + + /// A click needs a press frame and a release frame; the action is whatever + /// the release frame emits. Asserts the press frame stayed silent. + fn tap(ui: &mut UiBuilder, rect: [f32; 4], mut draw: F) -> Option + where + F: FnMut(&mut UiBuilder) -> Option, + { + let cx = rect[0] + rect[2] * 0.5; + let cy = rect[1] + rect[3] * 0.5; + ui.set_input(cx, cy, true); + ui.begin(W as u32, H as u32); + assert!(draw(ui).is_none(), "press frame must not emit an action"); + ui.set_input(cx, cy, false); + ui.begin(W as u32, H as u32); + draw(ui) + } + + /// One frame with the pointer parked off every control. + fn idle(ui: &mut UiBuilder, mut draw: F) -> Option + where + F: FnMut(&mut UiBuilder) -> Option, + { + ui.set_input(-100.0, -100.0, false); + ui.begin(W as u32, H as u32); + draw(ui) + } + + fn sample_roster(n: usize) -> Vec { + (0..n) + .map(|i| RosterEntry { + id: format!("demo-{i}"), + female: false, + name: format!("RECRUIT {i}"), + lineage: "TERRAN".into(), + vocation: "SCOUT".into(), + location: "TIDEWORKS".into(), + played: "12H".into(), + }) + .collect() + } + + // ── Entry ────────────────────────────────────────────────────────────── + + #[test] + fn entry_defaults_are_development_values() { + let screen = EntryScreen::new(); + assert_eq!(screen.endpoint.text, "ws://127.0.0.1:28093/"); + assert_eq!(screen.player.text, "dev-1"); + assert_eq!(screen.stage(), EntryStage::Login); + } + + #[test] + fn entry_connect_emits_options_and_enters_connecting_stage() { + let mut screen = EntryScreen::new(); + let mut ui = builder(); + let action = tap(&mut ui, EntryLayout::connect_rect(W, H), |ui| { + screen.draw(ui, W, H) + }); + match action { + Some(ScreenAction::Connect(opts)) => { + assert_eq!(opts.endpoint, "ws://127.0.0.1:28093/"); + assert_eq!(opts.player_id, "dev-1"); + assert_eq!(opts.actor_id, "dev-1"); + assert!(opts.ticket.is_none()); + assert!(opts.release.is_none()); + } + other => panic!("expected Connect, got {other:?}"), + } + assert_eq!(screen.stage(), EntryStage::Connecting); + } + + #[test] + fn entry_refuses_connect_without_identity() { + let mut screen = EntryScreen::new(); + screen.player.text.clear(); + let mut ui = builder(); + let rect = EntryLayout::connect_rect(W, H); + let cx = rect[0] + rect[2] * 0.5; + let cy = rect[1] + rect[3] * 0.5; + ui.set_input(cx, cy, true); + ui.begin(W as u32, H as u32); + assert!(screen.draw(&mut ui, W, H).is_none()); + ui.set_input(cx, cy, false); + ui.begin(W as u32, H as u32); + assert!(screen.draw(&mut ui, W, H).is_none()); + assert_eq!(screen.stage(), EntryStage::Login); + } + + #[test] + fn entry_cancel_returns_to_login_and_reports() { + let mut screen = EntryScreen::new(); + screen.begin_connecting(); + let mut ui = builder(); + let action = tap(&mut ui, EntryLayout::cancel_rect(W, H), |ui| { + screen.draw(ui, W, H) + }); + assert_eq!(action, Some(ScreenAction::CancelConnect)); + assert_eq!(screen.stage(), EntryStage::Login); + assert_eq!(screen.status(), Some("LINK ATTEMPT CANCELLED")); + } + + #[test] + fn connecting_dialog_owns_input() { + // The login controls behind the modal must be inert while it is up. + let mut screen = EntryScreen::new(); + screen.begin_connecting(); + let mut ui = builder(); + let action = tap(&mut ui, EntryLayout::connect_rect(W, H), |ui| { + screen.draw(ui, W, H) + }); + assert_eq!(action, None); + assert_eq!(screen.stage(), EntryStage::Connecting); + } + + #[test] + fn entry_back_and_quit_outputs() { + let mut screen = EntryScreen::new(); + let mut ui = builder(); + let back = tap(&mut ui, EntryLayout::back_rect(W, H), |ui| { + screen.draw(ui, W, H) + }); + assert_eq!(back, Some(ScreenAction::Back)); + assert_eq!(screen.stage(), EntryStage::Login); + + let mut screen = EntryScreen::new(); + let mut ui = builder(); + let quit = tap(&mut ui, EntryLayout::quit_rect(W, H), |ui| { + screen.draw(ui, W, H) + }); + assert_eq!(quit, Some(ScreenAction::Quit)); + } + + #[test] + fn entry_field_click_focuses_and_typing_routes() { + let mut screen = EntryScreen::new(); + let mut ui = builder(); + let action = tap(&mut ui, EntryLayout::player_rect(W, H), |ui| { + screen.draw(ui, W, H) + }); + assert_eq!(action, None); + assert!(screen.player.focused); + assert!(!screen.endpoint.focused); + + screen.player.clear(); + assert!(screen.input_char('x')); + assert_eq!(screen.player.text, "x"); + assert!(screen.backspace()); + assert!(screen.player.text.is_empty()); + } + + #[test] + fn entry_tab_traversal_cycles_both_directions() { + let mut screen = EntryScreen::new(); + // From nothing focused, Tab lands on the first stop, Shift+Tab the last. + screen.focus_next(); + assert!(screen.endpoint.focused && !screen.player.focused); + screen.focus_next(); + assert!(screen.player.focused && !screen.endpoint.focused); + screen.focus_prev(); + assert!(screen.endpoint.focused && !screen.player.focused); + + let mut screen = EntryScreen::new(); + screen.focus_prev(); + assert!(screen.player.focused && !screen.endpoint.focused); + } + + #[test] + fn connecting_stage_swallows_text_input() { + let mut screen = EntryScreen::new(); + screen.endpoint.focused = true; + screen.begin_connecting(); + assert!(!screen.input_char('z')); + assert!(!screen.backspace()); + assert_eq!(screen.endpoint.text, "ws://127.0.0.1:28093/"); + } + + #[test] + fn entry_failure_surfaces_and_resets_stage() { + let mut screen = EntryScreen::new(); + screen.begin_connecting(); + screen.fail("LINK REFUSED"); + assert_eq!(screen.stage(), EntryStage::Login); + assert_eq!(screen.status(), Some("LINK REFUSED")); + let mut ui = builder(); + assert!(idle(&mut ui, |ui| screen.draw(ui, W, H)).is_none()); + } + + // ── Roster ───────────────────────────────────────────────────────────── + + #[test] + fn roster_click_highlights_then_confirms() { + let mut screen = CharacterScreen::with_entries(sample_roster(3)); + let mut ui = builder(); + let row = CharacterLayout::roster_row_rect(W, H, 1); + + let first = tap(&mut ui, row, |ui| screen.draw(ui, W, H)); + assert_eq!(first, None, "first click only moves the highlight"); + assert_eq!(screen.selected(), 1); + + let second = tap(&mut ui, row, |ui| screen.draw(ui, W, H)); + assert_eq!(second, Some(ScreenAction::SelectCharacter(1))); + } + + #[test] + fn roster_enter_plate_confirms_the_highlight() { + let mut screen = CharacterScreen::with_entries(sample_roster(3)); + let mut ui = builder(); + tap(&mut ui, CharacterLayout::roster_row_rect(W, H, 2), |ui| { + screen.draw(ui, W, H) + }); + assert_eq!(screen.selected(), 2); + + let action = tap(&mut ui, CharacterLayout::next_rect(W, H), |ui| { + screen.draw(ui, W, H) + }); + assert_eq!(action, Some(ScreenAction::SelectCharacter(2))); + } + + #[test] + fn empty_roster_cannot_be_confirmed() { + let mut screen = CharacterScreen::new(vec![]); + let mut ui = builder(); + let rect = CharacterLayout::next_rect(W, H); + let cx = rect[0] + rect[2] * 0.5; + let cy = rect[1] + rect[3] * 0.5; + ui.set_input(cx, cy, true); + ui.begin(W as u32, H as u32); + assert!(screen.draw(&mut ui, W, H).is_none()); + ui.set_input(cx, cy, false); + ui.begin(W as u32, H as u32); + assert!(screen.draw(&mut ui, W, H).is_none()); + } + + #[test] + fn roster_back_leaves_the_flow() { + let mut screen = CharacterScreen::with_entries(sample_roster(2)); + let mut ui = builder(); + let action = tap(&mut ui, CharacterLayout::back_rect(W, H), |ui| { + screen.draw(ui, W, H) + }); + assert_eq!(action, Some(ScreenAction::Back)); + assert_eq!(screen.stage(), CharacterStage::Roster); + } + + #[test] + fn roster_pages_long_lists_inside_the_panel() { + let visible = CharacterLayout::roster_rows_visible(W, H); + let total = visible + 5; + let mut screen = CharacterScreen::with_entries(sample_roster(total)); + let mut ui = builder(); + + let action = tap(&mut ui, CharacterLayout::page_down_rect(W, H), |ui| { + screen.draw(ui, W, H) + }); + assert_eq!(action, None); + assert_eq!(screen.scroll(), total - visible); + + let up = tap(&mut ui, CharacterLayout::page_up_rect(W, H), |ui| { + screen.draw(ui, W, H) + }); + assert_eq!(up, None); + assert_eq!(screen.scroll(), 0); + } + + #[test] + fn keyboard_selection_follows_the_scrolled_viewport() { + let visible = CharacterLayout::roster_rows_visible(W, H); + let total = visible + 3; + let mut screen = CharacterScreen::with_entries(sample_roster(total)); + for _ in 0..total + 4 { + screen.move_selection(1, W, H); + } + assert_eq!(screen.selected(), total - 1, "selection clamps at the end"); + assert_eq!(screen.scroll(), total - visible); + assert!(screen.selected() >= screen.scroll()); + assert!(screen.selected() < screen.scroll() + visible); + + for _ in 0..total + 4 { + screen.move_selection(-1, W, H); + } + assert_eq!(screen.selected(), 0); + assert_eq!(screen.scroll(), 0); + } + + // ── Creation ─────────────────────────────────────────────────────────── + + #[test] + fn new_character_opens_the_profile_stage() { + let mut screen = CharacterScreen::with_entries(sample_roster(1)); + let mut ui = builder(); + let action = tap(&mut ui, CharacterLayout::new_character_rect(W, H), |ui| { + screen.draw(ui, W, H) + }); + assert_eq!(action, None); + assert_eq!(screen.stage(), CharacterStage::CreateProfile); + } + + #[test] + fn full_roster_cannot_start_another_character() { + let mut screen = CharacterScreen::with_entries(sample_roster(ROSTER_CAP)); + let mut ui = builder(); + let action = tap(&mut ui, CharacterLayout::new_character_rect(W, H), |ui| { + screen.draw(ui, W, H) + }); + assert_eq!(action, None); + assert_eq!(screen.stage(), CharacterStage::Roster); + } + + #[test] + fn creation_stages_advance_and_unwind() { + let mut screen = CharacterScreen::new(vec![]); + screen.set_stage(CharacterStage::CreateProfile); + let mut ui = builder(); + + tap(&mut ui, CharacterLayout::next_rect(W, H), |ui| { + screen.draw(ui, W, H) + }); + assert_eq!(screen.stage(), CharacterStage::CreateIdentity); + + tap(&mut ui, CharacterLayout::back_rect(W, H), |ui| { + screen.draw(ui, W, H) + }); + assert_eq!(screen.stage(), CharacterStage::CreateProfile); + + // Backing out of the first creation step returns to the roster and does + // NOT leak a `Back` action to the host. + let action = tap(&mut ui, CharacterLayout::back_rect(W, H), |ui| { + screen.draw(ui, W, H) + }); + assert_eq!(action, None); + assert_eq!(screen.stage(), CharacterStage::Roster); + } + + #[test] + fn profile_choices_bind_to_the_draft() { + let mut screen = CharacterScreen::new(vec![]); + screen.set_stage(CharacterStage::CreateProfile); + let mut ui = builder(); + + tap(&mut ui, CharacterLayout::lineage_row_rect(W, H, 3), |ui| { + screen.draw(ui, W, H) + }); + assert_eq!(screen.draft.lineage, 3); + assert_eq!(screen.draft.lineage().name, LINEAGES[3].name); + + tap(&mut ui, CharacterLayout::gender_rect(W, H, true), |ui| { + screen.draw(ui, W, H) + }); + assert!(screen.draft.female); + + tap(&mut ui, CharacterLayout::vocation_rect(W, H, 2), |ui| { + screen.draw(ui, W, H) + }); + assert_eq!(screen.draft.vocation(), VOCATIONS[2]); + + // The build track is a drag control: pressing at 20% sets 20%. + let bar = CharacterLayout::build_rect(W, H); + ui.set_input(bar[0] + bar[2] * 0.3, bar[1] + bar[3] * 0.5, true); + ui.begin(W as u32, H as u32); + screen.draw(&mut ui, W, H); + assert!((screen.draft.build - 0.3).abs() < 0.02); + assert_eq!(screen.draft.build_label(), "LEAN"); + } + + #[test] + fn create_requires_a_name_then_emits_it() { + let mut screen = CharacterScreen::new(vec![]); + screen.set_stage(CharacterStage::CreateIdentity); + let mut ui = builder(); + + let rect = CharacterLayout::next_rect(W, H); + let cx = rect[0] + rect[2] * 0.5; + let cy = rect[1] + rect[3] * 0.5; + ui.set_input(cx, cy, true); + ui.begin(W as u32, H as u32); + assert!(screen.draw(&mut ui, W, H).is_none()); + ui.set_input(cx, cy, false); + ui.begin(W as u32, H as u32); + assert!( + screen.draw(&mut ui, W, H).is_none(), + "CREATE is inert without a given name" + ); + + screen.draft.name.text = "CHARLIE".to_string(); + screen.draft.name.caret = screen.draft.name.text.chars().count(); + let action = tap(&mut ui, rect, |ui| screen.draw(ui, W, H)); + assert_eq!( + action, + Some(ScreenAction::CreateCharacter("CHARLIE".to_string())) + ); + + screen.draft.surname.text = "MOSS".to_string(); + let action = tap(&mut ui, rect, |ui| screen.draw(ui, W, H)); + assert_eq!( + action, + Some(ScreenAction::CreateCharacter("CHARLIE MOSS".to_string())) + ); + } + + #[test] + fn generate_fills_both_name_fields_deterministically() { + let mut screen = CharacterScreen::new(vec![]); + screen.set_stage(CharacterStage::CreateIdentity); + let mut ui = builder(); + tap(&mut ui, CharacterLayout::generate_rect(W, H), |ui| { + screen.draw(ui, W, H) + }); + let first = screen.draft.full_name(); + assert!(!first.is_empty()); + assert!(first.contains(' '), "generated names include a surname"); + + // Same seed ⇒ same name; the generator is not a nondeterminism source. + assert_eq!(generated_name(7), generated_name(7)); + assert_ne!(generated_name(7), generated_name(8)); + } + + #[test] + fn summary_tab_traversal_and_typing() { + let mut screen = CharacterScreen::new(vec![]); + screen.set_stage(CharacterStage::CreateIdentity); + screen.focus_next(); + assert!(screen.draft.name.focused); + assert!(screen.input_char('A')); + screen.focus_next(); + assert!(screen.draft.surname.focused); + assert!(screen.input_char('B')); + screen.focus_prev(); + assert!(screen.draft.name.focused); + assert_eq!(screen.draft.full_name(), "A B"); + } + + #[test] + fn roster_stage_ignores_naming_input() { + let mut screen = CharacterScreen::with_entries(sample_roster(1)); + screen.draft.name.focused = true; + assert!(!screen.input_char('q')); + assert!(!screen.backspace()); + screen.focus_next(); + assert!(screen.draft.name.focused, "traversal is a no-op off-stage"); + } + + // ── Layout invariants ────────────────────────────────────────────────── + + #[test] + fn navigation_keeps_the_original_far_edge_anchor() { + // The original client pins NEXT to (W-109, H-38) at every size. + for (w, h) in [(1280.0f32, 720.0f32), (1024.0, 768.0), (1920.0, 1080.0)] { + let next = nav_next_rect(w, h); + let back = nav_back_rect(h); + let cx = next[0] + next[2] * 0.5; + let cy = next[1] + next[3] * 0.5; + assert!((cx - (w - NAV_INSET_X)).abs() < 0.01, "{w}x{h} next x"); + assert!((cy - (h - NAV_INSET_Y)).abs() < 0.01, "{w}x{h} next y"); + assert!( + (back[0] + back[2] * 0.5 - NAV_INSET_X).abs() < 0.01, + "{w}x{h} back x" + ); + assert!( + next[0] > w * 0.5 && back[0] < w * 0.5, + "plates sit in corners" + ); + } + } + + #[test] + fn layout_is_viewport_relative_and_stays_on_screen() { + for (w, h) in [(1280.0f32, 720.0f32), (1024.0, 768.0), (1600.0, 900.0)] { + let panel = CharacterLayout::panel_rect(w, h); + let doll = CharacterLayout::paperdoll_rect(w, h); + let nav = nav_next_rect(w, h); + + assert!(panel[0] > 0.0 && panel[0] + panel[2] < w, "{w}x{h} panel x"); + assert!(panel[1] > 0.0 && panel[1] + panel[3] < h, "{w}x{h} panel y"); + // The panel owns the right rail; the viewer keeps the left. + assert!( + panel[0] > w * 0.5 && panel[0] + panel[2] > w * 0.9, + "{w}x{h} panel is a flush-right rail" + ); + // A quarter of a 16:9 viewport, naturally a little more at 4:3 — + // but always a minority rail, never a half-and-half split. + assert!(panel[2] < w * 0.40, "{w}x{h} rail stays a minority"); + assert!( + doll[2] > panel[2], + "{w}x{h} the viewer, not the rail, owns the composition" + ); + assert!(doll[0] + doll[2] < panel[0], "{w}x{h} viewer clears panel"); + assert!(doll[0] > 0.0 && doll[1] > 0.0); + assert!(panel[1] + panel[3] < nav[1], "{w}x{h} panel clears nav"); + + let r_panel = CharacterLayout::roster_panel_rect(w, h); + let last = CharacterLayout::roster_rows_visible(w, h) - 1; + for r in [ + CharacterLayout::roster_row_rect(w, h, last), + CharacterLayout::new_character_rect(w, h), + CharacterLayout::delete_character_rect(w, h), + ] { + assert!(r[0] >= r_panel[0], "{w}x{h} control left edge"); + assert!( + r[0] + r[2] <= r_panel[0] + r_panel[2] + 0.01, + "{w}x{h} right" + ); + assert!( + r[1] + r[3] <= r_panel[1] + r_panel[3] + 0.01, + "{w}x{h} bottom" + ); + } + // Creation controls stay inside too. + for i in 0..LINEAGES.len() { + let r = CharacterLayout::lineage_row_rect(w, h, i); + assert!(r[1] + r[3] <= panel[1] + panel[3] + 0.01, "{w}x{h} lineage"); + } + for i in 0..VOCATIONS.len() { + let r = CharacterLayout::vocation_rect(w, h, i); + assert!( + r[1] + r[3] <= panel[1] + panel[3] + 0.01, + "{w}x{h} vocation" + ); + } + + // Entry panel is centred and clear of the navigation row. + let entry = EntryLayout::panel_rect(w, h); + assert!((entry[0] + entry[2] * 0.5 - w * 0.5).abs() < 0.01); + assert!(entry[1] + entry[3] < nav[1], "{w}x{h} login clears nav"); + for i in 0..2 { + let f = EntryLayout::field_rect(w, h, i); + assert!(f[0] > entry[0] && f[0] + f[2] < entry[0] + entry[2]); + assert!(f[1] > entry[1] && f[1] + f[3] < entry[1] + entry[3]); + } + } + } + + #[test] + fn hit_targets_track_the_viewport() { + // Same logical control, two framebuffer sizes ⇒ different pixel rects. + let a = CharacterLayout::roster_row_rect(1280.0, 720.0, 0); + let b = CharacterLayout::roster_row_rect(1600.0, 900.0, 0); + assert!(b[0] > a[0] && b[3] > a[3]); + assert!( + !UiBuilder::hit(b[0], b[1], b[2], b[3], a[0] + 4.0, a[1] + 4.0), + "a 720p hit point must not land on the 900p rect" + ); + } + + #[test] + fn hosted_paperdoll_suppresses_the_silhouette_not_the_frame() { + let mut screen = CharacterScreen::with_entries(sample_roster(1)); + let mut ui = builder(); + idle(&mut ui, |ui| screen.draw(ui, W, H)); + let with_figure = ui.quads; + + screen.set_paperdoll_hosted(true); + assert!(screen.paperdoll_hosted()); + idle(&mut ui, |ui| screen.draw(ui, W, H)); + let hosted = ui.quads; + + assert!(hosted < with_figure, "figure geometry is dropped"); + assert!(hosted > 0, "pad, frame and caption still draw"); + } + + #[test] + fn loading_screen_renders_progress() { + let mut screen = LoadingScreen::default(); + screen.set_progress(0.75); + assert_eq!(screen.progress, 0.75); + screen.tick(0.016); + let mut ui = builder(); + ui.begin(W as u32, H as u32); + assert!(screen.draw(&mut ui, W, H).is_none()); + assert!(ui.quads > 0); + } + + #[test] + fn host_status_is_visible_on_every_character_stage() { + // A hosted create that is waiting or rejected must surface on the stage + // that produced it, not only on the roster. + for stage in [ + CharacterStage::Roster, + CharacterStage::CreateProfile, + CharacterStage::CreateIdentity, + ] { + let mut screen = CharacterScreen::with_entries(sample_roster(1)); + screen.set_stage(stage); + let mut ui = builder(); + idle(&mut ui, |ui| screen.draw(ui, W, H)); + // Quad count is not a valid proxy here: on the roster the status + // takes the place of a longer static line, so the frame can shrink. + // Compare the emitted geometry instead. + let quiet: Vec = ui.buf.clone(); + + screen.set_status_error("NAME ALREADY TAKEN"); + assert_eq!(screen.status(), Some("NAME ALREADY TAKEN")); + assert!(screen.status_is_error()); + idle(&mut ui, |ui| screen.draw(ui, W, H)); + assert!(ui.buf != quiet, "{stage:?} must draw the host status line"); + assert!(ui.quads > 0); + + screen.clear_status(); + assert!(screen.status().is_none()); + assert!(!screen.status_is_error()); + } + } + + #[test] + fn status_tone_distinguishes_pending_from_rejection() { + let mut screen = CharacterScreen::new(vec![]); + screen.set_status("AWAITING AUTHORITY"); + assert_eq!(screen.status(), Some("AWAITING AUTHORITY")); + assert!(!screen.status_is_error(), "a pending note is not an error"); + + screen.set_status_error("CREATE REJECTED"); + assert!(screen.status_is_error()); + + // A later neutral status clears the danger tone. + screen.set_status("AWAITING AUTHORITY"); + assert!(!screen.status_is_error()); + } + + #[test] + fn every_stage_renders_without_panicking_at_odd_sizes() { + for (w, h) in [(1280.0f32, 720.0f32), (800.0, 600.0), (1920.0, 1080.0)] { + let mut entry = EntryScreen::new(); + let mut ui = builder(); + ui.set_input(-1.0, -1.0, false); + ui.begin(w as u32, h as u32); + entry.draw(&mut ui, w, h); + entry.begin_connecting(); + ui.begin(w as u32, h as u32); + entry.draw(&mut ui, w, h); + assert!(ui.quads > 0); + + for stage in [ + CharacterStage::Roster, + CharacterStage::CreateProfile, + CharacterStage::CreateIdentity, + ] { + // Empty roster is the hardest case: no subject, no detail. + let mut screen = CharacterScreen::new(vec![]); + screen.set_stage(stage); + screen.tick(0.016); + ui.begin(w as u32, h as u32); + screen.draw(&mut ui, w, h); + assert!(ui.quads > 0, "{stage:?} at {w}x{h} drew nothing"); + } + } + } +} diff --git a/client-rust/source/app/src/windows/CONTRACT.md b/client-rust/source/app/src/windows/CONTRACT.md new file mode 100644 index 00000000..15dbb675 --- /dev/null +++ b/client-rust/source/app/src/windows/CONTRACT.md @@ -0,0 +1,76 @@ +# Window content contract (Wave 6) + +Each game window is a **self-contained content module** at +`client-rust/source/app/src/windows/.rs`. It draws INSIDE a +`WindowManager` content rect using the engine immediate-mode UI. Live authority +binding is Wave 11 — for now each module defines its own typed view struct + a +`sample()` builder used for demo/screenshot verification. + +## Module shape (MANDATORY) + +```rust +//! — one-line port note. +use super::{WindowAction, TEXT, DIM, ACCENT, SLOT, SLOT_EDGE}; +use crate::hud::Icons; +use successor_engine_render::ui::{UiBuilder, ButtonStyle}; + +/// View model this window reads (add fields you need; keep it plain data). +#[derive(Clone, Debug, Default)] +pub struct FooModel { /* ... */ } + +impl FooModel { + pub fn sample() -> Self { /* representative demo state */ } +} + +/// Draw content into `rect = [x, y, w, h]` (px, top-left origin). Push emitted +/// intents into `out`. MUST NOT panic on empty/default model. +pub fn draw(ui: &mut UiBuilder, rect: [f32; 4], model: &FooModel, icons: &Icons, out: &mut Vec<WindowAction>) { /* ... */ } + +#[cfg(test)] +mod tests { /* at least one interaction test using set_input/begin twice */ } +``` + +## Rules +- DO create only your `windows/<name>.rs` files. DO NOT edit `mod.rs`, + `model.rs`, `lib.rs`, or `main.rs` — the parent wires dispatch + demo after you + land (prevents shared-file conflicts). +- Reuse shared color consts from `super::` (`TEXT`, `DIM`, `ACCENT`, `SLOT`, + `SLOT_EDGE`) — do not invent a second palette. +- Add any new `WindowAction` variants you need by messaging the parent (do NOT + edit the enum yourself); prefer the existing generic `WindowAction::Button(String)` + / `Toggle(String)` for one-off intents to avoid enum edits. +- Icons: `icons.cell("<icon-id>")` → `Option<(u32,u32)>`; draw with + `ui.icon(col, row, x, y, w, h, tint)`. Icon ids are the `client-3d/src/ui/icons.ts` + keys (e.g. `loot`, `bank`, `trade`, `craft`, `survey`, `converse`, `travel`, + `datapad`, `clone`, `splice`, `macro`, `actions`, `bug-report`, `item-*`). +- Keep it deterministic + no per-frame heap beyond the UI buffer (fine to + allocate Strings for labels via `format!`). +- Run ONLY `cargo test -q -p successor-client <yourfilter>` — do NOT run the full + gate suite, formatters, or bench. + +## UiBuilder API you will use +- `ui.rect(x,y,w,h, [u8;4])`, `ui.border(x,y,w,h,thick, rgba)`, + `ui.panel(x,y,w,h, fill, edge)` +- `ui.text(&str, x, y, px_size, rgba) -> f32` (returns end x); measure with + `ui.measure_text(s, px)` — it uses the live raster font, so it is the only + correct width for centring, right-aligning, or clipping. `UiBuilder::text_width` + is the fixed 5x7 fallback estimate and is wrong under the shipped font. +- `ui.icon(col,row, x,y,w,h, rgba)` +- `ui.button(x,y,w,h, label, ButtonStyle) -> bool` (clicked this frame) +- `ui.icon_button(col,row, x,y,size, ButtonStyle) -> bool` +- `ui.interact(x,y,w,h) -> Response { hovered, pressed, released, clicked, held }` +- `ui.mouse() -> (f32,f32)` +- `TextField` (line editor): `ui.text_field(&mut TextField, x,y,w,h, px, show_caret) -> Response` +- `ButtonStyle::default()`; fields `fill/hover/active/edge/text: [u8;4]`. +- Font is the shipped proportional raster face with a 5x7 uppercase fallback; + keep labels short. + +## Interaction-test pattern (click needs a press frame then a release frame) +```rust +ui.set_input(bx, by, true); ui.begin(1280, 720); +let mut out = Vec::new(); draw(&mut ui, rect, &model, &icons, &mut out); // press +ui.set_input(bx, by, false); ui.begin(1280, 720); +out.clear(); draw(&mut ui, rect, &model, &icons, &mut out); // release => clicked +assert!(out.contains(&WindowAction::...)); +``` +`Icons::load()` works in tests (atlas is embedded). diff --git a/client-rust/source/app/src/windows/bugreport.rs b/client-rust/source/app/src/windows/bugreport.rs new file mode 100644 index 00000000..700bc496 --- /dev/null +++ b/client-rust/source/app/src/windows/bugreport.rs @@ -0,0 +1,531 @@ +//! BUG REPORT — categories, bounded player text, redacted diagnostics, and +//! request-correlated results (ports of `ui/windows/defs/bugReportWindow.ts`, +//! `slice-core/bugReportSystem.ts` and `support/bugReportDiagnostics.ts`). +//! +//! Redaction contract: the diagnostics payload is built ONLY from the typed +//! [`DiagnosticsInput`] — free-text fields pass through [`redact_text`], and +//! game/chat tickets, bearer values and filesystem secrets have no field to +//! ride in. Reports correlate through `requestId`; only a matching +//! `successor.bug-report-result.v1` settles the pending state. + +use super::{accent, dim, slot, slot_edge, text, WindowAction}; +use crate::hud::Icons; +use serde_json::{json, Value}; +use successor_engine_render::ui::{TextField, UiBuilder}; + +pub const BODY_MIN_CHARS: usize = 20; +pub const BODY_MAX_CHARS: usize = 4_000; + +pub const CATEGORIES: [(&str, &str); 5] = [ + ("gameplay", "GAMEPLAY"), + ("interface", "INTERFACE"), + ("connection", "CONNECTION"), + ("graphics_audio", "GRAPHICS/AUDIO"), + ("other", "OTHER"), +]; + +#[derive(Clone, Debug, Default, PartialEq)] +pub enum BugStatus { + #[default] + Idle, + /// Submitted; waiting for the correlated `bugReportResult`. + Pending { + request_id: String, + }, + Accepted { + report_id: String, + }, + Denied { + copy: String, + }, +} + +/// Window state (owned by the host's window ui-state; mutated by draw). +#[derive(Default)] +pub struct BugReportModel { + pub category: usize, + pub body: TextField, + pub status: BugStatus, +} + +impl BugReportModel { + pub fn new() -> Self { + Self { + category: 0, + body: TextField::new(BODY_MAX_CHARS), + status: BugStatus::Idle, + } + } + pub fn sample() -> Self { + Self::new() + } +} + +/// Player-facing denial copy (reference `reportErrorCopy`). +pub fn report_error_copy(reason_code: &str) -> &'static str { + match reason_code { + "rate_limited" => "QUEUE BUSY / TRY AGAIN IN A MINUTE", + "invalid_report" => "REPORT NEEDS MORE DETAIL", + _ => "NO LINK / YOUR REPORT IS KEPT, TRY AGAIN", + } +} + +/// Correlated result decode (port of `bugReportResultForRequest`): `None` +/// when the payload is not this request's result. +pub fn result_for_request(payload: &Value, expected_request_id: &str) -> Option<BugStatus> { + if payload.get("schema").and_then(|v| v.as_str()) != Some("successor.bug-report-result.v1") { + return None; + } + if payload.get("requestId").and_then(|v| v.as_str()) != Some(expected_request_id) { + return None; + } + match payload.get("status").and_then(|v| v.as_str()) { + Some("accepted") => { + let report_id = payload.get("reportId").and_then(|v| v.as_str())?; + let received_at = payload.get("receivedAt").and_then(|v| v.as_f64())?; + if report_id.len() < 8 || !received_at.is_finite() { + return None; + } + Some(BugStatus::Accepted { + report_id: report_id.chars().take(128).collect(), + }) + } + Some("rejected") => { + let reason = payload.get("reasonCode").and_then(|v| v.as_str())?; + if !["invalid_report", "rate_limited", "unavailable"].contains(&reason) { + return None; + } + Some(BugStatus::Denied { + copy: report_error_copy(reason).to_string(), + }) + } + _ => None, + } +} + +// ── Redaction ─────────────────────────────────────────────────────────────── + +fn is_token_char(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '~' | '-') +} + +/// Redact bearer values and ticket/token query parameters from free text and +/// cap its length (port of the reference `safeText` patterns). +pub fn redact_text(value: &str, max: usize) -> String { + let bytes: Vec<char> = value.chars().take(max).collect(); + let lower_chars: Vec<char> = bytes.iter().map(|c| c.to_ascii_lowercase()).collect(); + let mut out = String::with_capacity(bytes.len() + 32); + let mut i = 0usize; + const SECRET_KEYS: [&str; 6] = [ + "chatticket", + "gameticket", + "csrftoken", + "csrf", + "ticket", + "token", + ]; + while i < bytes.len() { + // `Bearer <16+ token chars>` → `Bearer [redacted]`. + if lower_chars[i..].starts_with(&['b', 'e', 'a', 'r', 'e', 'r', ' ']) { + let start = i + 7; + let mut end = start; + while end < bytes.len() && is_token_char(bytes[end]) { + end += 1; + } + if end - start >= 16 { + out.extend(&bytes[i..i + 7]); + out.push_str("[redacted]"); + i = end; + continue; + } + } + // `?key=value` / `&key=value` for secret-bearing keys. + if bytes[i] == '?' || bytes[i] == '&' { + let key_start = i + 1; + if let Some(eq_at) = SECRET_KEYS.iter().find_map(|key| { + let end = key_start + key.len(); + (end < lower_chars.len() + && lower_chars[key_start..end].iter().collect::<String>() == **key + && lower_chars[end] == '=') + .then_some(end) + }) { + let mut end = eq_at + 1; + while end < bytes.len() && bytes[end] != '&' && !bytes[end].is_whitespace() { + end += 1; + } + out.extend(&bytes[i..=eq_at]); + out.push_str("[redacted]"); + i = end; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + out +} + +/// Typed diagnostics input — this is the ONLY door into the payload. There is +/// deliberately no field a ticket, bearer token or chat body could ride in. +#[derive(Clone, Debug, Default)] +pub struct DiagnosticsInput { + pub client_release_id: String, + pub server_release_id: String, + pub shard_id: String, + pub source_state_hash: String, + pub area_id: String, + pub position: Option<(f32, f32)>, + pub life_state: String, + pub selected_actor_id: Option<String>, + pub weapon_id: Option<String>, + pub connected: bool, + pub authority_tick: u64, + pub accepted_commands: u64, + pub rejected_commands: u64, + /// Recent receipts: (command_id, accepted, reason_code). + pub recent_receipts: Vec<(u64, bool, String)>, + /// Recent client errors (already short strings; redacted again anyway). + pub recent_errors: Vec<String>, + pub open_windows: Vec<String>, + pub viewport: (u32, u32), + pub fps: f32, + pub uptime_ms: u64, +} + +/// Build the redacted diagnostics payload +/// (`successor.bug-report-diagnostics.v1`). +pub fn collect_diagnostics(input: &DiagnosticsInput) -> Value { + let receipts: Vec<Value> = input + .recent_receipts + .iter() + .rev() + .take(16) + .map(|(id, accepted, reason)| { + json!({ + "commandId": id, + "accepted": accepted, + "reasonCode": if reason.is_empty() { Value::Null } else { Value::String(redact_text(reason, 128)) }, + }) + }) + .collect(); + let errors: Vec<Value> = input + .recent_errors + .iter() + .rev() + .take(4) + .map(|e| Value::String(redact_text(e, 500))) + .collect(); + json!({ + "schema": "successor.bug-report-diagnostics.v1", + "clientUptimeMs": input.uptime_ms, + "client": { + "clientReleaseId": redact_text(&input.client_release_id, 128), + "serverReleaseId": redact_text(&input.server_release_id, 128), + "shardId": redact_text(&input.shard_id, 128), + }, + "viewport": { "width": input.viewport.0, "height": input.viewport.1 }, + "world": { + "areaId": redact_text(&input.area_id, 64), + "position": input.position.map(|(x, y)| json!({"x": x, "y": y})).unwrap_or(Value::Null), + "lifeState": redact_text(&input.life_state, 32), + "selectedActorId": input.selected_actor_id.as_deref().map(|s| Value::String(redact_text(s, 64))).unwrap_or(Value::Null), + "weaponId": input.weapon_id.as_deref().map(|s| Value::String(redact_text(s, 64))).unwrap_or(Value::Null), + }, + "authority": { + "connected": input.connected, + "tick": input.authority_tick, + "sourceStateHash": redact_text(&input.source_state_hash, 128), + "acceptedCommands": input.accepted_commands, + "rejectedCommands": input.rejected_commands, + }, + "recentReceipts": receipts, + "runtimeErrors": errors, + "ui": { "openWindows": input.open_windows.iter().map(|w| Value::String(redact_text(w, 48))).collect::<Vec<_>>() }, + "renderer": { "fps": input.fps }, + }) +} + +// ── Window ────────────────────────────────────────────────────────────────── + +pub fn draw( + ui: &mut UiBuilder, + ctx: super::Ctx, + model: &mut BugReportModel, + icons: &Icons, + out: &mut Vec<WindowAction>, +) { + let [x, y, w, h] = ctx.rect; + if let Some((col, row)) = icons.cell("bug-report") { + ui.icon(col, row, x + w - 28.0, y - 2.0, 22.0, 22.0, accent()); + } + + // Received panel replaces the form after acceptance. + if let BugStatus::Accepted { report_id } = &model.status { + let cy = y + 40.0; + ui.text("REPORT RECEIVED", x, cy, 2.0, accent()); + ui.text( + "YOUR SESSION LOG AND NOTES ARE TOGETHER IN THE QUEUE.", + x, + cy + 20.0, + 1.4, + dim(), + ); + ui.rect(x, cy + 36.0, w, 18.0, slot()); + ui.text(report_id, x + 6.0, cy + 40.0, 1.5, text()); + if ui.button( + x, + cy + 64.0, + 140.0, + 24.0, + "ANOTHER REPORT", + crate::hud::button_style(), + ) { + out.push(WindowAction::BugReportReset); + } + return; + } + + let mut cy = y + 24.0; + ui.text( + "TELL US WHAT BROKE, WHAT YOU EXPECTED, AND WHAT", + x, + cy, + 1.4, + dim(), + ); + ui.text("YOU DID JUST BEFORE IT HAPPENED.", x, cy + 11.0, 1.4, dim()); + cy += 28.0; + + // AREA selector. + ui.text("AREA", x, cy, 1.4, dim()); + cy += 12.0; + let btn_w = (w - 12.0) / 3.0; + for (i, (_, label)) in CATEGORIES.iter().enumerate() { + let col = i % 3; + let row = i / 3; + let bx = x + col as f32 * (btn_w + 6.0); + let by = cy + row as f32 * 26.0; + let mut style = crate::hud::button_style(); + if i == model.category { + style.fill = style.active; + style.edge = accent(); + } + if ui.button(bx, by, btn_w, 20.0, label, style) { + model.category = i; + } + } + cy += 2.0 * 26.0 + 8.0; + + // Body field + live count. + ui.text("WHAT HAPPENED?", x, cy, 1.4, dim()); + cy += 12.0; + let field_h = (h - (cy - y) - 78.0).max(40.0); + let pending = matches!(model.status, BugStatus::Pending { .. }); + ui.text_field( + &mut model.body, + x, + cy, + w, + field_h, + 1.6, + !pending, + crate::hud::button_style(), + ); + cy += field_h + 6.0; + let len = model.body.text.trim().chars().count(); + ui.text( + &format!("{len} / {BODY_MAX_CHARS}"), + x, + cy, + 1.3, + if len < BODY_MIN_CHARS { dim() } else { text() }, + ); + + // Diagnostics disclosure (exact reference promise). + ui.text( + "SESSION LOG ATTACHED: BUILD AND SHARD IDS, LOCATION,", + x, + cy + 12.0, + 1.2, + dim(), + ); + ui.text( + "CLIENT ERRORS, RECEIPTS, OPEN WINDOWS. NEVER PASSWORDS,", + x, + cy + 22.0, + 1.2, + dim(), + ); + ui.text( + "TICKETS, COOKIES, CHAT, OR INVENTORY.", + x, + cy + 32.0, + 1.2, + dim(), + ); + + // Status line. + match &model.status { + BugStatus::Pending { .. } => { + ui.text("PACKING SESSION LOG...", x, cy + 44.0, 1.4, accent()); + } + BugStatus::Denied { copy } => { + ui.text(copy, x, cy + 44.0, 1.4, crate::hud::active_palette().danger); + } + _ => {} + } + + // Submit. + let can_send = len >= BODY_MIN_CHARS && !pending; + let mut style = crate::hud::button_style(); + if !can_send { + style.text = dim(); + style.edge = slot_edge(); + } + let label = if pending { "SENDING..." } else { "SEND REPORT" }; + if ui.button(x, y + h - 24.0, w, 22.0, label, style) && can_send { + out.push(WindowAction::SubmitBugReport { + category: CATEGORIES[model.category].0.to_string(), + body: crate::hud::sanitize_text(&model.body.text, BODY_MAX_CHARS), + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx(rect: [f32; 4]) -> crate::windows::Ctx { + crate::windows::Ctx { + spec: crate::windows::spec::surface("bug-report").expect("report surface"), + rect, + tab: 0, + } + } + + #[test] + fn redacts_bearer_and_ticket_params() { + let s = "auth Bearer abcdefghijklmnop0123 tail"; + assert_eq!(redact_text(s, 500), "auth Bearer [redacted] tail"); + let q = "wss://x/y?gameTicket=SECRET123&keep=1&chatTicket=ALSO"; + let r = redact_text(q, 500); + assert!(!r.contains("SECRET123"), "{r}"); + assert!(!r.contains("ALSO"), "{r}"); + assert!(r.contains("gameTicket=[redacted]"), "{r}"); + assert!(r.contains("keep=1"), "{r}"); + // Short bearer-ish strings survive (not a token). + assert_eq!(redact_text("Bearer short", 64), "Bearer short"); + } + + #[test] + fn diagnostics_never_carry_secrets() { + let input = DiagnosticsInput { + client_release_id: "client-1".into(), + server_release_id: "server-1".into(), + shard_id: "shard-9".into(), + source_state_hash: "abc123".into(), + area_id: "open-desert".into(), + recent_errors: vec![ + "socket wss://host/game?ticket=TOPSECRET failed".into(), + "reject Bearer aaaaaaaaaaaaaaaaaaaaaa".into(), + ], + ..Default::default() + }; + let payload = serde_json::to_string(&collect_diagnostics(&input)).unwrap(); + assert!(!payload.contains("TOPSECRET")); + assert!(!payload.contains("aaaaaaaaaaaaaaaaaaaaaa")); + assert!(payload.contains("[redacted]")); + assert!(payload.contains("successor.bug-report-diagnostics.v1")); + } + + #[test] + fn result_correlation_is_exact() { + let accepted = serde_json::json!({ + "schema": "successor.bug-report-result.v1", + "requestId": "req-1", + "status": "accepted", + "reportId": "REPORT-12345", + "receivedAt": 172000.0, + }); + assert_eq!( + result_for_request(&accepted, "req-1"), + Some(BugStatus::Accepted { + report_id: "REPORT-12345".into() + }) + ); + assert_eq!( + result_for_request(&accepted, "req-2"), + None, + "foreign request ignored" + ); + let rejected = serde_json::json!({ + "schema": "successor.bug-report-result.v1", + "requestId": "req-1", + "status": "rejected", + "reasonCode": "rate_limited", + }); + assert!(matches!( + result_for_request(&rejected, "req-1"), + Some(BugStatus::Denied { .. }) + )); + let short_id = serde_json::json!({ + "schema": "successor.bug-report-result.v1", + "requestId": "req-1", + "status": "accepted", + "reportId": "short", + "receivedAt": 1.0, + }); + assert_eq!(result_for_request(&short_id, "req-1"), None); + } + + #[test] + fn submit_gates_on_min_length_and_pending() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let mut model = BugReportModel::new(); + let rect = [10.0, 10.0, 320.0, 400.0]; + // Too-short body: click SEND → no action. + for c in "short".chars() { + model.body.insert(c); + } + let (bx, by) = (10.0 + 160.0, 10.0 + 400.0 - 13.0); + ui.set_input(bx, by, true); + ui.begin(1280, 720); + let mut out = Vec::new(); + draw(&mut ui, ctx(rect), &mut model, &icons, &mut out); + ui.set_input(bx, by, false); + ui.begin(1280, 720); + out.clear(); + draw(&mut ui, ctx(rect), &mut model, &icons, &mut out); + assert!(out.is_empty()); + // Long enough → SubmitBugReport with the reference category id. + for c in " and then the terminal ate my report".chars() { + model.body.insert(c); + } + ui.set_input(bx, by, true); + ui.begin(1280, 720); + out.clear(); + draw(&mut ui, ctx(rect), &mut model, &icons, &mut out); + ui.set_input(bx, by, false); + ui.begin(1280, 720); + out.clear(); + draw(&mut ui, ctx(rect), &mut model, &icons, &mut out); + assert!(matches!( + out.as_slice(), + [WindowAction::SubmitBugReport { category, .. }] if category == "gameplay" + )); + // Pending swallows further submits. + model.status = BugStatus::Pending { + request_id: "r".into(), + }; + ui.set_input(bx, by, true); + ui.begin(1280, 720); + out.clear(); + draw(&mut ui, ctx(rect), &mut model, &icons, &mut out); + ui.set_input(bx, by, false); + ui.begin(1280, 720); + out.clear(); + draw(&mut ui, ctx(rect), &mut model, &icons, &mut out); + assert!(out.is_empty()); + } +} diff --git a/client-rust/source/app/src/windows/character.rs b/client-rust/source/app/src/windows/character.rs new file mode 100644 index 00000000..3370ea81 --- /dev/null +++ b/client-rust/source/app/src/windows/character.rs @@ -0,0 +1,261 @@ +//! CHARACTER — read-only sheet + the one action: profession-title select. +//! +//! Reads `WindowModel::character` (live `CharacterModel` projection: the +//! decoded player actor, active area, earned title options, career goal). +//! Emits `WindowAction::SetProfessionTitle(<title id>)` — the host maps it +//! onto `ClientCommand::SetProfessionTitle { title_id }`. + +use super::{accent, dim, slot, text, WindowAction, WindowModel}; +use crate::hud::{Icons, TextBuffer}; +use core::fmt::Write; +use successor_engine_render::ui::UiBuilder; + +/// Portrait column share of the sheet. The inventory's equipment column takes +/// the original's 232/468 split; the sheet is text-heavy, so it takes a +/// narrower slice of the same portrait. +const PORTRAIT_WIDTH_RATIO: f32 = 0.34; +const PORTRAIT_MIN_W: f32 = 72.0; +const PORTRAIT_MAX_W: f32 = 200.0; +const PORTRAIT_GAP: f32 = 12.0; +/// Doll viewport aspect, from the original's 225x367 paperdoll rect. +const DOLL_ASPECT: f32 = 225.0 / 367.0; + +fn bar(ui: &mut UiBuilder, x: f32, y: f32, w: f32, frac: f32, fill: [u8; 4], label: &str) { + ui.rect(x, y, w, 16.0, slot()); + if frac > 0.0 { + ui.rect(x, y, w * frac.clamp(0.0, 1.0), 16.0, fill); + } + + ui.text(label, x + 4.0, y + 2.0, 1.6, text()); +} + +/// Portrait column carrying the live character doll, mirroring the inventory's +/// equipment column so the same character reads the same way in both windows. +/// The renderer composites the doll into this rect after the UI pass. +pub fn preview_rect(rect: [f32; 4]) -> [f32; 4] { + let [x, y, w, h] = rect; + let column_w = (w * PORTRAIT_WIDTH_RATIO).clamp(PORTRAIT_MIN_W, PORTRAIT_MAX_W); + let column_w = column_w.min(w * 0.5); + let avail_h = (h - 4.0).max(0.0); + let preview_w = column_w.min(avail_h * DOLL_ASPECT); + let preview_h = avail_h.min(column_w / DOLL_ASPECT); + [x, y + (avail_h - preview_h) * 0.5, preview_w, preview_h] +} + +/// Left edge of the sheet's text column: past the portrait, or the frame edge +/// when the window is too narrow to carry one. +fn text_origin(rect: [f32; 4]) -> f32 { + let preview = preview_rect(rect); + if preview[2] <= PORTRAIT_MIN_W * 0.5 { + rect[0] + } else { + preview[0] + preview[2] + PORTRAIT_GAP + } +} + +pub fn draw( + ui: &mut UiBuilder, + ctx: super::Ctx, + model: &WindowModel, + _icons: &Icons, + out: &mut Vec<WindowAction>, +) { + let [_, y, _, _] = ctx.rect; + let c = &model.character; + let p = &c.player; + + // The live doll composites into the portrait column after the UI pass; the + // sheet's text starts past it. + let preview = preview_rect(ctx.rect); + super::chrome::region(ui, preview); + super::chrome::viewer_seat(ui, preview); + let x = text_origin(ctx.rect); + let w = (ctx.rect[0] + ctx.rect[2] - x).max(0.0); + + let mut label = TextBuffer::<160>::new(); + ui.text(&p.name, x, y, 3.0, accent()); + let title = p + .active_title + .as_ref() + .map(|t| t.label.as_str()) + .unwrap_or("NONE"); + let _ = write!(&mut label, "TITLE {}", title); + ui.text(label.as_str(), x, y + 30.0, 2.0, dim()); + label.clear(); + + // Vitals. + let bw = w - 4.0; + let _ = write!( + &mut label, + "HEALTH {}/{}", + p.health as i32, p.health_max as i32 + ); + bar( + ui, + x, + y + 58.0, + bw, + p.health / p.health_max.max(1.0), + crate::hud::plate::POOL_HEALTH, + label.as_str(), + ); + label.clear(); + let _ = write!( + &mut label, + "ACTION {}/{}", + p.action as i32, p.action_max as i32 + ); + bar( + ui, + x, + y + 80.0, + bw, + p.action / p.action_max.max(1.0), + crate::hud::plate::POOL_ACTION, + label.as_str(), + ); + label.clear(); + + // Ledger — live projection scalars only. + let _ = write!(&mut label, "AREA {}", c.area_id); + ui.text(label.as_str(), x, y + 108.0, 2.0, text()); + label.clear(); + let _ = write!(&mut label, "CREDITS {}", p.credits); + ui.text(label.as_str(), x, y + 130.0, 2.0, accent()); + label.clear(); + let goal = c.career_goal_label.as_deref().unwrap_or("NONE"); + let _ = write!(&mut label, "GOAL {}", goal); + ui.text(label.as_str(), x, y + 152.0, 2.0, text()); + label.clear(); + + // Professions (actor `professions[]`: label + accumulated XP). + ui.text("PROFESSIONS", x, y + 182.0, 2.0, dim()); + if p.professions.is_empty() { + ui.text("NONE", x + 8.0, y + 206.0, 1.8, dim()); + } + for (i, prof) in p.professions.iter().enumerate() { + let py = y + 206.0 + i as f32 * 22.0; + ui.text(&prof.label, x + 8.0, py, 1.8, text()); + let _ = write!(&mut label, "XP {}", prof.xp); + ui.text(label.as_str(), x + 180.0, py, 1.8, accent()); + label.clear(); + } + + // Title selector — the sole action. Options are the earned titles the + // projection derived from trained skill boxes; empty ⇒ nothing to set. + let rows = p.professions.len().max(1); + let ty = y + 206.0 + rows as f32 * 22.0 + 16.0; + ui.text("SET TITLE", x, ty, 2.0, dim()); + if c.title_options.is_empty() { + ui.text("NO TITLES EARNED", x + 8.0, ty + 26.0, 1.8, dim()); + return; + } + let bs = crate::hud::button_style(); + let bw2 = ((w - 16.0) / c.title_options.len().max(1) as f32).min(150.0); + for (i, opt) in c.title_options.iter().enumerate() { + let bx = x + i as f32 * (bw2 + 6.0); + let mut style = bs; + if p.active_title.as_ref() == Some(opt) { + style.fill = style.active; + } + if ui.button(bx, ty + 22.0, bw2, 26.0, &opt.label, style) { + out.push(WindowAction::SetProfessionTitle(opt.id.clone())); + } + } +} + +#[cfg(test)] +mod tests { + /// Fixed surface context for layout-independent assertions. + fn test_ctx(rect: [f32; 4]) -> crate::windows::Ctx { + crate::windows::Ctx { + spec: crate::windows::spec::surface("character").expect("character surface"), + rect, + tab: 0, + } + } + + /// The sheet frame every case draws into. + const RECT: [f32; 4] = [100.0, 100.0, 600.0, 700.0]; + + use super::*; + use crate::windows::model::{ProfessionState, ProfessionTitle}; + + /// Explicit test fixture — `WindowModel::sample()` is intentionally empty + /// so demo/test state can never masquerade as a live projection. + fn fixture() -> WindowModel { + let mut m = WindowModel::sample(); + m.character.player.name = "VETT".into(); + m.character.player.health = 80.0; + m.character.player.health_max = 100.0; + m.character.player.action = 40.0; + m.character.player.action_max = 120.0; + m.character.player.credits = 1250; + m.character.area_id = "open-desert".into(); + m.character.player.professions = vec![ProfessionState { + id: "marksman".into(), + label: "MARKSMAN".into(), + xp: 1200, + ..Default::default() + }]; + m.character.title_options = vec![ + ProfessionTitle { + id: "title-novice-marksman".into(), + label: "MARKSMAN".into(), + skill_box_id: "marksman_novice".into(), + }, + ProfessionTitle { + id: "title-scout".into(), + label: "SCOUT".into(), + skill_box_id: "scout_novice".into(), + }, + ]; + m + } + + #[test] + fn title_button_emits_title_id() { + let icons = Icons::load(); + let model = fixture(); + let mut ui = UiBuilder::new(icons.meta); + // 1 profession row ⇒ ty = y + 206 + 22 + 16; buttons sit at ty + 22. + // The text column starts past the portrait, so the button's x is + // derived from the layout rather than pinned to the frame edge. + let bx = text_origin(RECT) + 60.0; + let by = 100.0 + 206.0 + 22.0 + 16.0 + 22.0 + 12.0; + ui.set_input(bx, by, true); + ui.begin(1280, 900); + let mut out = Vec::new(); + draw(&mut ui, test_ctx(RECT), &model, &icons, &mut out); + ui.set_input(bx, by, false); + ui.begin(1280, 900); + out.clear(); + draw(&mut ui, test_ctx(RECT), &model, &icons, &mut out); + assert!( + matches!( + out.first(), + Some(WindowAction::SetProfessionTitle(t)) if t == "title-novice-marksman" + ), + "first title option emits its wire id, got {out:?}" + ); + } + + #[test] + fn empty_model_renders_without_actions() { + let icons = Icons::load(); + let model = WindowModel::sample(); + let mut ui = UiBuilder::new(icons.meta); + ui.set_input(160.0, 388.0, true); + ui.begin(1280, 900); + let mut out = Vec::new(); + draw(&mut ui, test_ctx(RECT), &model, &icons, &mut out); + ui.set_input(160.0, 388.0, false); + ui.begin(1280, 900); + out.clear(); + draw(&mut ui, test_ctx(RECT), &model, &icons, &mut out); + assert!( + out.is_empty(), + "empty projection emits nothing, got {out:?}" + ); + } +} diff --git a/client-rust/source/app/src/windows/chrome.rs b/client-rust/source/app/src/windows/chrome.rs new file mode 100644 index 00000000..e820b201 --- /dev/null +++ b/client-rust/source/app/src/windows/chrome.rs @@ -0,0 +1,872 @@ +//! Shared window chrome: the restrained drawing kit every surface uses. +//! +//! Visual grammar (deliberately narrow, so twenty-one surfaces read as one +//! client): +//! * **The frame perimeter is the only outline.** Rows, cards, and blocks are +//! separated by translucent fills and sparse hairlines, never by their own +//! borders. [`region`] paints a well fill plus one top hairline; [`Rows`] +//! paints an alternating band. Nothing nests a box inside a box. +//! * **Selection and focus are the only accents.** A selected row gets a 2 px +//! left rail and a brighter fill; hover gets a fill only. +//! * **The action column is reserved from the right and shrinks first.** Row +//! labels truncate with an ellipsis instead of colliding with a control, so +//! a frame dragged to its resize floor stays readable. +//! * **Density comes from the surface family** ([`super::spec::Metrics`]), not +//! from per-window literals. +//! +//! Metrics and tints are measured from the original dense windows +//! (`ui_options.inc`, `ui_auction.inc`, and the passing inventory crops): rows, +//! buttons, and tabs are 19 px; labels are 12 px and values 13 px; primary text +//! fields are 32 px; the content well is `#003848` with a `#004858` raised band +//! and a `#007890` rail. + +use super::spec::{Metrics, Surface}; +use super::{active, dim, label, text, value}; +use successor_engine_render::ui::{ButtonStyle, UiBuilder}; + +// ── Measured metrics ──────────────────────────────────────────────────────── + +/// Generic row / button / tab height. +pub const ROW_H: f32 = 19.0; +/// Label cap height. +pub const LABEL_PX: f32 = 12.0; +/// Value cap height. +pub const VALUE_PX: f32 = 13.0; +/// Primary text-field height. +pub const FIELD_H: f32 = 32.0; +/// Tab strip height (same 19 px as every other control). +pub const TAB_H: f32 = ROW_H; + +/// Convert a measured cap height in px into the 5×7 glyph scale `ui.text` takes. +pub const fn scale(cap_px: f32) -> f32 { + cap_px / 7.0 +} + +// ── Measured tints ────────────────────────────────────────────────────────── + +/// Content well fill (dominant tone inside a frame). +#[inline] +pub fn well() -> [u8; 4] { + crate::hud::faded(crate::hud::active_palette().bg_cell) +} + +/// Raised band: alternating list rows and secondary blocks. +#[inline] +pub fn well_raised() -> [u8; 4] { + crate::hud::faded(crate::hud::active_palette().accent_soft) +} + +/// Thin separator / border rail. +#[inline] +pub fn rail() -> [u8; 4] { + crate::hud::active_palette().hairline +} + +/// Bright edge highlight. +#[inline] +pub fn edge_bright() -> [u8; 4] { + crate::hud::active_palette().accent +} + +/// Warning / gated state ink. +#[inline] +pub fn warn() -> [u8; 4] { + crate::hud::active_palette().accent +} + +/// Denial ink. +#[inline] +pub fn deny() -> [u8; 4] { + crate::hud::active_palette().danger +} + +/// Hairline used for section separators and region tops. Alias of [`rail`]. +#[inline] +pub fn hairline() -> [u8; 4] { + rail() +} + +/// Recessed content region fill. Alias of [`well`]. +#[inline] +pub fn region_fill() -> [u8; 4] { + well() +} + +/// Alternating list band. Alias of [`well_raised`]. +#[inline] +pub fn band() -> [u8; 4] { + well_raised() +} + +/// Row hover. +#[inline] +pub fn hover() -> [u8; 4] { + crate::hud::faded(crate::hud::active_palette().accent_soft) +} + +/// Row selection fill (paired with the accent rail). +#[inline] +pub fn selected() -> [u8; 4] { + crate::hud::faded(crate::hud::active_palette().accent_soft) +} +/// Primary action control. +pub fn action_style() -> ButtonStyle { + ButtonStyle { + fill: [0, 0, 0, 0], + hover: hover(), + active: well_raised(), + edge: rail(), + text: text(), + } +} + +pub fn quiet_style() -> ButtonStyle { + ButtonStyle { + fill: [0, 0, 0, 0], + hover: hover(), + active: well_raised(), + edge: rail(), + text: dim(), + } +} + +fn tab_style(tab_active: bool) -> ButtonStyle { + ButtonStyle { + fill: if tab_active { well_raised() } else { well() }, + hover: hover(), + active: well_raised(), + edge: rail(), + text: if tab_active { text() } else { dim() }, + } +} + +/// Header: surface title, family caption, one full-width hairline. Returns the +/// content-start `y`. Surfaces whose body opens with a live 3D viewport declare +/// `header: false` and keep their caption in the frame title bar instead. +pub fn header(ui: &mut UiBuilder, rect: [f32; 4], spec: &Surface) -> f32 { + let [x, y, w, _] = rect; + let metrics = spec.metrics(); + ui.text(spec.title, x, y, metrics.heading_px, active()); + let caption = spec.family.caption(); + let caption_w = ui.measure_text(caption, metrics.caption_px); + let title_w = ui.measure_text(spec.title, metrics.heading_px); + // The caption right-aligns when it can stand clear of the heading. + if title_w + 14.0 + caption_w <= w { + ui.text( + caption, + x + w - caption_w, + y + metrics.heading_px * 7.0 - metrics.caption_px * 7.0, + metrics.caption_px, + dim(), + ); + } + let baseline = y + metrics.heading_px * 7.0 + 4.0; + ui.rect(x, baseline, w, 1.0, rail()); + baseline + 6.0 +} + +/// Body origin for a surface: below the header and tab strip when it has them. +/// Kept in sync with [`header`] and [`tabs`] so geometry tests can predict the +/// first content row without drawing. +pub fn body_top(rect: [f32; 4], spec: &Surface) -> f32 { + let metrics = spec.metrics(); + let mut y = rect[1]; + if spec.header { + y += metrics.heading_px * 7.0 + 4.0 + 6.0; + } + if !spec.tabs.is_empty() { + y += TAB_H + 8.0; + } + y +} + +/// Underline-selected tab strip. Returns the clicked index, if any. Tabs share +/// the width evenly and never draw a border — the 2 px accent underline is the +/// only selection signal. +pub fn tabs( + ui: &mut UiBuilder, + x: f32, + y: f32, + w: f32, + labels: &[&str], + active: usize, +) -> Option<usize> { + if labels.is_empty() { + return None; + } + let gap = 3.0; + let count = labels.len() as f32; + let tab_w = ((w - gap * (count - 1.0)) / count).max(28.0); + let mut clicked = None; + for (index, label) in labels.iter().enumerate() { + let tab_x = x + index as f32 * (tab_w + gap); + let is_active = index == active; + if ui.button(tab_x, y, tab_w, TAB_H, label, tab_style(is_active)) { + clicked = Some(index); + } + if is_active { + ui.rect(tab_x, y + TAB_H - 2.0, tab_w, 2.0, super::active()); + } + } + clicked +} + +/// Sparse section separator: a dim label over one hairline. Returns the `y` +/// below it. +pub fn section(ui: &mut UiBuilder, x: f32, y: f32, w: f32, label: &str, metrics: Metrics) -> f32 { + let label_w = ui.measure_text(label, metrics.caption_px); + ui.text(label, x, y, metrics.caption_px, dim()); + let rule_x = x + label_w + 8.0; + if rule_x < x + w { + ui.rect( + rule_x, + y + metrics.caption_px * 3.5, + x + w - rule_x, + 1.0, + rail(), + ); + } + y + metrics.caption_px * 7.0 + 6.0 +} + +/// A true content region: well fill. Never a full border — the frame perimeter +/// already encloses everything. +pub fn region(ui: &mut UiBuilder, rect: [f32; 4]) { + let [x, y, w, h] = rect; + if w <= 0.0 || h <= 0.0 { + return; + } + ui.rect(x, y, w, h, well()); +} + +/// Faint tint behind a composited 3D viewer cell. +/// +/// Owner ruling 2026-08-05: the doll reads against the pane's own translucent +/// fill, so the seat does not paint an opaque slab. An alpha-245 near-black +/// here punched a hard black rectangle through an otherwise see-through +/// window wherever a viewer cell sat. +pub const VIEWER_SEAT: [u8; 4] = [3, 7, 8, 28]; + +/// Seat for a live 3D viewer cell. +pub fn viewer_seat(ui: &mut UiBuilder, rect: [f32; 4]) { + let [x, y, w, h] = rect; + if w > 0.0 && h > 0.0 { + // The cell is marked by its border, not by blacking it out; the model + // composites over whatever the pane already shows. + ui.rect(x, y, w, h, VIEWER_SEAT); + ui.border(x, y, w, h, 1.0, hairline()); + } +} + +/// Horizontal value meter (vitals, concentration, progress). Track then fill; +/// no outline. +pub fn meter(ui: &mut UiBuilder, x: f32, y: f32, w: f32, h: f32, frac: f32, tint: [u8; 4]) { + ui.rect(x, y, w, h, super::slot()); + let filled = w * frac.clamp(0.0, 1.0); + if filled > 0.0 { + ui.rect(x, y, filled, h, tint); + } +} + +/// Longest character prefix of `text` that fits `max_w` at `px`, and whether it +/// was cut. Truncation is how a narrow frame stays readable. +pub fn clip<'a>(ui: &UiBuilder, text: &'a str, px: f32, max_w: f32) -> (&'a str, bool) { + if max_w <= 0.0 { + return ("", !text.is_empty()); + } + if ui.measure_text(text, px) <= max_w { + return (text, false); + } + let budget = (max_w - ui.measure_text("...", px)).max(0.0); + let mut end = 0; + let mut width = 0.0; + for (offset, ch) in text.char_indices() { + let advance = ui.measure_text(&text[offset..offset + ch.len_utf8()], px); + if width + advance > budget { + break; + } + width += advance; + end = offset + ch.len_utf8(); + } + (&text[..end], true) +} + +/// Draw `text` clipped to `max_w`, appending a dim ellipsis when cut. Returns +/// the x cursor after the text. +pub fn text_clipped( + ui: &mut UiBuilder, + text: &str, + x: f32, + y: f32, + px: f32, + max_w: f32, + rgba: [u8; 4], +) -> f32 { + let (head, cut) = clip(ui, text, px, max_w); + let end = ui.text(head, x, y, px, rgba); + if cut { + ui.text("...", end, y, px, dim()); + return end + ui.measure_text("...", px); + } + end +} + +/// Explicit unavailable/empty state. One dim line, no placeholder furniture. +pub fn empty(ui: &mut UiBuilder, x: f32, y: f32, note: &str) { + ui.text( + if note.is_empty() { "UNAVAILABLE" } else { note }, + x, + y, + scale(LABEL_PX), + dim(), + ); +} + +/// A gated/denied notice: warning ink, same single line. +pub fn denied(ui: &mut UiBuilder, x: f32, y: f32, note: &str) { + ui.text( + if note.is_empty() { "UNAVAILABLE" } else { note }, + x, + y, + scale(LABEL_PX), + warn(), + ); +} + +/// Label/value pair on one baseline; the value right-aligns to `w`. +pub fn field( + ui: &mut UiBuilder, + x: f32, + y: f32, + w: f32, + label: &str, + value: &str, + metrics: Metrics, +) -> f32 { + ui.text(label, x, y + 1.0, metrics.caption_px, super::label()); + let label_w = ui.measure_text(label, metrics.caption_px) + 10.0; + let value_w = ui.measure_text(value, metrics.label_px); + let available = (w - label_w).max(0.0); + let value_x = if value_w <= available { + x + w - value_w + } else { + x + label_w + }; + text_clipped( + ui, + value, + value_x, + y, + metrics.label_px, + available, + super::value(), + ); + y + metrics.row_h - 4.0 +} + +/// A vertical run of scannable rows with a right-hand action column. +/// +/// The cursor stops at `bottom`, so a resized frame drops rows instead of +/// drawing past its content rect. Row bands alternate; nothing is boxed. +pub struct Rows { + x: f32, + y: f32, + w: f32, + bottom: f32, + metrics: Metrics, + index: usize, +} + +impl Rows { + pub fn new(rect: [f32; 4], metrics: Metrics) -> Self { + Self { + x: rect[0], + y: rect[1], + w: rect[2], + bottom: rect[1] + rect[3], + metrics, + index: 0, + } + } + + /// Current cursor `y` (for trailing content under the list). + pub fn cursor(&self) -> f32 { + self.y + } + + /// Advance the cursor without emitting a row (spacers, sub-lines). + pub fn advance(&mut self, dy: f32) { + self.y += dy; + } + + /// Whether another row of `height` still fits. + pub fn fits(&self, height: f32) -> bool { + self.y + height <= self.bottom + } + + pub fn metrics(&self) -> Metrics { + self.metrics + } + + /// Begin the next row. `None` once the list runs out of vertical room. + pub fn next(&mut self, ui: &mut UiBuilder) -> Option<Row> { + self.row_with_height(ui, self.metrics.row_h, false) + } + + /// Begin the next row, marked as the current selection. + pub fn next_selected(&mut self, ui: &mut UiBuilder, selected: bool) -> Option<Row> { + self.row_with_height(ui, self.metrics.row_h, selected) + } + + /// Begin a taller row (two-line entries: label plus qualifier). + pub fn next_tall(&mut self, ui: &mut UiBuilder, height: f32) -> Option<Row> { + self.row_with_height(ui, height, false) + } + + fn row_with_height(&mut self, ui: &mut UiBuilder, height: f32, selected: bool) -> Option<Row> { + if !self.fits(height) { + return None; + } + let y = self.y; + let (mouse_x, mouse_y) = ui.mouse(); + let band_h = height - 1.0; + let hovered = UiBuilder::hit(self.x, y, self.w, band_h, mouse_x, mouse_y); + if selected { + ui.rect(self.x, y, self.w, band_h, super::chrome::selected()); + ui.rect(self.x, y, 2.0, band_h, active()); + } else if hovered { + ui.rect(self.x, y, self.w, band_h, hover()); + } else if self.index % 2 == 1 { + ui.rect(self.x, y, self.w, band_h, well_raised()); + } + self.index += 1; + self.y += height; + Some(Row { + x: self.x, + y, + w: self.w, + h: band_h, + metrics: self.metrics, + right: self.x + self.w, + action_right: self.x + self.w, + selected, + hovered, + }) + } +} + +/// One list row. Actions pack from the right; the label region is whatever is +/// left, and long labels truncate. +pub struct Row { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, + pub metrics: Metrics, + /// Band right edge, fixed at construction so an indent cannot move it. + right: f32, + action_right: f32, + pub selected: bool, + pub hovered: bool, +} + +impl Row { + /// Action control width at this row's width: the column shrinks before the + /// label does, and never below a legible floor. + pub fn action_w(&self) -> f32 { + (self.w * 0.24).clamp(46.0, self.metrics.action_w) + } + + /// Leave one standard action column empty so controls in a shorter row + /// align with sibling rows that carry two actions. + pub fn reserve_action(&mut self) { + let left = self.action_right - self.action_w(); + if left >= self.x + 40.0 { + self.action_right = left - 3.0; + } + } + + /// Shift the text origin right, for rows that lead with a glyph. The band, + /// the action column, and the row's right edge all stay where they are. + pub fn indent(&mut self, dx: f32) { + self.x += dx; + } + + /// Place the next action, right to left. Returns true when clicked. + pub fn action(&mut self, ui: &mut UiBuilder, label: &str) -> bool { + self.action_styled(ui, label, action_style()) + } + + /// A quieter action (destructive or secondary) in the same column. + pub fn quiet_action(&mut self, ui: &mut UiBuilder, label: &str) -> bool { + self.action_styled(ui, label, quiet_style()) + } + + fn action_styled(&mut self, ui: &mut UiBuilder, label: &str, style: ButtonStyle) -> bool { + let width = self.action_w(); + let left = self.action_right - width; + if left < self.x + 40.0 { + // No room for another control at this width; drop it rather than + // overlap the label. + return false; + } + let height = self.metrics.action_h.min(self.h); + let clicked = ui.button( + left, + self.y + (self.h - height) * 0.5, + width, + height, + label, + style, + ); + self.action_right = left - 3.0; + clicked + } + + /// Width still available for text before the action column. + pub fn text_w(&self) -> f32 { + (self.action_right - self.x - self.metrics.gutter - 4.0).max(0.0) + } + + /// Primary row label, clipped to the free width. + pub fn label(&self, ui: &mut UiBuilder, text: &str) { + let px = self.metrics.label_px; + text_clipped( + ui, + text, + self.x + self.metrics.gutter, + self.y + (self.h - px * 7.0) * 0.5, + px, + self.text_w(), + label(), + ); + } + + /// Primary label plus a dim qualifier under it (two-line rows). + pub fn label_caption(&self, ui: &mut UiBuilder, text: &str, caption: &str) { + let px = self.metrics.label_px; + let cpx = self.metrics.caption_px; + let block = px * 7.0 + cpx * 7.0 + 2.0; + let top = self.y + ((self.h - block) * 0.5).max(1.0); + let text_w = self.text_w(); + text_clipped( + ui, + text, + self.x + self.metrics.gutter, + top, + px, + text_w, + label(), + ); + text_clipped( + ui, + caption, + self.x + self.metrics.gutter, + top + px * 7.0 + 2.0, + cpx, + text_w, + dim(), + ); + } + + /// Split a tall row into the standard first line — label plus the action + /// columns — and a caption line under it. Returns the caption's top `y`. + /// + /// Call this before placing actions or a leading glyph: both centre on the + /// row height it collapses. Where [`Row::label_caption`] keeps the + /// qualifier inside the label block beside the controls, this hands the + /// second line the row's whole width, so a description truncates only when + /// the frame itself is too narrow. + pub fn split_caption_line(&mut self) -> f32 { + let top = self.y + self.metrics.row_h; + self.h = self.h.min(self.metrics.row_h - 1.0); + top + } + + /// Width the caption line has: everything from the text origin to the row's + /// right edge, unlike [`Row::text_w`], which stops at the action column. + pub fn caption_w(&self) -> f32 { + (self.right - self.x - self.metrics.gutter - 4.0).max(0.0) + } + + /// The caption line opened by [`Row::split_caption_line`]: one dim line + /// across the row's whole width, bounded by the frame alone. + pub fn caption_line(&self, ui: &mut UiBuilder, top: f32, text: &str) { + text_clipped( + ui, + text, + self.x + self.metrics.gutter, + top, + self.metrics.caption_px, + self.caption_w(), + dim(), + ); + } + + /// Label tinted by state (dim when unavailable, warn when gated). + pub fn label_tinted(&self, ui: &mut UiBuilder, text: &str, rgba: [u8; 4]) { + let px = self.metrics.label_px; + text_clipped( + ui, + text, + self.x + self.metrics.gutter, + self.y + (self.h - px * 7.0) * 0.5, + px, + self.text_w(), + rgba, + ); + } + + /// A right-aligned value left of the action column (quantities, prices). + pub fn value(&mut self, ui: &mut UiBuilder, text: &str) { + let px = self.metrics.label_px; + let width = ui.measure_text(text, px); + let left = self.action_right - width; + if left <= self.x + self.metrics.gutter { + return; + } + ui.text(text, left, self.y + (self.h - px * 7.0) * 0.5, px, value()); + self.action_right = left - 8.0; + } + + /// Whole-row click target (selection). Consumed after the actions so a + /// button press is not also a row select. + pub fn clicked(&self, ui: &UiBuilder) -> bool { + ui.interact(self.x, self.y, self.text_w(), self.h).clicked + } +} + +/// A footer action rail: `labels` share the width evenly along one baseline. +/// Returns the clicked index. Used for commit rows (ASSEMBLE / CANCEL) so +/// primary actions land in the same place on every surface. +pub fn action_rail( + ui: &mut UiBuilder, + x: f32, + y: f32, + w: f32, + labels: &[&str], + metrics: Metrics, +) -> Option<usize> { + if labels.is_empty() { + return None; + } + let gap = 4.0; + let count = labels.len() as f32; + let each = ((w - gap * (count - 1.0)) / count).max(44.0); + let mut clicked = None; + for (index, label) in labels.iter().enumerate() { + let bx = x + index as f32 * (each + gap); + if bx + each > x + w + 0.5 { + break; + } + if ui.button(bx, y, each, metrics.action_h + 3.0, label, action_style()) { + clicked = Some(index); + } + } + clicked +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::windows::spec::{self, Density}; + + fn builder() -> UiBuilder { + UiBuilder::new(crate::hud::Icons::load().meta) + } + + #[test] + fn measured_metrics_match_the_original_density() { + assert_eq!(ROW_H, 19.0, "generic rows/buttons/tabs are 19 px"); + assert_eq!(TAB_H, ROW_H, "tabs share the generic control height"); + assert_eq!(LABEL_PX, 12.0); + assert_eq!(VALUE_PX, 13.0); + assert_eq!(FIELD_H, 32.0); + // Every family stays on the measured pitch rather than card rows. + for surface in &spec::SURFACES { + let metrics = surface.metrics(); + assert!( + metrics.row_h <= 30.0, + "{} row pitch {} is a card, not a row", + surface.id, + metrics.row_h + ); + } + } + + #[test] + fn clip_truncates_instead_of_overflowing() { + let ui = builder(); + let (head, cut) = clip(&ui, "PORTABLE MINERAL EXTRACTOR", 1.5, 40.0); + assert!(cut, "a long label must report truncation"); + assert!( + ui.measure_text(head, 1.5) + ui.measure_text("...", 1.5) <= 40.0, + "clipped label plus ellipsis must fit the budget" + ); + let (head, cut) = clip(&ui, "CR 40", 1.5, 200.0); + assert!(!cut); + assert_eq!(head, "CR 40"); + assert_eq!(clip(&ui, "ANY", 1.5, 0.0), ("", true)); + } + + #[test] + fn rows_stop_at_the_content_bottom() { + let mut ui = builder(); + ui.begin(1280, 720); + let metrics = Density::List.metrics(); + let mut rows = Rows::new([0.0, 0.0, 300.0, metrics.row_h * 3.0], metrics); + assert!(rows.next(&mut ui).is_some()); + assert!(rows.next(&mut ui).is_some()); + assert!(rows.next(&mut ui).is_some()); + assert!( + rows.next(&mut ui).is_none(), + "a row list must not draw past its rect" + ); + } + + #[test] + fn narrow_rows_keep_the_label_clear_of_the_action_column() { + let mut ui = builder(); + ui.begin(1280, 720); + let metrics = Density::List.metrics(); + let mut rows = Rows::new([0.0, 0.0, 180.0, 200.0], metrics); + let mut row = rows.next(&mut ui).expect("first row"); + assert!(!row.action(&mut ui, "TAKE")); + assert!( + row.text_w() > 0.0, + "the label region collapsed at the narrow width" + ); + assert!( + row.action_w() >= 46.0, + "the action control fell below the legible floor" + ); + } + + /// A split row confines its controls to the first line and hands the caption + /// line the row's whole width, so a description is bounded by the frame + /// rather than by the action column it would otherwise stop short of. + #[test] + fn a_split_row_hands_the_caption_line_the_full_width() { + let mut ui = builder(); + ui.begin(1280, 720); + let metrics = Density::List.metrics(); + let tall = metrics.row_h + metrics.caption_px * 7.0 + 2.0; + let mut rows = Rows::new([0.0, 0.0, 300.0, 200.0], metrics); + let mut row = rows.next_tall(&mut ui, tall).expect("tall row"); + let caption_y = row.split_caption_line(); + assert_eq!( + caption_y, + row.y + metrics.row_h, + "the caption must open the line under the controls" + ); + assert_eq!( + row.h, + metrics.row_h - 1.0, + "controls must be confined to the first line" + ); + assert!( + caption_y >= row.y + row.h, + "the caption line must clear the control line" + ); + row.action(&mut ui, "ASSIGN"); + row.action(&mut ui, "OPEN"); + row.indent(metrics.row_h - 1.0); + assert!( + row.caption_w() >= row.text_w() + row.action_w() * 2.0, + "the caption line must clear both action columns: {} against a label \ + region of {}", + row.caption_w(), + row.text_w() + ); + } + + #[test] + fn body_top_matches_the_drawn_header_and_tabs() { + let mut ui = builder(); + ui.begin(1280, 720); + for id in ["bank", "datapad", "inventory", "examine"] { + let surface = spec::surface(id).expect("spec"); + let rect = [10.0, 20.0, 400.0, 300.0]; + let predicted = body_top(rect, surface); + let mut drawn = if surface.header { + header(&mut ui, rect, surface) + } else { + rect[1] + }; + if !surface.tabs.is_empty() { + drawn += TAB_H + 8.0; + } + assert!( + (predicted - drawn).abs() <= 0.5, + "{id}: body_top {predicted} disagrees with drawn {drawn}" + ); + } + } + + #[test] + fn a_row_action_reports_its_click() { + let mut ui = builder(); + let metrics = Density::List.metrics(); + let mut clicked = false; + for down in [true, false] { + ui.set_input(300.0 - 30.0, metrics.row_h * 0.5, down); + ui.begin(1280, 720); + let mut rows = Rows::new([0.0, 0.0, 300.0, 200.0], metrics); + let mut row = rows.next(&mut ui).expect("row"); + clicked = row.action(&mut ui, "TAKE"); + } + assert!(clicked, "release inside the action must report a click"); + } + + /// True when any emitted triangle covers the framebuffer point `(x, y)`. + /// Vertices are `[ndc_x, ndc_y, u, v, r, g, b, a]`. + fn covers_point(ui: &UiBuilder, x: f32, y: f32, sw: f32, sh: f32) -> bool { + let ndc_x = x / sw * 2.0 - 1.0; + let ndc_y = 1.0 - y / sh * 2.0; + const STRIDE: usize = 8; + ui.buf.chunks_exact(STRIDE * 3).any(|tri| { + let p = [ + (tri[0], tri[1]), + (tri[STRIDE], tri[STRIDE + 1]), + (tri[STRIDE * 2], tri[STRIDE * 2 + 1]), + ]; + let edge = |a: (f32, f32), b: (f32, f32)| { + (b.0 - a.0) * (ndc_y - a.1) - (b.1 - a.1) * (ndc_x - a.0) + }; + let (d0, d1, d2) = (edge(p[0], p[1]), edge(p[1], p[2]), edge(p[2], p[0])); + (d0 >= 0.0 && d1 >= 0.0 && d2 >= 0.0) || (d0 <= 0.0 && d1 <= 0.0 && d2 <= 0.0) + }) + } + + #[test] + fn a_viewer_seat_lays_an_unlit_backdrop_under_its_model() { + // The seat is drawn with the rest of the window content, which the + // host flushes BEFORE the composite band for that window. Filling it + // is therefore safe and necessary: the model lands on top, and without + // the fill an empty viewer shows the themed pane or the world instead + // of an unlit backdrop. + let cell = [10.0, 20.0, 60.0, 70.0]; + let centre = (cell[0] + cell[2] * 0.5, cell[1] + cell[3] * 0.5); + + let mut seat = builder(); + seat.begin(1280, 720); + viewer_seat(&mut seat, cell); + assert!( + covers_point(&seat, centre.0, centre.1, 1280.0, 720.0), + "the seat must back the viewer interior" + ); + assert!( + covers_point(&seat, centre.0, cell[1] + 0.5, 1280.0, 720.0), + "and still mark the lane" + ); + // The backdrop is unlit, not the themed pane: a lit fill would tint + // the model sitting on it. + assert!(VIEWER_SEAT[..3].iter().all(|channel| *channel < 16)); + + // A degenerate cell draws nothing at all. + let mut empty = builder(); + empty.begin(1280, 720); + viewer_seat(&mut empty, [10.0, 20.0, 0.0, 70.0]); + assert_eq!(empty.quads, 0); + } +} diff --git a/client-rust/source/app/src/windows/commands.rs b/client-rust/source/app/src/windows/commands.rs new file mode 100644 index 00000000..da7d8e58 --- /dev/null +++ b/client-rust/source/app/src/windows/commands.rs @@ -0,0 +1,203 @@ +//! ACTIONS — the command browser (`SwgCuiCommandBrowser`). +//! +//! One scannable row per bindable command in the toolbar registry: the command +//! name and its controls share the standard first line, and the command's +//! one-line description spans the full width beneath them, so a description is +//! bounded by the frame rather than by the action column. ASSIGN arms the +//! toolbar's pending assignment so the next slot click binds that command — the +//! original surface's whole job is getting commands onto the bar, and this port +//! keeps that as the primary action (the reference client drags a row onto a +//! slot; the immediate-mode port arms-then-clicks, which is the same two +//! gestures without a drag payload). +//! +//! A closing TOOLS section carries the surfaces that have no dock button and no +//! terminal of their own, so they stay reachable without inventing a second +//! dock: the support report and the survey tool. + +use super::chrome::{self, Row, Rows}; +use super::{dim, Ctx, WindowAction}; +use crate::hud::toolbar::{ActionKind, TOOLBAR_ACTIONS}; +use crate::hud::Icons; +use successor_engine_render::ui::UiBuilder; + +/// Context surfaces reachable from the browser: `(window id, label)`. +const TOOLS: [(&str, &str); 2] = [("survey", "SURVEY TOOL"), ("bug-report", "SUPPORT REPORT")]; + +pub fn draw(ui: &mut UiBuilder, ctx: Ctx, icons: &Icons, out: &mut Vec<WindowAction>) { + let metrics = ctx.metrics(); + let [x, y, w, h] = ctx.rect; + + // The TOOLS rail is fixed at the bottom so the command list never pushes it + // off a short frame. + let tools_h = metrics.caption_px * 7.0 + 6.0 + TOOLS.len() as f32 * metrics.row_h; + let list_h = (h - tools_h - 6.0).max(metrics.row_h); + + let hint_y = y; + chrome::text_clipped( + ui, + "ASSIGN ARMS A SLOT. CLICK A TOOLBAR SLOT TO BIND.", + x, + hint_y, + metrics.caption_px, + w, + dim(), + ); + let list_y = hint_y + metrics.caption_px * 7.0 + 5.0; + let mut rows = Rows::new([x, list_y, w, (list_h - (list_y - y)).max(0.0)], metrics); + + for action in TOOLBAR_ACTIONS.iter() { + let row_h = metrics.row_h + metrics.caption_px * 7.0 + 2.0; + let Some(mut row) = rows.next_tall(ui, row_h) else { + break; + }; + // The command name and its controls share the standard first line; the + // description owns the full-width line beneath, so a caption is bounded + // by the frame instead of stopping short of the ASSIGN column. + let caption_y = row.split_caption_line(); + if row.action(ui, "ASSIGN") { + out.push(WindowAction::BeginAssignAction(action.id.to_string())); + } + // Window shortcuts also open their surface directly, which is how the + // reference options pane links into a window. + if let ActionKind::Window(window_id) = action.kind { + if row.action(ui, "OPEN") { + out.push(WindowAction::OpenWindow(window_id.to_string())); + } + } + glyph(ui, &row, icons, action.icon); + // Indent the text past the glyph without a second column of chrome. + row.indent(metrics.row_h - 1.0); + row.label(ui, action.label); + row.caption_line(ui, caption_y, action.description); + } + + let mut cursor = y + list_h; + cursor = chrome::section(ui, x, cursor, w, "TOOLS", metrics); + let mut tools = Rows::new([x, cursor, w, (y + h - cursor).max(0.0)], metrics); + for (window_id, label) in TOOLS { + let Some(mut row) = tools.next(ui) else { break }; + // One control per tool row, parked in the command rows' OPEN column so + // both sections share the same two right-hand columns. + row.reserve_action(); + if row.action(ui, "OPEN") { + out.push(WindowAction::OpenWindow(window_id.to_string())); + } + glyph(ui, &row, icons, window_id); + row.indent(metrics.row_h - 1.0); + row.label(ui, label); + } +} + +/// The row's leading atlas glyph, centred on the row's first line. Both +/// sections call this, so TOOLS keeps the command rows' glyph rhythm and text +/// inset instead of starting flush at the gutter. +fn glyph(ui: &mut UiBuilder, row: &Row, icons: &Icons, id: &str) { + let Some((column, glyph_row)) = icons.cell(id) else { + return; + }; + let size = row.metrics.row_h - 5.0; + ui.icon( + column, + glyph_row, + row.x + row.metrics.gutter, + row.y + (row.h - size) * 0.5, + size, + size, + super::label(), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::windows::spec; + + fn ctx(rect: [f32; 4]) -> Ctx { + Ctx { + spec: spec::surface("actions").expect("actions surface"), + rect, + tab: 0, + } + } + + fn click( + ui: &mut UiBuilder, + icons: &Icons, + rect: [f32; 4], + cx: f32, + cy: f32, + ) -> Vec<WindowAction> { + let mut out = Vec::new(); + for down in [true, false] { + ui.set_input(cx, cy, down); + ui.begin(1280, 720); + out.clear(); + draw(ui, ctx(rect), icons, &mut out); + } + out + } + + #[test] + fn every_registry_command_is_listed_and_assignable() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + // Tall enough for the whole registry plus the tools rail. + let rect = [0.0, 0.0, 320.0, 900.0]; + let metrics = ctx(rect).metrics(); + let list_y = metrics.caption_px * 7.0 + 5.0; + let action_w = (rect[2] * 0.24).clamp(46.0, metrics.action_w); + // First registry row's ASSIGN control. Controls ride the row's standard + // first line, above the description line. + let out = click( + &mut ui, + &icons, + rect, + rect[2] - action_w * 0.5, + list_y + metrics.row_h * 0.5, + ); + let first = TOOLBAR_ACTIONS[0].id; + assert!( + out.contains(&WindowAction::BeginAssignAction(first.to_string())), + "the first command row must arm assignment for {first}, got {out:?}" + ); + } + + /// TOOLS rows carry one action, and it sits in the same column as the + /// command rows' OPEN control rather than jumping to the ASSIGN column. + #[test] + fn tools_rail_opens_the_dockless_surfaces_in_the_open_column() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let rect = [0.0, 0.0, 320.0, 400.0]; + let metrics = ctx(rect).metrics(); + let tools_h = metrics.caption_px * 7.0 + 6.0 + TOOLS.len() as f32 * metrics.row_h; + let list_h = (rect[3] - tools_h - 6.0).max(metrics.row_h); + let first_tool_y = list_h + metrics.caption_px * 7.0 + 6.0 + metrics.row_h * 0.5; + let action_w = (rect[2] * 0.24).clamp(46.0, metrics.action_w); + let open_column_x = rect[2] - action_w - 3.0 - action_w * 0.5; + let outer_column_x = rect[2] - action_w * 0.5; + + let out = click(&mut ui, &icons, rect, open_column_x, first_tool_y); + assert!( + out.contains(&WindowAction::OpenWindow("survey".into())), + "the tools rail must open the survey surface, got {out:?}" + ); + + let outer = click(&mut ui, &icons, rect, outer_column_x, first_tool_y); + assert!( + outer.is_empty(), + "the reserved outer column must stay empty on tool rows, got {outer:?}" + ); + } + + #[test] + fn no_intents_without_a_click() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + ui.begin(1280, 720); + let mut out = Vec::new(); + draw(&mut ui, ctx([0.0, 0.0, 300.0, 280.0]), &icons, &mut out); + assert!(out.is_empty()); + assert!(ui.quads > 0, "the browser must draw at its resize floor"); + } +} diff --git a/client-rust/source/app/src/windows/inventory.rs b/client-rust/source/app/src/windows/inventory.rs new file mode 100644 index 00000000..06eb0ba3 --- /dev/null +++ b/client-rust/source/app/src/windows/inventory.rs @@ -0,0 +1,2006 @@ +//! INVENTORY — held-stack grid + examine sidebar with Use/Equip/Drop actions. +//! +//! Reads `WindowModel::inventory` (live `InventoryModel` projection: wire +//! `GameInventoryRow`s, reservation holds, wallet credits, wielded weapon). +//! The grid shows held (non-exchange) stacks; exchange stockpile rows belong +//! to the datapad DATA tab. Selection is window-local UI state keyed by +//! `(container, stack_id)` and is joined back into the live examine projection. +//! Action payloads carry `InventoryRow::item_id`. + +use super::{accent, dim, slot, slot_edge, text, WindowAction, WindowModel}; +use crate::hud::Icons; +use core::cell::{Cell, RefCell}; +use successor_engine_render::ui::UiBuilder; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum InventoryView { + IconGrid, + CompactList, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EquipSlotKind { + Head, + Face, + Torso, + Back, + ArmLeft, + ArmRight, + Hands, + Belt, + Legs, + Feet, + Weapon, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum CategoryFilter { + #[default] + All, + Weapon, + Armor, + Clothing, + Consumable, + Resource, + Misc, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum SortMode { + #[default] + Name, + Category, + Quantity, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct SplitStepperState { + container: String, + stack_id: String, + quantity: u32, +} + +thread_local! { + /// Window-local examine selection `(container, stack_id)`. Interim home + /// until the shared `WindowUiState` threading lands (see `windows::mod`). + static SELECTED: RefCell<Option<(String, String)>> = const { RefCell::new(None) }; + static GRID_PAGE: Cell<usize> = const { Cell::new(0) }; + static LIST_PAGE: Cell<usize> = const { Cell::new(0) }; + static VIEW: Cell<InventoryView> = const { Cell::new(InventoryView::IconGrid) }; + static FILTER: Cell<CategoryFilter> = const { Cell::new(CategoryFilter::All) }; + static SORT: Cell<SortMode> = const { Cell::new(SortMode::Name) }; + static SORTED_INDICES: RefCell<Vec<usize>> = const { RefCell::new(Vec::new()) }; + static SORT_CACHE_KEY: Cell<(CategoryFilter, SortMode, usize, u64)> = + const { Cell::new((CategoryFilter::All, SortMode::Name, 0, 0)) }; + static SPLIT_STEPPER: RefCell<Option<SplitStepperState>> = const { RefCell::new(None) }; +} + +fn current_filter() -> CategoryFilter { + FILTER.with(|f| f.get()) +} + +fn set_filter(filter: CategoryFilter) { + FILTER.with(|f| f.set(filter)); +} + +fn current_sort() -> SortMode { + SORT.with(|s| s.get()) +} + +fn cycle_sort() { + let next = match current_sort() { + SortMode::Name => SortMode::Category, + SortMode::Category => SortMode::Quantity, + SortMode::Quantity => SortMode::Name, + }; + SORT.with(|s| s.set(next)); +} + +fn matches_filter(row: &super::InventoryRow, filter: CategoryFilter) -> bool { + match filter { + CategoryFilter::All => true, + CategoryFilter::Weapon => row.kind() == super::ItemKind::Weapon, + CategoryFilter::Armor => { + if row.kind() == super::ItemKind::Gear { + let name = row.item.to_ascii_uppercase(); + name.contains("ARMOR") + || name.contains("HELM") + || name.contains("VEST") + || name.contains("CHEST") + || name.contains("GREAVE") + || name.contains("BRACER") + || name.contains("GAUNTLET") + || name.contains("SHIELD") + || name.contains("SUIT") + } else { + false + } + } + CategoryFilter::Clothing => { + if row.kind() == super::ItemKind::Gear { + let name = row.item.to_ascii_uppercase(); + !(name.contains("ARMOR") + || name.contains("HELM") + || name.contains("VEST") + || name.contains("CHEST") + || name.contains("GREAVE") + || name.contains("BRACER") + || name.contains("GAUNTLET") + || name.contains("SHIELD") + || name.contains("SUIT")) + } else { + false + } + } + CategoryFilter::Consumable => { + matches!(row.kind(), super::ItemKind::Medical | super::ItemKind::Ammo) + } + CategoryFilter::Resource => row.kind() == super::ItemKind::Resource, + CategoryFilter::Misc => { + matches!( + row.kind(), + super::ItemKind::Tool | super::ItemKind::Currency | super::ItemKind::Item + ) + } + } +} + +fn kind_sort_order(kind: super::ItemKind) -> usize { + match kind { + super::ItemKind::Weapon => 0, + super::ItemKind::Gear => 1, + super::ItemKind::Medical => 2, + super::ItemKind::Ammo => 3, + super::ItemKind::Resource => 4, + super::ItemKind::Tool => 5, + super::ItemKind::Currency => 6, + super::ItemKind::Item => 7, + } +} + +#[derive(Clone, Copy, Debug)] +pub struct InventoryLayout { + pub grid: [f32; 4], + /// Shallow footer shared by inventory status, economy, and item actions. + pub footer: [f32; 4], + /// Equipment column on the left, holding the live character doll. + pub equipment: [f32; 4], + /// The doll viewport itself, inset and aspect-held inside `equipment`. + pub preview: [f32; 4], + pub detail: [f32; 4], +} + +const GRID_GAP: f32 = 4.0; +/// Original volume-page cell is 48x48 (legacy UI layout contract). +const GRID_CARD_MIN: f32 = 48.0; +const GRID_LABEL_H: f32 = 14.0; +const GRID_MIN_COLUMNS: usize = 3; +const GRID_MAX_COLUMNS: usize = 12; +const GRID_STATUS_H: f32 = 22.0; +const SCROLLBAR_CHANNEL_W: f32 = 10.0; +const LIST_ROW_H: f32 = 25.0; +const LIST_ROW_GAP: f32 = 2.0; +const USAGE_SEGMENTS: usize = 8; +/// Equipment column share of the window, from the original's 232/468 content +/// split, with its 88 px floor and 232 px ceiling. +const EQUIPMENT_WIDTH_RATIO: f32 = 232.0 / 468.0; +const EQUIPMENT_MIN_W: f32 = 88.0; +const EQUIPMENT_MAX_W: f32 = 232.0; +/// Equipment slot cell, and the lane one rail of them occupies. The original +/// frames the paperdoll with its slots rather than overlaying them, so the +/// column has to reserve the lane before the doll claims the width. +pub const EQUIP_SLOT: f32 = 20.0; +const EQUIP_RAIL_W: f32 = EQUIP_SLOT + 8.0; +/// Doll width below which the rails are not worth their cost: the portrait +/// would read as a thumbnail. At that point the rails drop and the doll takes +/// the whole column, which is what the 250x244 resize floor gets. +const EQUIP_DOLL_MIN_W: f32 = 96.0; +/// Rails also need vertical room to space eleven cells without touching. +const EQUIP_RAIL_MIN_H: f32 = 160.0; +/// Doll viewport aspect, from the original's 225x367 paperdoll rect. +const DOLL_ASPECT: f32 = 225.0 / 367.0; + +fn current_view() -> InventoryView { + VIEW.with(|view| view.get()) +} + +fn set_view(view: InventoryView) { + VIEW.with(|current| current.set(view)); +} + +fn toggle_view() { + set_view(match current_view() { + InventoryView::IconGrid => InventoryView::CompactList, + InventoryView::CompactList => InventoryView::IconGrid, + }); +} + +fn current_page() -> usize { + match current_view() { + InventoryView::IconGrid => GRID_PAGE.with(|page| page.get()), + InventoryView::CompactList => LIST_PAGE.with(|page| page.get()), + } +} + +/// Legacy inventory geometry: an equipment column carrying the live character +/// doll owns the left of the window, the item field owns the right, and one +/// shallow footer carries status, economy, and the selected stack's actions. +/// +/// Proportions follow the original's `ui_pda_inventory.inc`: in its shipped +/// 483x450 window the equipment panel is 232 of 468 content px with an 88 px +/// floor, and the doll viewport inside it is 225x367 — a tall portrait, not a +/// thumbnail. +pub fn layout(rect: [f32; 4]) -> InventoryLayout { + let [x, y, w, h] = rect; + let gap = 7.0; + let footer_h = (h * 0.23).clamp(86.0, 100.0).min((h - 120.0).max(72.0)); + let footer_y = y + h - footer_h; + let body_h = (footer_y - gap - y).max(0.0); + + // Equipment column: the original's 232/468 share, floored at 88 so a + // narrow window keeps a usable doll, and never more than half the window. + let equipment_w = (w * EQUIPMENT_WIDTH_RATIO) + .clamp(EQUIPMENT_MIN_W, EQUIPMENT_MAX_W) + .min(w * 0.5); + let equipment = [x, y, equipment_w, body_h]; + + // The doll holds the original's 225x367 portrait aspect inside the lane + // the slot rails leave it, so resizing never stretches the character and + // the slots never overlap it. + let inset = 3.0; + let avail_w = (equipment_w - inset * 2.0).max(0.0); + let avail_h = (body_h - inset * 2.0).max(0.0); + let rails = avail_w - EQUIP_RAIL_W * 2.0 >= EQUIP_DOLL_MIN_W && avail_h >= EQUIP_RAIL_MIN_H; + let rail_w = if rails { EQUIP_RAIL_W } else { 0.0 }; + let doll_w = (avail_w - rail_w * 2.0).max(0.0); + let preview_w = doll_w.min(avail_h * DOLL_ASPECT); + let preview_h = if DOLL_ASPECT > 0.0 { + avail_h.min(doll_w / DOLL_ASPECT) + } else { + avail_h + }; + let preview = [ + x + inset + rail_w + (doll_w - preview_w) * 0.5, + y + inset + (avail_h - preview_h) * 0.5, + preview_w, + preview_h, + ]; + + let grid_x = x + equipment_w + gap; + InventoryLayout { + grid: [grid_x, y, (x + w - grid_x).max(0.0), body_h], + footer: [x, footer_y, w, footer_h], + equipment, + preview, + detail: [x, footer_y, w, footer_h], + } +} + +const FILTER_BAR_H: f32 = 20.0; + +/// The item well sits below the compact category filter row and leaves an edge channel for the scrollbar. +fn item_well(rect: [f32; 4]) -> [f32; 4] { + let [x, y, w, h] = layout(rect).grid; + [ + x, + y + FILTER_BAR_H, + (w - SCROLLBAR_CHANNEL_W).max(0.0), + (h - FILTER_BAR_H).max(0.0), + ] +} + +fn grid_columns(rect: [f32; 4]) -> usize { + let width = item_well(rect)[2]; + (((width - GRID_GAP) / (GRID_CARD_MIN + GRID_GAP)).floor() as usize) + .clamp(GRID_MIN_COLUMNS, GRID_MAX_COLUMNS) +} + +fn icon_grid_card_rect(rect: [f32; 4], index: usize) -> Option<[f32; 4]> { + let [grid_x, grid_y, grid_w, grid_h] = item_well(rect); + let columns = grid_columns(rect); + let card_size = (grid_w - GRID_GAP * (columns as f32 + 1.0)) / columns as f32; + let column = index % columns; + let row = index / columns; + let x = grid_x + GRID_GAP + column as f32 * (card_size + GRID_GAP); + let y = grid_y + GRID_GAP + row as f32 * (card_size + GRID_LABEL_H + GRID_GAP); + let height = card_size + GRID_LABEL_H; + (y + height <= grid_y + grid_h - GRID_STATUS_H).then_some([x, y, card_size, height]) +} + +fn compact_list_row_rect(rect: [f32; 4], index: usize) -> Option<[f32; 4]> { + let [grid_x, grid_y, grid_w, grid_h] = item_well(rect); + let y = grid_y + GRID_GAP + index as f32 * (LIST_ROW_H + LIST_ROW_GAP); + (y + LIST_ROW_H <= grid_y + grid_h - GRID_STATUS_H).then_some([ + grid_x + GRID_GAP, + y, + (grid_w - GRID_GAP * 2.0).max(0.0), + LIST_ROW_H, + ]) +} + +/// Visible item bounds for a page-local held-stack index in the active view. +/// The name remains stable because the live radial and item-preview hosts use +/// this shared hit geometry. +pub fn grid_card_rect(rect: [f32; 4], index: usize) -> Option<[f32; 4]> { + match current_view() { + InventoryView::IconGrid => icon_grid_card_rect(rect, index), + InventoryView::CompactList => compact_list_row_rect(rect, index), + } +} + +/// Exact square occupied by a live item model. Compact rows use atlas glyphs, +/// so they deliberately deactivate the separate 3D preview lanes. +pub fn grid_preview_rect(rect: [f32; 4], index: usize) -> Option<[f32; 4]> { + if current_view() != InventoryView::IconGrid { + return None; + } + let [x, y, width, _] = icon_grid_card_rect(rect, index)?; + Some([x + 2.0, y + 2.0, width - 4.0, width - 4.0]) +} + +pub fn grid_capacity(rect: [f32; 4]) -> usize { + let mut count = 0; + while grid_card_rect(rect, count).is_some() { + count += 1; + } + count +} + +/// Held-stack indices visible on the current inventory page. +pub fn visible_held_range(rect: [f32; 4], held_count: usize) -> (usize, usize) { + let capacity = grid_capacity(rect).max(1); + let last_page = held_count.saturating_sub(1) / capacity; + let page = current_page().min(last_page); + set_page(page); + let start = page * capacity; + (start, (start + capacity).min(held_count)) +} + +/// Copy the held-stack indices the grid is drawing right now, in draw order, +/// into `out`; returns how many were written. +/// +/// The 3D item lanes composite behind the cards, so they have to consume the +/// same filtered, sorted, paged order the 2D layer used. Re-deriving it from +/// `held()` puts a model under the wrong label the moment a filter or sort is +/// active. Caller-owned buffer: this runs every frame and must not allocate. +pub fn copy_visible_held_indices(rect: [f32; 4], out: &mut [usize]) -> usize { + let filtered = SORTED_INDICES.with(|indices| indices.borrow().len()); + let (start, end) = visible_held_range(rect, filtered); + SORTED_INDICES.with(|indices| { + let indices = indices.borrow(); + if start >= indices.len() { + return 0; + } + let slice = &indices[start..end.min(indices.len())]; + let count = slice.len().min(out.len()); + out[..count].copy_from_slice(&slice[..count]); + count + }) +} + +fn set_page(page: usize) { + match current_view() { + InventoryView::IconGrid => GRID_PAGE.with(|current| current.set(page)), + InventoryView::CompactList => LIST_PAGE.with(|current| current.set(page)), + } +} + +pub fn selected_identity() -> Option<(String, String)> { + SELECTED.with(|selected| selected.borrow().clone()) +} + +/// Make `(container, stack_id)` the live selection. The host calls this when a +/// pointer gesture outside the window picks a stack (the right-click radial +/// route), so the footer and the radial always address the same object. +pub fn select_identity(container: &str, stack_id: &str) { + SELECTED.with(|selected| { + *selected.borrow_mut() = Some((container.to_string(), stack_id.to_string())); + }); +} + +/// Clear the live selection (the picked stack left the projection). +pub fn clear_selection() { + SELECTED.with(|selected| *selected.borrow_mut() = None); +} + +pub fn selected_row(model: &WindowModel) -> Option<&super::InventoryRow> { + let (container, stack_id) = selected_identity()?; + model + .inventory + .row(&container, &stack_id) + .filter(|row| !row.in_exchange()) +} + +#[derive(Clone, Copy, Debug)] +struct FooterLayout { + info: [f32; 4], + actions: [f32; 4], + change_view: [f32; 4], +} + +fn footer_layout(rect: [f32; 4]) -> FooterLayout { + let [detail_x, detail_y, detail_w, detail_h] = layout(rect).detail; + let side = 6.0; + // The action lane owns the right edge. It contracts before the summary + // lane, so its controls cannot cover finance or the view switch at resize. + let desired_action_w = (detail_w * 0.39).clamp(116.0, 210.0); + let action_w = desired_action_w.min((detail_w - side * 3.0 - 80.0).max(0.0)); + let action_x = detail_x + detail_w - side - action_w; + let info_x = detail_x + side; + let info_w = (action_x - info_x - side).max(0.0); + let change_w = info_w.min(88.0); + FooterLayout { + info: [info_x, detail_y + 5.0, info_w, (detail_h - 10.0).max(0.0)], + actions: [ + action_x, + detail_y + 6.0, + action_w, + (detail_h - 12.0).max(0.0), + ], + change_view: [info_x + info_w - change_w, detail_y + 4.0, change_w, 18.0], + } +} + +fn action_button_rects(footer: FooterLayout) -> [[f32; 4]; 6] { + let [x, y, width, height] = footer.actions; + let gap = 4.0; + let button_w = ((width - gap) * 0.5).max(0.0); + let button_h = ((height - gap * 2.0) / 3.0).clamp(0.0, 19.0); + [ + [x, y, button_w, button_h], + [x + button_w + gap, y, button_w, button_h], + [x, y + button_h + gap, button_w, button_h], + [x + button_w + gap, y + button_h + gap, button_w, button_h], + [x, y + (button_h + gap) * 2.0, button_w, button_h], + [ + x + button_w + gap, + y + (button_h + gap) * 2.0, + button_w, + button_h, + ], + ] +} + +fn ensure_sorted_indices(model: &WindowModel) { + let filter = current_filter(); + let sort = current_sort(); + let held_rows: Vec<&super::InventoryRow> = model.inventory.held().collect(); + let count = held_rows.len(); + + let mut checksum: u64 = 0; + for (idx, row) in held_rows.iter().enumerate() { + checksum = checksum.wrapping_add((idx as u64 + 1) * 31); + checksum = checksum.wrapping_add(row.item_id as u64); + checksum = checksum.wrapping_add(row.quantity as u64); + checksum = checksum.wrapping_add(if row.equipped { 1 } else { 0 }); + } + + let key = (filter, sort, count, checksum); + SORT_CACHE_KEY.with(|cache| { + if cache.get() == key { + return; + } + cache.set(key); + SORTED_INDICES.with(|indices| { + let mut vec = indices.borrow_mut(); + vec.clear(); + for (idx, row) in held_rows.iter().enumerate() { + if matches_filter(row, filter) { + vec.push(idx); + } + } + match sort { + SortMode::Name => { + vec.sort_by(|&a, &b| { + held_rows[a] + .item + .to_ascii_uppercase() + .cmp(&held_rows[b].item.to_ascii_uppercase()) + .then_with(|| a.cmp(&b)) + }); + } + SortMode::Category => { + vec.sort_by(|&a, &b| { + kind_sort_order(held_rows[a].kind()) + .cmp(&kind_sort_order(held_rows[b].kind())) + .then_with(|| { + held_rows[a] + .item + .to_ascii_uppercase() + .cmp(&held_rows[b].item.to_ascii_uppercase()) + }) + .then_with(|| a.cmp(&b)) + }); + } + SortMode::Quantity => { + vec.sort_by(|&a, &b| { + held_rows[b] + .quantity + .cmp(&held_rows[a].quantity) + .then_with(|| { + held_rows[a] + .item + .to_ascii_uppercase() + .cmp(&held_rows[b].item.to_ascii_uppercase()) + }) + .then_with(|| a.cmp(&b)) + }); + } + } + }); + }); +} + +fn reveal_selected_stack(rect: [f32; 4], model: &WindowModel) { + let Some((container, stack_id)) = selected_identity() else { + return; + }; + ensure_sorted_indices(model); + let held_rows: Vec<&super::InventoryRow> = model.inventory.held().collect(); + let found_pos = SORTED_INDICES.with(|indices| { + indices.borrow().iter().position(|&idx| { + if idx < held_rows.len() { + let row = held_rows[idx]; + row.container == container && row.stack_id == stack_id + } else { + false + } + }) + }); + if let Some(index) = found_pos { + set_page(index / grid_capacity(rect).max(1)); + } +} + +fn match_equip_slot(row: &super::InventoryRow) -> Option<EquipSlotKind> { + if !row.equipped { + return None; + } + let kind = row.kind(); + if kind == super::ItemKind::Weapon { + return Some(EquipSlotKind::Weapon); + } + let name = row.item.to_ascii_uppercase(); + let key = row.item_key.as_deref().unwrap_or("").to_ascii_lowercase(); + + if name.contains("HEAD") + || name.contains("HELM") + || name.contains("HAT") + || name.contains("CAP") + || name.contains("HOOD") + || name.contains("CROWN") + || key.contains("head") + || key.contains("helm") + { + Some(EquipSlotKind::Head) + } else if name.contains("FACE") + || name.contains("EYES") + || name.contains("VISOR") + || name.contains("GOGGLE") + || name.contains("GLASSES") + || name.contains("MONOCLE") + || key.contains("face") + || key.contains("visor") + { + Some(EquipSlotKind::Face) + } else if name.contains("BACK") + || name.contains("CAPE") + || name.contains("CLOAK") + || name.contains("PACK") + || key.contains("back") + || key.contains("cape") + { + Some(EquipSlotKind::Back) + } else if name.contains("ARM L") + || name.contains("BICEP L") + || name.contains("LEFT ARM") + || name.contains("BRACER L") + || name.contains("SHOULDER L") + || key.contains("arm_l") + || key.contains("bicep_l") + { + Some(EquipSlotKind::ArmLeft) + } else if name.contains("ARM R") + || name.contains("BICEP R") + || name.contains("RIGHT ARM") + || name.contains("BRACER R") + || name.contains("SHOULDER R") + || key.contains("arm_r") + || key.contains("bicep_r") + { + Some(EquipSlotKind::ArmRight) + } else if name.contains("HAND") + || name.contains("GLOVE") + || name.contains("GAUNTLET") + || key.contains("glove") + || key.contains("hand") + { + Some(EquipSlotKind::Hands) + } else if name.contains("BELT") + || name.contains("WAIST") + || name.contains("BANDOLIER") + || name.contains("SASH") + || key.contains("belt") + { + Some(EquipSlotKind::Belt) + } else if name.contains("LEG") + || name.contains("PANT") + || name.contains("TROUSER") + || name.contains("GREAVE") + || name.contains("KILT") + || name.contains("SKIRT") + || key.contains("leg") + || key.contains("pant") + { + Some(EquipSlotKind::Legs) + } else if name.contains("FEET") + || name.contains("BOOT") + || name.contains("SHOE") + || key.contains("boot") + || key.contains("feet") + { + Some(EquipSlotKind::Feet) + } else { + Some(EquipSlotKind::Torso) + } +} + +fn draw_equipment_slot_rails( + ui: &mut UiBuilder, + model: &WindowModel, + icons: &Icons, + equipment_rect: [f32; 4], + preview_rect: [f32; 4], + out: &mut Vec<WindowAction>, +) { + let [eq_x, _eq_y, eq_w, _eq_h] = equipment_rect; + let [px, py, pw, ph] = preview_rect; + + let left_space = px - eq_x; + let right_space = (eq_x + eq_w) - (px + pw); + + // `layout` already reserved the lanes; this only refuses to draw when a + // caller hands over a rect the reservation could not honour. + let slot_size = EQUIP_SLOT; + if left_space < slot_size + 4.0 || right_space < slot_size + 4.0 || ph < 120.0 { + return; + } + + const LEFT_SLOTS: [EquipSlotKind; 6] = [ + EquipSlotKind::Head, + EquipSlotKind::Face, + EquipSlotKind::Torso, + EquipSlotKind::ArmLeft, + EquipSlotKind::Hands, + EquipSlotKind::Weapon, + ]; + + const RIGHT_SLOTS: [EquipSlotKind; 5] = [ + EquipSlotKind::Back, + EquipSlotKind::ArmRight, + EquipSlotKind::Belt, + EquipSlotKind::Legs, + EquipSlotKind::Feet, + ]; + + let left_x = eq_x + (left_space - slot_size) * 0.5; + let right_x = px + pw + (right_space - slot_size) * 0.5; + + let left_step = if LEFT_SLOTS.len() > 1 { + (ph - slot_size) / (LEFT_SLOTS.len() - 1) as f32 + } else { + 0.0 + }; + for (i, &slot) in LEFT_SLOTS.iter().enumerate() { + let sy = py + i as f32 * left_step; + draw_single_equip_slot(ui, model, icons, slot, [left_x, sy, slot_size], out); + } + + let right_step = if RIGHT_SLOTS.len() > 1 { + (ph - slot_size) / (RIGHT_SLOTS.len() - 1) as f32 + } else { + 0.0 + }; + for (j, &slot) in RIGHT_SLOTS.iter().enumerate() { + let sy = py + j as f32 * right_step; + draw_single_equip_slot(ui, model, icons, slot, [right_x, sy, slot_size], out); + } +} + +fn draw_single_equip_slot( + ui: &mut UiBuilder, + model: &WindowModel, + icons: &Icons, + slot: EquipSlotKind, + cell: [f32; 3], + out: &mut Vec<WindowAction>, +) { + let [sx, sy, slot_size] = cell; + let equipped_row = model + .inventory + .held() + .find(|r| r.equipped && match_equip_slot(r) == Some(slot)); + + let is_selected = equipped_row.is_some_and(|r| { + SELECTED.with(|sel| { + sel.borrow() + .as_ref() + .is_some_and(|(c, s)| c == &r.container && s == &r.stack_id) + }) + }); + + ui.rect(sx, sy, slot_size, slot_size, super::slot()); + if is_selected { + ui.border(sx, sy, slot_size, slot_size, 1.5, accent()); + } else { + ui.border(sx, sy, slot_size, slot_size, 1.0, slot_edge()); + } + + if let Some(row) = equipped_row { + let kind = row.kind(); + if let Some((col, glyph_row)) = icons.cell(kind.icon()) { + ui.icon( + col, + glyph_row, + sx + 2.0, + sy + 2.0, + slot_size - 4.0, + slot_size - 4.0, + text(), + ); + } + + let resp = ui.interact(sx, sy, slot_size, slot_size); + if resp.hovered && !is_selected { + ui.border(sx, sy, slot_size, slot_size, 1.0, super::chrome::hover()); + } + + if resp.clicked { + if is_selected { + let command = if row.kind() == super::ItemKind::Gear { + successor_net::ClientCommand::SetEquippedClothing { + item_id: row.item_id, + equipped: false, + container: Some(row.container.clone()), + stack_id: Some(row.stack_id.clone()), + variant_id: Some(row.variant_id), + } + } else { + successor_net::ClientCommand::SetEquippedWeapon { + weapon_id: None, + weapon_item_id: None, + weapon_variant_id: None, + } + }; + out.push(WindowAction::Command(command)); + } else { + select_identity(&row.container, &row.stack_id); + out.push(WindowAction::Select(row.item_id)); + } + } + } +} + +fn draw_filter_and_sort_bar( + ui: &mut UiBuilder, + grid_rect: [f32; 4], + rect: [f32; 4], + model: &WindowModel, +) { + let [grid_x, grid_y, grid_w, _grid_h] = grid_rect; + let avail_w = (grid_w - SCROLLBAR_CHANNEL_W).max(0.0); + if avail_w <= 0.0 { + return; + } + + let current_f = current_filter(); + let current_s = current_sort(); + + let sort_w = (avail_w * 0.22).clamp(50.0, 75.0); + let filter_avail_w = (avail_w - sort_w - 4.0).max(0.0); + + const FILTERS: [(CategoryFilter, &str); 7] = [ + (CategoryFilter::All, "ALL"), + (CategoryFilter::Weapon, "WEAPON"), + (CategoryFilter::Armor, "ARMOR"), + (CategoryFilter::Clothing, "CLOTHING"), + (CategoryFilter::Consumable, "CONSUMABLE"), + (CategoryFilter::Resource, "RESOURCE"), + (CategoryFilter::Misc, "MISC"), + ]; + + let tab_w = filter_avail_w / FILTERS.len() as f32; + + for (i, &(f, label)) in FILTERS.iter().enumerate() { + let tx = grid_x + i as f32 * tab_w; + let ty = grid_y; + let is_active = current_f == f; + + let style = crate::hud::button_style(); + if ui.button(tx, ty, tab_w, 18.0, label, style) && current_f != f { + set_filter(f); + set_page(0); + reveal_selected_stack(rect, model); + } + if is_active { + ui.rect(tx + 1.0, ty + 16.0, (tab_w - 2.0).max(0.0), 2.0, accent()); + } + } + + let sort_x = grid_x + avail_w - sort_w; + let sort_label = match current_s { + SortMode::Name => "SORT: NAME", + SortMode::Category => "SORT: CAT", + SortMode::Quantity => "SORT: QTY", + }; + if ui.button( + sort_x, + grid_y, + sort_w, + 18.0, + sort_label, + crate::hud::button_style(), + ) { + cycle_sort(); + } +} +fn draw_scrollbar( + ui: &mut UiBuilder, + rect: [f32; 4], + held_count: usize, + capacity: usize, + page: usize, + page_count: usize, +) { + let [grid_x, grid_y, grid_w, grid_h] = layout(rect).grid; + let channel_x = grid_x + grid_w - SCROLLBAR_CHANNEL_W; + let track_y = grid_y + FILTER_BAR_H + 3.0; + let track_h = (grid_h - FILTER_BAR_H - GRID_STATUS_H - 6.0).max(0.0); + ui.rect( + channel_x, + grid_y + FILTER_BAR_H + 1.0, + SCROLLBAR_CHANNEL_W, + track_h + 4.0, + slot(), + ); + if track_h <= 0.0 { + return; + } + let viewport_fraction = capacity as f32 / held_count.max(capacity) as f32; + let min_thumb_h = track_h.min(12.0); + let thumb_h = if held_count == 0 { + track_h + } else { + (track_h * viewport_fraction).clamp(min_thumb_h, track_h) + }; + let page_fraction = if page_count > 1 { + page as f32 / (page_count - 1) as f32 + } else { + 0.0 + }; + let thumb_y = track_y + (track_h - thumb_h).max(0.0) * page_fraction; + ui.rect( + channel_x + 3.0, + thumb_y, + (SCROLLBAR_CHANNEL_W - 5.0).max(0.0), + thumb_h, + accent(), + ); +} + +fn draw_segmented_usage_meter( + ui: &mut UiBuilder, + x: f32, + y: f32, + width: f32, + occupied: usize, + capacity: usize, +) { + if width <= 0.0 { + return; + } + let gap = (width / (USAGE_SEGMENTS as f32 * 2.0)).min(2.0); + let segment_w = ((width - gap * (USAGE_SEGMENTS - 1) as f32) / USAGE_SEGMENTS as f32).max(0.0); + let filled = if capacity == 0 { + 0 + } else { + ((occupied as f32 / capacity as f32) * USAGE_SEGMENTS as f32).ceil() as usize + }; + for index in 0..USAGE_SEGMENTS { + ui.rect( + x + index as f32 * (segment_w + gap), + y, + segment_w, + 4.0, + if index < filled { accent() } else { slot() }, + ); + } +} + +fn draw_page_navigation(ui: &mut UiBuilder, grid: [f32; 4], page: usize, page_count: usize) { + if page_count <= 1 { + return; + } + let [grid_x, grid_y, grid_w, grid_h] = grid; + let button_w = 42.0; + let gap = 4.0; + let nav_x = grid_x + (grid_w - button_w * 2.0 - gap) * 0.5; + let nav_y = grid_y + grid_h - GRID_STATUS_H + 2.0; + let nav = crate::hud::button_style(); + if ui.button(nav_x, nav_y, button_w, 18.0, "PREV", nav) && page > 0 { + set_page(page - 1); + } + if ui.button(nav_x + button_w + gap, nav_y, button_w, 18.0, "NEXT", nav) + && page + 1 < page_count + { + set_page(page + 1); + } +} + +pub fn draw( + ui: &mut UiBuilder, + rect: [f32; 4], + model: &WindowModel, + icons: &Icons, + out: &mut Vec<WindowAction>, +) { + let inv = &model.inventory; + let panes = layout(rect); + let [gx, gy, gw, gh] = panes.grid; + let view = current_view(); + + ensure_sorted_indices(model); + let filtered_count = SORTED_INDICES.with(|idx| idx.borrow().len()); + let capacity = grid_capacity(rect).max(1); + let (visible_start, visible_end) = visible_held_range(rect, filtered_count); + + super::chrome::region(ui, [gx, gy, gw, gh]); + super::chrome::region(ui, panes.equipment); + super::chrome::viewer_seat(ui, panes.preview); + + draw_equipment_slot_rails(ui, model, icons, panes.equipment, panes.preview, out); + draw_filter_and_sort_bar(ui, panes.grid, rect, model); + + let page = visible_start / capacity; + let page_count = if filtered_count == 0 { + 0 + } else { + filtered_count.saturating_sub(1) / capacity + 1 + }; + + let held_rows: Vec<&super::InventoryRow> = inv.held().collect(); + + SORTED_INDICES.with(|indices| { + let b = indices.borrow(); + let slice = if visible_start < b.len() { + &b[visible_start..visible_end.min(b.len())] + } else { + &[] + }; + + for (index, &row_idx) in slice.iter().enumerate() { + if row_idx >= held_rows.len() { + continue; + } + let row = held_rows[row_idx]; + let Some([sx, sy, card_w, card_h]) = grid_card_rect(rect, index) else { + break; + }; + let resp = ui.interact(sx, sy, card_w, card_h); + let selected = SELECTED.with(|selection| { + selection + .borrow() + .as_ref() + .is_some_and(|(container, stack_id)| { + *container == row.container && *stack_id == row.stack_id + }) + }); + if selected { + ui.rect(sx, sy, card_w, card_h, super::chrome::selected()); + } else if resp.hovered { + ui.rect(sx, sy, card_w, card_h, super::chrome::hover()); + } + if selected { + match view { + InventoryView::IconGrid => { + ui.rect(sx, sy + card_h - 2.0, card_w, 2.0, accent()); + } + InventoryView::CompactList => { + ui.rect(sx + 1.0, sy + 2.0, 2.0, (card_h - 4.0).max(0.0), accent()); + } + } + } + + let kind = row.kind(); + match view { + InventoryView::IconGrid => { + ui.text(kind_label(kind), sx + 4.0, sy + 3.0, 1.0, dim()); + let hosted = index < crate::item_preview::INVENTORY_LANES; + if let Some((column, glyph_row)) = icons.cell(kind.icon()).filter(|_| !hosted) { + let icon_size = (card_w * 0.36) + .clamp(14.0, 28.0) + .min((card_w - 20.0).max(0.0)); + let icon_top = sy + 15.0; + let icon_bottom = sy + card_w - 17.0; + ui.icon( + column, + glyph_row, + sx + (card_w - icon_size) * 0.5, + icon_top + ((icon_bottom - icon_top - icon_size) * 0.5).max(0.0), + icon_size, + icon_size, + text(), + ); + } + super::chrome::text_clipped( + ui, + &row.item, + sx + 3.0, + sy + card_w + 3.0, + 1.05, + (card_w - 6.0).max(0.0), + text(), + ); + let quantity = row.quantity.to_string(); + ui.text( + &quantity, + sx + card_w - ui.measure_text(&quantity, 1.2) - 3.0, + sy + card_w - 12.0, + 1.2, + text(), + ); + if row.equipped { + ui.rect(sx + 3.0, sy + card_w - 4.0, card_w - 6.0, 2.0, accent()); + } + if row.reserved > 0 { + ui.rect(sx + card_w - 7.0, sy + 4.0, 4.0, 4.0, [214, 138, 62, 255]); + } + } + InventoryView::CompactList => { + let icon_size = (card_h - 6.0).clamp(0.0, 18.0); + if let Some((column, glyph_row)) = icons.cell(kind.icon()) { + ui.icon( + column, + glyph_row, + sx + 5.0, + sy + (card_h - icon_size) * 0.5, + icon_size, + icon_size, + text(), + ); + } + let label_x = sx + icon_size + 10.0; + let quantity = row.quantity.to_string(); + let quantity_x = sx + card_w - ui.measure_text(&quantity, 1.1) - 6.0; + super::chrome::text_clipped( + ui, + &row.item, + label_x, + sy + 3.0, + 1.1, + (quantity_x - label_x - 6.0).max(0.0), + text(), + ); + ui.text(kind_label(kind), label_x, sy + 14.0, 0.9, dim()); + ui.text(&quantity, quantity_x, sy + 14.0, 1.1, text()); + if row.equipped { + ui.rect(sx + 4.0, sy + card_h - 3.0, 10.0, 2.0, accent()); + } + if row.reserved > 0 { + ui.rect(sx + 17.0, sy + card_h - 3.0, 4.0, 2.0, [214, 138, 62, 255]); + } + } + } + if resp.clicked { + SELECTED.with(|selection| { + *selection.borrow_mut() = Some((row.container.clone(), row.stack_id.clone())); + }); + out.push(WindowAction::Select(row.item_id)); + } + } + }); + + draw_scrollbar(ui, rect, filtered_count, capacity, page, page_count); + if filtered_count == 0 { + ui.text( + "NO HELD STACKS", + gx + 6.0, + gy + FILTER_BAR_H + 8.0, + 1.15, + dim(), + ); + } + draw_page_navigation(ui, [gx, gy, gw, gh], page, page_count); + + super::chrome::region(ui, panes.footer); + let footer = footer_layout(rect); + let view_label = match view { + InventoryView::IconGrid => "GRID", + InventoryView::CompactList => "LIST", + }; + let page_label = if page_count == 0 { + "PAGE 0/0".to_string() + } else { + format!("PAGE {}/{}", page + 1, page_count) + }; + + // InventoryModel currently exposes no container volume capacity field. + // We report held_count honestly as HELD STACKS <N>, without inventing a fake capacity. + let status = format!( + "{} {} / HELD STACKS {}", + view_label, + page_label, + inv.held().count() + ); + super::chrome::text_clipped( + ui, + &status, + footer.info[0], + footer.info[1], + 1.0, + (footer.change_view[0] - footer.info[0] - 5.0).max(0.0), + dim(), + ); + if ui.button( + footer.change_view[0], + footer.change_view[1], + footer.change_view[2], + footer.change_view[3], + "CHANGE VIEW", + crate::hud::button_style(), + ) { + toggle_view(); + reveal_selected_stack(rect, model); + } + draw_segmented_usage_meter( + ui, + footer.info[0], + footer.info[1] + 15.0, + (footer.info[2] * 0.45).clamp(0.0, 96.0), + inv.held().count(), + inv.held().count().max(1), + ); + let bank = model + .bank + .bank + .as_ref() + .map(|snapshot| snapshot.credits.to_string()) + .unwrap_or_else(|| "--".to_string()); + let finance = format!("CASH {} CR BANK {}", inv.credits, bank); + super::chrome::text_clipped( + ui, + &finance, + footer.info[0], + footer.info[1] + 27.0, + 1.0, + footer.info[2], + accent(), + ); + let wielded = format!("WIELD {}", inv.weapon_label.as_deref().unwrap_or("--")); + super::chrome::text_clipped( + ui, + &wielded, + footer.info[0], + footer.info[1] + 40.0, + 1.0, + footer.info[2], + text(), + ); + + let current_selected_row = selected_row(model); + + // Keep split stepper in sync with selection + let is_split_armed = current_selected_row.as_ref().is_some_and(|row| { + SPLIT_STEPPER.with(|s| { + s.borrow().as_ref().is_some_and(|st| { + st.container == row.container && st.stack_id == row.stack_id && row.available > 1 + }) + }) + }); + + if !is_split_armed && current_selected_row.as_ref().is_none() { + SPLIT_STEPPER.with(|s| *s.borrow_mut() = None); + } + + match current_selected_row { + Some(row) => { + super::chrome::text_clipped( + ui, + &row.item, + footer.info[0], + footer.info[1] + 53.0, + 1.15, + footer.info[2], + text(), + ); + + let mut details = format!("{} / QTY {}", kind_label(row.kind()), row.quantity); + if let Some(potency) = row.potency { + details.push_str(&format!(" / POT {}", potency)); + } + if let Some(purity) = row.purity { + details.push_str(&format!(" / PUR {}", purity)); + } + + if is_split_armed { + let stepper_qty = + SPLIT_STEPPER.with(|s| s.borrow().as_ref().map(|st| st.quantity).unwrap_or(1)); + details = format!( + "SPLIT STACK / QTY {} / MAX {}", + stepper_qty, + row.available - 1 + ); + } + + super::chrome::text_clipped( + ui, + &details, + footer.info[0], + footer.info[1] + 67.0, + 0.95, + footer.info[2], + dim(), + ); + + let button = crate::hud::button_style(); + let buttons = action_button_rects(footer); + + if is_split_armed { + let max_split = (row.available - 1).max(1) as u32; + let mut stepper_qty = SPLIT_STEPPER + .with(|s| s.borrow().as_ref().map(|st| st.quantity).unwrap_or(1)) + .clamp(1, max_split); + let step = 1u32; + + let [minus_x, minus_y, minus_w, minus_h] = buttons[0]; + if ui.button(minus_x, minus_y, minus_w, minus_h, "-", button) { + stepper_qty = stepper_qty.saturating_sub(step).clamp(1, max_split); + SPLIT_STEPPER.with(|s| { + if let Some(st) = s.borrow_mut().as_mut() { + st.quantity = stepper_qty; + } + }); + } + + let [plus_x, plus_y, plus_w, plus_h] = buttons[1]; + if ui.button(plus_x, plus_y, plus_w, plus_h, "+", button) { + stepper_qty = (stepper_qty + step).clamp(1, max_split); + SPLIT_STEPPER.with(|s| { + if let Some(st) = s.borrow_mut().as_mut() { + st.quantity = stepper_qty; + } + }); + } + + let [half_x, half_y, half_w, half_h] = buttons[2]; + if ui.button(half_x, half_y, half_w, half_h, "HALF", button) { + stepper_qty = ((row.available / 2).max(1) as u32).min(max_split); + SPLIT_STEPPER.with(|s| { + if let Some(st) = s.borrow_mut().as_mut() { + st.quantity = stepper_qty; + } + }); + } + + let [all_x, all_y, all_w, all_h] = buttons[3]; + if ui.button(all_x, all_y, all_w, all_h, "ALL", button) { + stepper_qty = max_split; + SPLIT_STEPPER.with(|s| { + if let Some(st) = s.borrow_mut().as_mut() { + st.quantity = stepper_qty; + } + }); + } + + let [confirm_x, confirm_y, confirm_w, confirm_h] = buttons[4]; + if ui.button( + confirm_x, confirm_y, confirm_w, confirm_h, "CONFIRM", button, + ) { + out.push(WindowAction::Command( + successor_net::ClientCommand::SplitStack { + container: row.container.clone(), + stack_id: row.stack_id.clone(), + item_id: row.item_id, + variant_id: row.variant_id, + quantity: stepper_qty, + }, + )); + SPLIT_STEPPER.with(|s| *s.borrow_mut() = None); + } + + let [cancel_x, cancel_y, cancel_w, cancel_h] = buttons[5]; + if ui.button(cancel_x, cancel_y, cancel_w, cancel_h, "CANCEL", button) { + SPLIT_STEPPER.with(|s| *s.borrow_mut() = None); + } + } else { + let [use_x, use_y, use_w, use_h] = buttons[0]; + if ui.button(use_x, use_y, use_w, use_h, "USE", button) { + let command = if row.is_credit_chip() { + successor_net::ClientCommand::RedeemCreditChip { + container: row.container.clone(), + stack_id: row.stack_id.clone(), + } + } else if row.kind() == super::ItemKind::Ammo { + successor_net::ClientCommand::RefillAmmo { + item_id: row + .item_key + .clone() + .unwrap_or_else(|| row.item_id.to_string()), + } + } else { + successor_net::ClientCommand::UseConsumable { + item_id: row + .item_key + .clone() + .unwrap_or_else(|| row.item_id.to_string()), + item_numeric_id: Some(row.item_id), + variant_id: Some(row.variant_id), + } + }; + out.push(WindowAction::Command(command)); + } + let [equip_x, equip_y, equip_w, equip_h] = buttons[1]; + let equip = if row.equipped { "UNEQUIP" } else { "EQUIP" }; + if ui.button(equip_x, equip_y, equip_w, equip_h, equip, button) { + let command = if row.kind() == super::ItemKind::Gear { + successor_net::ClientCommand::SetEquippedClothing { + item_id: row.item_id, + equipped: !row.equipped, + container: Some(row.container.clone()), + stack_id: Some(row.stack_id.clone()), + variant_id: Some(row.variant_id), + } + } else { + successor_net::ClientCommand::SetEquippedWeapon { + weapon_id: None, + weapon_item_id: (!row.equipped).then_some(row.item_id), + weapon_variant_id: (!row.equipped).then_some(row.variant_id), + } + }; + out.push(WindowAction::Command(command)); + } + let [drop_x, drop_y, drop_w, drop_h] = buttons[2]; + if ui.button(drop_x, drop_y, drop_w, drop_h, "DISCARD", button) { + out.push(WindowAction::Command( + successor_net::ClientCommand::DiscardStack { + container: row.container.clone(), + stack_id: row.stack_id.clone(), + item_id: row.item_id, + variant_id: row.variant_id, + }, + )); + } + let [split_x, split_y, split_w, split_h] = buttons[3]; + if row.available > 1 + && ui.button(split_x, split_y, split_w, split_h, "SPLIT", button) + { + let max_split = (row.available - 1).max(1) as u32; + let initial_qty = ((row.available / 2).max(1) as u32).min(max_split); + SPLIT_STEPPER.with(|s| { + *s.borrow_mut() = Some(SplitStepperState { + container: row.container.clone(), + stack_id: row.stack_id.clone(), + quantity: initial_qty, + }); + }); + } + let [merge_x, merge_y, merge_w, merge_h] = buttons[4]; + if let Some(target) = inv.held().find(|other| { + other.stack_id != row.stack_id + && other.container == row.container + && other.item_id == row.item_id + && other.variant_id == row.variant_id + }) { + if ui.button(merge_x, merge_y, merge_w, merge_h, "MERGE", button) { + out.push(WindowAction::Command( + successor_net::ClientCommand::MergeStacks { + container: row.container.clone(), + source_stack_id: row.stack_id.clone(), + target_stack_id: target.stack_id.clone(), + }, + )); + } + } + let [store_x, store_y, store_w, store_h] = buttons[5]; + if row.available > 0 + && ui.button(store_x, store_y, store_w, store_h, "STORE", button) + { + out.push(WindowAction::Command( + successor_net::ClientCommand::StoreToExchange { + item_id: row.item_id, + variant_id: row.variant_id, + quantity: row.available as u32, + }, + )); + } + } + } + None => { + ui.text( + "SELECT AN ITEM", + footer.info[0], + footer.info[1] + 53.0, + 1.15, + dim(), + ); + ui.text( + "DETAILS AND ACTIONS", + footer.info[0], + footer.info[1] + 67.0, + 0.95, + dim(), + ); + } + } +} + +fn kind_label(kind: super::ItemKind) -> &'static str { + match kind { + super::ItemKind::Weapon => "WPN", + super::ItemKind::Ammo => "AMMO", + super::ItemKind::Medical => "MED", + super::ItemKind::Resource => "RES", + super::ItemKind::Tool => "TOOL", + super::ItemKind::Gear => "GEAR", + super::ItemKind::Currency => "CR", + super::ItemKind::Item => "ITEM", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::windows::model::{InventoryRow, EXCHANGE_CONTAINER}; + + /// Explicit test fixture — `WindowModel::sample()` is intentionally empty + /// so demo/test state can never masquerade as a live projection. + fn fixture() -> WindowModel { + let mut m = WindowModel::sample(); + m.inventory.credits = 250; + m.inventory.rows = vec![ + InventoryRow { + container: "player".into(), + stack_id: "1".into(), + item: "Field Stim".into(), + item_id: 11, + quantity: 3, + available: 3, + ..Default::default() + }, + InventoryRow { + container: "player".into(), + stack_id: "2".into(), + item: "Slugthrower Pistol".into(), + item_id: 12, + quantity: 1, + available: 1, + equipped: true, + ..Default::default() + }, + InventoryRow { + container: EXCHANGE_CONTAINER.into(), + stack_id: "9".into(), + item: "Copper Ore".into(), + item_id: 13, + quantity: 40, + available: 40, + ..Default::default() + }, + ]; + m + } + + fn reset_ui_state() { + set_view(InventoryView::IconGrid); + set_page(0); + set_filter(CategoryFilter::All); + SORT.with(|s| s.set(SortMode::Name)); + SORT_CACHE_KEY.with(|k| k.set((CategoryFilter::All, SortMode::Name, 0, 0))); + SPLIT_STEPPER.with(|s| *s.borrow_mut() = None); + clear_selection(); + } + + /// 600x400 content geometry is the minimum visual-regression surface. + const RECT: [f32; 4] = [100.0, 100.0, 600.0, 400.0]; + + fn click( + ui: &mut UiBuilder, + model: &WindowModel, + icons: &Icons, + cx: f32, + cy: f32, + ) -> Vec<WindowAction> { + ui.set_input(cx, cy, true); + ui.begin(1280, 720); + let mut out = Vec::new(); + draw(ui, RECT, model, icons, &mut out); + ui.set_input(cx, cy, false); + ui.begin(1280, 720); + out.clear(); + draw(ui, RECT, model, icons, &mut out); + out + } + + #[test] + fn click_selects_then_use_emits_typed_stack_command() { + reset_ui_state(); + let icons = Icons::load(); + let model = fixture(); + let mut ui = UiBuilder::new(icons.meta); + // Select the first held stack (Field Stim, item_id 11). + let [x, y, w, h] = grid_card_rect(RECT, 0).expect("first grid card"); + let out = click(&mut ui, &model, &icons, x + w * 0.5, y + h * 0.5); + assert!( + out.contains(&WindowAction::Select(11)), + "clicking slot 0 selects the stack, got {out:?}" + ); + let [button_x, button_y, button_w, button_h] = action_button_rects(footer_layout(RECT))[0]; + let out = click( + &mut ui, + &model, + &icons, + button_x + button_w * 0.5, + button_y + button_h * 0.5, + ); + assert!( + out.contains(&WindowAction::Command( + successor_net::ClientCommand::UseConsumable { + item_id: "11".into(), + item_numeric_id: Some(11), + variant_id: Some(0), + } + )), + "USE emits the complete typed stack command, got {out:?}" + ); + } + + #[test] + fn exchange_rows_stay_out_of_the_grid() { + reset_ui_state(); + let icons = Icons::load(); + let model = fixture(); + let mut ui = UiBuilder::new(icons.meta); + // Two held rows occupy columns 0 and 1; column 2 is empty. Exchange + // stock must not leak into that third card. + let [x, y, w, h] = grid_card_rect(RECT, 2).expect("third grid card"); + let out = click(&mut ui, &model, &icons, x + w * 0.5, y + h * 0.5); + assert!( + out.is_empty(), + "exchange stockpile row must not render in the held grid, got {out:?}" + ); + } + + #[test] + fn approved_weapon_item_ids_classify_as_weapons() { + for (item_id, item) in [ + (3101, "Slugthrower"), + (3103, "Vibrosword"), + (3104, "Plasma Sword"), + (3105, "Scrapline Machete"), + (3106, "Field Saber"), + (3107, "Quarry Chopper"), + (3111, "STEN Mk II"), + (3112, "Kiln Energy Cell Carbine"), + (3121, "Lightning Carbine"), + (3122, "Badge Bolt Pistol"), + (3123, "Slagrail Vanguard"), + (3124, "Coilgate Scatter"), + (3125, "Kiln Long Pattern"), + (3126, "Bastion LMG"), + (3127, "Flare Net Launcher"), + ] { + let row = InventoryRow { + item_id, + item: item.into(), + ..Default::default() + }; + assert_eq!(row.kind(), crate::windows::ItemKind::Weapon, "{item_id}"); + } + } + + #[test] + fn last_weapon_page_equips_its_concrete_item_id() { + reset_ui_state(); + let icons = Icons::load(); + let mut model = WindowModel::sample(); + let weapon_ids = [ + 3101, 3103, 3104, 3105, 3106, 3107, 3111, 3112, 3121, 3122, 3123, 3124, 3125, 3126, + 3127, + ]; + model.inventory.rows = weapon_ids + .iter() + .enumerate() + .map(|(index, item_id)| InventoryRow { + container: "player:field-pack".into(), + stack_id: format!("weapon-{index}"), + item: format!("Weapon {item_id}"), + item_id: *item_id, + quantity: 1, + available: 1, + ..Default::default() + }) + .collect(); + let capacity = grid_capacity(RECT); + let target_index = weapon_ids.len() - 1; + set_page(target_index / capacity); + let local_index = target_index % capacity; + let [x, y, w, h] = grid_card_rect(RECT, local_index).expect("target grid card"); + let mut ui = UiBuilder::new(icons.meta); + let selected = click(&mut ui, &model, &icons, x + w * 0.5, y + h * 0.5); + assert!(selected.contains(&WindowAction::Select(3127))); + + let [button_x, button_y, button_w, button_h] = action_button_rects(footer_layout(RECT))[1]; + let equipped = click( + &mut ui, + &model, + &icons, + button_x + button_w * 0.5, + button_y + button_h * 0.5, + ); + assert!(equipped.contains(&WindowAction::Command( + successor_net::ClientCommand::SetEquippedWeapon { + weapon_id: None, + weapon_item_id: Some(3127), + weapon_variant_id: Some(0), + } + ))); + reset_ui_state(); + } + + #[test] + fn pagination_exposes_every_held_stack() { + reset_ui_state(); + let capacity = grid_capacity(RECT); + assert!(capacity > 0); + let held_count = capacity + 2; + assert_eq!(visible_held_range(RECT, held_count), (0, capacity)); + set_page(1); + assert_eq!(visible_held_range(RECT, held_count), (capacity, held_count)); + set_page(usize::MAX); + assert_eq!( + visible_held_range(RECT, held_count), + (capacity, held_count), + "page clamps after inventory changes" + ); + set_page(0); + } + + #[test] + fn empty_model_renders_without_actions() { + reset_ui_state(); + let icons = Icons::load(); + let model = WindowModel::sample(); + let mut ui = UiBuilder::new(icons.meta); + let [x, y, w, h] = grid_card_rect(RECT, 0).expect("first grid card"); + let out = click(&mut ui, &model, &icons, x + w * 0.5, y + h * 0.5); + assert!( + out.is_empty(), + "empty projection emits nothing, got {out:?}" + ); + } + + #[test] + fn change_view_toggles_compact_list_with_selectable_items() { + reset_ui_state(); + let icons = Icons::load(); + let model = fixture(); + let mut ui = UiBuilder::new(icons.meta); + let [x, y, w, h] = footer_layout(RECT).change_view; + let _ = click(&mut ui, &model, &icons, x + w * 0.5, y + h * 0.5); + assert_eq!(current_view(), InventoryView::CompactList); + + let [row_x, row_y, row_w, row_h] = grid_card_rect(RECT, 0).expect("first compact-list row"); + assert_eq!(row_h, LIST_ROW_H); + let out = click( + &mut ui, + &model, + &icons, + row_x + row_w * 0.5, + row_y + row_h * 0.5, + ); + assert!( + out.contains(&WindowAction::Select(11)), + "compact-list row keeps the same typed selection, got {out:?}" + ); + + let [x, y, w, h] = footer_layout(RECT).change_view; + let _ = click(&mut ui, &model, &icons, x + w * 0.5, y + h * 0.5); + assert_eq!(current_view(), InventoryView::IconGrid); + reset_ui_state(); + } + + #[test] + fn visual_sample_is_explicit_and_sample_remains_empty() { + let empty = WindowModel::sample(); + assert!(!empty.connected); + assert!(empty.inventory.rows.is_empty()); + assert_eq!(empty.inventory.credits, 0); + assert!(empty.inventory.weapon_label.is_none()); + assert!(empty.bank.bank.is_none()); + + let visual = WindowModel::visual_sample(); + let held = visual.inventory.held().collect::<Vec<_>>(); + assert!( + !visual.connected, + "visual data must not claim a live session" + ); + assert!( + (8..=10).contains(&held.len()), + "visual fixture needs representative held stacks, got {}", + held.len() + ); + assert_eq!(held.first().map(|row| row.item_id), Some(3101)); + assert!(held.iter().all(|row| !row.in_exchange())); + assert_eq!(visual.inventory.credits, 2_750); + assert_eq!( + visual.inventory.weapon_label.as_deref(), + Some("SLUGTHROWER PISTOL") + ); + assert_eq!( + visual.bank.bank.as_ref().map(|snapshot| snapshot.credits), + Some(18_450) + ); + } + + #[test] + fn footer_lanes_stay_separate_at_supported_sizes() { + for rect in [RECT, [100.0, 100.0, 660.0, 521.0]] { + let footer = footer_layout(rect); + assert!( + footer.change_view[0] >= footer.info[0] + && footer.change_view[0] + footer.change_view[2] + <= footer.info[0] + footer.info[2] + ); + assert!( + footer.info[0] + footer.info[2] + 6.0 <= footer.actions[0], + "summary/view lane must finish before actions at {rect:?}" + ); + for button in action_button_rects(footer) { + assert!( + button[0] >= footer.actions[0] + && button[0] + button[2] <= footer.actions[0] + footer.actions[2] + && button[1] >= footer.actions[1] + && button[1] + button[3] <= footer.actions[1] + footer.actions[3], + "action button escaped its reserved lane at {rect:?}: {button:?}" + ); + } + } + } + #[test] + fn slot_click_selects_and_second_slot_click_unequips() { + reset_ui_state(); + let icons = Icons::load(); + let mut model = WindowModel::sample(); + model.inventory.rows = vec![InventoryRow { + container: "player:pack".into(), + stack_id: "vest-1".into(), + item: "Armor Vest".into(), + item_id: 2001, + quantity: 1, + available: 1, + equipped: true, + ..Default::default() + }]; + let mut ui = UiBuilder::new(icons.meta); + + let panes = layout(RECT); + let slot_size = 20.0; + let left_space = panes.preview[0] - panes.equipment[0]; + let slot_x = panes.equipment[0] + (left_space - slot_size) * 0.5; + let left_step = (panes.preview[3] - slot_size) / 5.0; + let slot_y = panes.preview[1] + 2.0 * left_step; + + let out = click( + &mut ui, + &model, + &icons, + slot_x + slot_size * 0.5, + slot_y + slot_size * 0.5, + ); + assert!( + out.contains(&WindowAction::Select(2001)), + "first slot click selects stack" + ); + assert_eq!( + selected_identity(), + Some(("player:pack".into(), "vest-1".into())) + ); + + let out = click( + &mut ui, + &model, + &icons, + slot_x + slot_size * 0.5, + slot_y + slot_size * 0.5, + ); + assert!( + out.contains(&WindowAction::Command( + successor_net::ClientCommand::SetEquippedClothing { + item_id: 2001, + equipped: false, + container: Some("player:pack".into()), + stack_id: Some("vest-1".into()), + variant_id: Some(0), + } + )), + "second click unequips stack, got {out:?}" + ); + reset_ui_state(); + } + + #[test] + fn filter_narrows_rows() { + reset_ui_state(); + let icons = Icons::load(); + let mut model = WindowModel::sample(); + model.inventory.rows = vec![ + InventoryRow { + container: "player".into(), + stack_id: "w1".into(), + item: "Pistol".into(), + item_id: 3101, + quantity: 1, + available: 1, + ..Default::default() + }, + InventoryRow { + container: "player".into(), + stack_id: "r1".into(), + item: "Copper Ore".into(), + item_id: 501, + quantity: 10, + available: 10, + resource_stats: Some(Default::default()), + ..Default::default() + }, + ]; + let mut ui = UiBuilder::new(icons.meta); + + ui.begin(1280, 720); + let mut out = Vec::new(); + draw(&mut ui, RECT, &model, &icons, &mut out); + assert_eq!(SORTED_INDICES.with(|i| i.borrow().len()), 2); + + set_filter(CategoryFilter::Weapon); + ui.begin(1280, 720); + draw(&mut ui, RECT, &model, &icons, &mut out); + assert_eq!(SORTED_INDICES.with(|i| i.borrow().len()), 1); + + set_filter(CategoryFilter::Resource); + ui.begin(1280, 720); + draw(&mut ui, RECT, &model, &icons, &mut out); + assert_eq!(SORTED_INDICES.with(|i| i.borrow().len()), 1); + + reset_ui_state(); + } + + #[test] + fn sort_reorders_deterministically() { + reset_ui_state(); + let icons = Icons::load(); + let mut model = WindowModel::sample(); + model.inventory.rows = vec![ + InventoryRow { + container: "player".into(), + stack_id: "s1".into(), + item: "Zebra Skin".into(), + item_id: 1, + quantity: 5, + ..Default::default() + }, + InventoryRow { + container: "player".into(), + stack_id: "s2".into(), + item: "Alpha Fiber".into(), + item_id: 2, + quantity: 100, + ..Default::default() + }, + ]; + let mut ui = UiBuilder::new(icons.meta); + + SORT.with(|s| s.set(SortMode::Name)); + ui.begin(1280, 720); + let mut out = Vec::new(); + draw(&mut ui, RECT, &model, &icons, &mut out); + SORTED_INDICES.with(|i| { + assert_eq!(*i.borrow(), vec![1, 0]); + }); + + SORT.with(|s| s.set(SortMode::Quantity)); + ui.begin(1280, 720); + draw(&mut ui, RECT, &model, &icons, &mut out); + SORTED_INDICES.with(|i| { + assert_eq!(*i.borrow(), vec![1, 0]); + }); + + reset_ui_state(); + } + + #[test] + fn split_stepper_clamps_and_emits_exact_quantity() { + reset_ui_state(); + let icons = Icons::load(); + let mut model = WindowModel::sample(); + model.inventory.rows = vec![InventoryRow { + container: "player:pack".into(), + stack_id: "stim-stack".into(), + item: "Field Stim".into(), + item_id: 101, + quantity: 10, + available: 10, + ..Default::default() + }]; + let mut ui = UiBuilder::new(icons.meta); + + select_identity("player:pack", "stim-stack"); + ui.begin(1280, 720); + let mut out = Vec::new(); + draw(&mut ui, RECT, &model, &icons, &mut out); + + let buttons = action_button_rects(footer_layout(RECT)); + let [split_x, split_y, split_w, split_h] = buttons[3]; + let _ = click( + &mut ui, + &model, + &icons, + split_x + split_w * 0.5, + split_y + split_h * 0.5, + ); + + let armed_qty = SPLIT_STEPPER.with(|s| s.borrow().as_ref().map(|st| st.quantity)); + assert_eq!(armed_qty, Some(5)); + + let _ = click( + &mut ui, + &model, + &icons, + split_x + split_w * 0.5, + split_y + split_h * 0.5, + ); + let armed_qty = SPLIT_STEPPER.with(|s| s.borrow().as_ref().map(|st| st.quantity)); + assert_eq!(armed_qty, Some(9)); + + let [confirm_x, confirm_y, confirm_w, confirm_h] = buttons[4]; + let out = click( + &mut ui, + &model, + &icons, + confirm_x + confirm_w * 0.5, + confirm_y + confirm_h * 0.5, + ); + assert!( + out.contains(&WindowAction::Command( + successor_net::ClientCommand::SplitStack { + container: "player:pack".into(), + stack_id: "stim-stack".into(), + item_id: 101, + variant_id: 0, + quantity: 9, + } + )), + "CONFIRM emits SplitStack with exact quantity, got {out:?}" + ); + assert!(SPLIT_STEPPER.with(|s| s.borrow().is_none())); + + reset_ui_state(); + } + + #[test] + fn min_size_frame_draws_without_panicking_and_without_overlapping_lanes() { + reset_ui_state(); + let icons = Icons::load(); + let model = fixture(); + let min_rect: [f32; 4] = [0.0, 0.0, 250.0, 244.0]; + let mut ui = UiBuilder::new(icons.meta); + ui.begin(1280, 720); + let mut out = Vec::new(); + draw(&mut ui, min_rect, &model, &icons, &mut out); + + let footer = footer_layout(min_rect); + assert!(footer.info[0] + footer.info[2] <= footer.actions[0] + 0.1); + assert!(footer.actions[2] >= 0.0); + + reset_ui_state(); + } +} diff --git a/client-rust/source/app/src/windows/live/bank.rs b/client-rust/source/app/src/windows/live/bank.rs new file mode 100644 index 00000000..fe61aeea --- /dev/null +++ b/client-rust/source/app/src/windows/live/bank.rs @@ -0,0 +1,251 @@ +use std::cell::Cell; + +use crate::windows::chrome::{self, Rows}; +use crate::windows::live::shared::*; +use crate::windows::spec::Metrics; +use crate::windows::{Ctx, WindowAction, WindowModel}; +use successor_engine_render::ui::UiBuilder; +use successor_net::ClientCommand; + +thread_local! { + static BANK_STEP_AMOUNT: Cell<u64> = const { Cell::new(100) }; +} + +pub fn bank(ui: &mut UiBuilder, ctx: Ctx, model: &WindowModel, out: &mut Vec<WindowAction>) { + let mut pane = Pane::open(ctx); + if !model.bank.gate.available { + let note = if model.bank.gate.note.is_empty() { + "AT BANK TERMINAL ONLY" + } else { + &model.bank.gate.note + }; + pane.denied(ui, note); + return; + } + let Some(bank) = &model.bank.bank else { + pane.empty(ui, "BANK STATE UNAVAILABLE"); + return; + }; + pane.field_pair( + ui, + ("WALLET", &format!("{} CR", model.inventory.credits)), + ("VAULT", &format!("{} CR", bank.credits)), + ); + let cur_amt = BANK_STEP_AMOUNT.with(|cell| cell.get()); + if let Some(index) = pane.rail( + ui, + &[ + &format!("DEPOSIT {cur_amt} CR"), + &format!("WITHDRAW {cur_amt} CR"), + ], + ) { + if index == 0 { + let deposit_max = model.inventory.credits.max(0) as u64; + let amount = cur_amt.min(deposit_max); + if amount > 0 { + out.push(WindowAction::Command(ClientCommand::BankDepositCredits { + amount, + })); + } + } else { + let withdraw_max = bank.credits.max(0) as u64; + let amount = cur_amt.min(withdraw_max); + if amount > 0 { + out.push(WindowAction::Command(ClientCommand::BankWithdrawCredits { + amount, + })); + } + } + } + if let Some(index) = pane.rail(ui, &["STEP 100", "STEP 1,000", "STEP 10,000", "MAX WALLET"]) { + let new_amt = match index { + 0 => 100, + 1 => 1_000, + 2 => 10_000, + 3 => model.inventory.credits.max(0) as u64, + _ => 100, + }; + BANK_STEP_AMOUNT.with(|cell| cell.set(new_amt.max(1))); + } + let backup = pane.reserve_footer(); + let mut any = false; + if ctx.tab == 0 { + pane.section(ui, "CARRIED INVENTORY"); + let mut rows = pane.rows(); + for row in model.inventory.held() { + any = true; + let Some(mut list) = rows.next(ui) else { break }; + if row.available > 0 && list.action(ui, "STORE") { + out.push(WindowAction::Command(ClientCommand::BankStoreItem { + source_stack_id: row.stack_id.clone(), + quantity: row.available as u32, + })); + } + list.value(ui, &qty(row.available)); + list.label(ui, &row.item); + } + if !any { + chrome::empty(ui, pane.x, rows.cursor(), "NOTHING HELD"); + } + } else { + pane.section(ui, "VAULT ITEMS"); + let mut rows = pane.rows(); + for row in &bank.items { + any = true; + let Some(mut list) = rows.next(ui) else { break }; + if row.quantity > 0 && list.action(ui, "RETRIEVE") { + out.push(WindowAction::Command(ClientCommand::BankRetrieveItem { + bank_stack_id: row.stack_id.clone(), + quantity: row.quantity as u32, + })); + } + list.value(ui, &qty(row.quantity)); + list.label(ui, &row.item); + } + if !any { + chrome::empty(ui, pane.x, rows.cursor(), "VAULT EMPTY"); + } + } + if pane + .footer(ui, backup, &["SAVE CLONE SKILL BACKUP"]) + .is_some() + { + out.push(WindowAction::Command( + ClientCommand::CloneSaveSkillBackup {}, + )); + } +} + +/// District exchange holdings. Drawn as the datapad's DATA pane, so it opens on +/// a section rule and inherits the datapad's metrics rather than resolving a +/// surface of its own. +pub fn exchange( + ui: &mut UiBuilder, + rect: [f32; 4], + metrics: Metrics, + model: &WindowModel, + out: &mut Vec<WindowAction>, +) { + let [x, y, w, h] = rect; + let w = w.max(0.0); + let top = chrome::section(ui, x, y, w, "EXCHANGE", metrics); + let mut rows = Rows::new([x, top, w, (y + h - top).max(0.0)], metrics); + let mut any = false; + for row in model.inventory.exchange().take(10) { + any = true; + let Some(mut list) = rows.next(ui) else { break }; + if row.quantity > 0 && list.action(ui, "RETRIEVE") { + out.push(WindowAction::Command(ClientCommand::RetrieveFromExchange { + item_id: row.item_id, + variant_id: row.variant_id, + quantity: row.quantity as u32, + })); + } + list.value(ui, &qty(row.quantity)); + list.label(ui, &row.item); + } + if !any { + chrome::empty(ui, x, rows.cursor(), "EXCHANGE EMPTY"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hud::Icons; + use crate::windows::model::{BankSnapshot, Gate, InventoryRow}; + + fn test_ctx(rect: [f32; 4], tab: usize) -> Ctx { + Ctx { + spec: crate::windows::spec::surface("bank").expect("bank surface"), + rect, + tab, + } + } + + const RECT: [f32; 4] = [100.0, 100.0, 400.0, 500.0]; + + #[test] + fn test_bank_denied_when_gate_closed() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let mut model = WindowModel::default(); + model.bank.gate = Gate::closed("AT BANK TERMINAL ONLY"); + + let ctx = test_ctx(RECT, 0); + let mut out = Vec::new(); + bank(&mut ui, ctx, &model, &mut out); + + assert!(out.is_empty()); + } + + #[test] + fn test_bank_empty_when_no_snapshot() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let mut model = WindowModel::default(); + model.bank.gate = Gate::open("bank_terminal_1"); + model.bank.bank = None; + + let ctx = test_ctx(RECT, 0); + let mut out = Vec::new(); + bank(&mut ui, ctx, &model, &mut out); + + assert!(out.is_empty()); + } + + #[test] + fn test_bank_deposit_withdraw_store_retrieve() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let mut model = WindowModel::default(); + model.bank.gate = Gate::open("bank_terminal_1"); + model.inventory.credits = 5000; + model.inventory.rows = vec![InventoryRow { + container: "inventory".to_string(), + stack_id: "inv_1".to_string(), + item: "HEAVY AMMO".to_string(), + quantity: 50, + available: 50, + ..Default::default() + }]; + model.bank.bank = Some(BankSnapshot { + credits: 2000, + items: vec![InventoryRow { + container: "bank".to_string(), + stack_id: "bank_1".to_string(), + item: "MEDKIT".to_string(), + quantity: 10, + available: 10, + ..Default::default() + }], + ..Default::default() + }); + + // Press DEPOSIT button on tab 0 + let ctx = test_ctx(RECT, 0); + let bx = RECT[0] + 10.0; + let by = RECT[1] + 30.0; + ui.set_input(bx, by, true); + ui.begin(1280, 900); + let mut out = Vec::new(); + bank(&mut ui, ctx, &model, &mut out); + + ui.set_input(bx, by, false); + ui.begin(1280, 900); + out.clear(); + bank(&mut ui, ctx, &model, &mut out); + + assert!( + out.contains(&WindowAction::Command(ClientCommand::BankDepositCredits { + amount: 100 + })) + ); + + // Test tab 1 retrieve + let ctx_tab1 = test_ctx(RECT, 1); + ui.begin(1280, 900); + out.clear(); + bank(&mut ui, ctx_tab1, &model, &mut out); + } +} diff --git a/client-rust/source/app/src/windows/live/builder.rs b/client-rust/source/app/src/windows/live/builder.rs new file mode 100644 index 00000000..b0824d68 --- /dev/null +++ b/client-rust/source/app/src/windows/live/builder.rs @@ -0,0 +1,592 @@ +//! Debug character builder, reached by conversing with GR0K. +//! +//! Replaces the legacy MMO "blue frog" character-builder flow, which is +//! a single-select nested list: one click, one grant, back to the top. Kitting a +//! character out takes dozens of round trips. This one pages the same kind of +//! tree but every leaf is a checkbox, so a tester ticks what they want across a +//! page and commits the lot in one action. +//! +//! Compiled only under `dev-tools`. The commands it emits are refused by any +//! authority that does not have `GAME_DEBUG_AUTHORITY_COMMANDS` set, so this is +//! belt and braces rather than the only gate. + +use std::cell::RefCell; + +use crate::windows::chrome::{self, Rows}; +use crate::windows::spec::Metrics; +use crate::windows::{accent, dim, value, WindowAction}; +use successor_engine_render::ui::UiBuilder; +use successor_net::ClientCommand; + +/// Generated from the authority's own tables. See +/// `crates/successor-sim/src/bin/emit_debug_catalog.rs`; a hygiene gate fails +/// when this file drifts from the runtime. +const CATALOG_JSON: &str = + include_str!("../../../../../../tools/codegen/generated/debug-catalog.generated.json"); + +pub struct CatalogItem { + pub id: u32, + pub name: String, +} + +pub struct CatalogSkill { + pub id: String, + pub title: String, + pub profession: String, +} + +pub struct CatalogPack { + pub label: String, + pub items: Vec<u32>, +} + +#[derive(Default)] +pub struct Catalog { + pub items: Vec<CatalogItem>, + pub skills: Vec<CatalogSkill>, + pub packs: Vec<CatalogPack>, + pub professions: Vec<String>, + pub categories: Vec<&'static str>, +} + +/// Item id bands, which the authority allocates deliberately: consumables at +/// 1_000, resource containers at 2_000, tools at 3_000, weapons from 3_100, +/// schematics at 5_000, seeds at 6_000. Deriving the grouping from the band +/// keeps the menu organised without a second table to maintain. +fn category_of(id: u32) -> &'static str { + match id { + 1_000..=1_099 => "CONSUMABLE", + 1_100..=1_199 => "AMMO", + 1_200..=1_999 => "COMPONENT", + 2_000..=2_099 => "RESOURCE", + 2_100..=2_999 => "HARVEST", + 3_000..=3_099 => "TOOL", + 3_100..=3_199 => "WEAPON", + 3_200..=4_999 => "EQUIPMENT", + 5_000..=5_999 => "SCHEMATIC", + 6_000..=6_999 => "SEED", + _ => "OTHER", + } +} + +fn parse_catalog() -> Catalog { + let Ok(root) = serde_json::from_str::<serde_json::Value>(CATALOG_JSON) else { + return Catalog::default(); + }; + let mut catalog = Catalog::default(); + for entry in root["items"].as_array().into_iter().flatten() { + let (Some(id), Some(name)) = (entry["id"].as_u64(), entry["name"].as_str()) else { + continue; + }; + catalog.items.push(CatalogItem { + id: id as u32, + name: name.to_ascii_uppercase(), + }); + } + for entry in root["skillBoxes"].as_array().into_iter().flatten() { + let (Some(id), Some(title), Some(profession)) = ( + entry["id"].as_str(), + entry["title"].as_str(), + entry["profession"].as_str(), + ) else { + continue; + }; + catalog.skills.push(CatalogSkill { + id: id.to_owned(), + title: title.to_ascii_uppercase(), + profession: profession.to_owned(), + }); + } + for entry in root["packs"].as_array().into_iter().flatten() { + let Some(label) = entry["label"].as_str() else { + continue; + }; + catalog.packs.push(CatalogPack { + label: label.to_ascii_uppercase(), + items: entry["items"] + .as_array() + .into_iter() + .flatten() + .filter_map(|v| v.as_u64().map(|v| v as u32)) + .collect(), + }); + } + for skill in &catalog.skills { + if !catalog.professions.contains(&skill.profession) { + catalog.professions.push(skill.profession.clone()); + } + } + for item in &catalog.items { + let category = category_of(item.id); + if !catalog.categories.contains(&category) { + catalog.categories.push(category); + } + } + catalog +} + +/// Which page the builder is showing. The stack is one level deep by design: +/// a tester should never be lost, and a breadcrumb plus BACK beats a tree. +#[derive(Clone, PartialEq, Eq)] +pub enum Page { + Root, + Packs, + Items(&'static str), + ItemCategories, + Professions, + Skills(String), + Credits, +} + +pub struct BuilderState { + pub catalog: Catalog, + pub page: Page, + pub items: Vec<u32>, + pub skills: Vec<String>, + pub credits: i64, + pub last_result: Option<String>, + /// Whether the builder has taken over the reply column. + pub opened: bool, +} + +impl BuilderState { + fn new() -> Self { + Self { + catalog: parse_catalog(), + page: Page::Root, + items: Vec::new(), + skills: Vec::new(), + credits: 10_000, + last_result: None, + opened: false, + } + } + + fn toggle_item(&mut self, id: u32) { + match self.items.iter().position(|held| *held == id) { + Some(index) => { + self.items.remove(index); + } + None => self.items.push(id), + } + } + + fn toggle_skill(&mut self, id: &str) { + match self.skills.iter().position(|held| held == id) { + Some(index) => { + self.skills.remove(index); + } + None => self.skills.push(id.to_owned()), + } + } + + pub fn selected(&self) -> usize { + self.items.len() + self.skills.len() + } + + /// Emit one command per ticked item and ONE command carrying every ticked + /// skill - `DebugGrantSkillBoxes` already takes a list, so a whole + /// profession is a single round trip rather than twenty. + fn commit(&mut self, out: &mut Vec<WindowAction>) { + for id in &self.items { + out.push(WindowAction::Command(ClientCommand::DebugGiveItem { + item_id: *id, + variant_id: 0, + quantity: 1, + equip: false, + })); + } + if !self.skills.is_empty() { + out.push(WindowAction::Command(ClientCommand::DebugGrantSkillBoxes { + skill_box_ids: self.skills.clone(), + })); + } + self.last_result = Some(format!( + "GRANTED {} ITEM(S), {} SKILL(S)", + self.items.len(), + self.skills.len() + )); + self.items.clear(); + self.skills.clear(); + } +} + +thread_local! { + static STATE: RefCell<BuilderState> = RefCell::new(BuilderState::new()); +} + +/// Run `f` against the retained builder state. Exposed for tests, which drive +/// the same state the pane does rather than a parallel copy. +pub fn with_state<R>(f: impl FnOnce(&mut BuilderState) -> R) -> R { + STATE.with(|state| f(&mut state.borrow_mut())) +} + +/// Draw the builder into `rect`. Returns false when the player asked to leave, +/// so the caller can hand the column back to ordinary dialogue. +pub fn draw( + ui: &mut UiBuilder, + rect: [f32; 4], + metrics: Metrics, + out: &mut Vec<WindowAction>, +) -> bool { + with_state(|state| draw_state(state, ui, rect, metrics, out)) +} + +fn draw_state( + state: &mut BuilderState, + ui: &mut UiBuilder, + rect: [f32; 4], + metrics: Metrics, + out: &mut Vec<WindowAction>, +) -> bool { + let [x, y, w, h] = rect; + let mut open = true; + + // Breadcrumb: the tester always knows where they are and how to get out. + let crumb = match &state.page { + Page::Root => "BUILDER".to_string(), + Page::Packs => "BUILDER / PACKS".to_string(), + Page::ItemCategories => "BUILDER / ITEMS".to_string(), + Page::Items(category) => format!("BUILDER / ITEMS / {category}"), + Page::Professions => "BUILDER / SKILLS".to_string(), + Page::Skills(profession) => { + format!("BUILDER / SKILLS / {}", profession.to_ascii_uppercase()) + } + Page::Credits => "BUILDER / CREDITS".to_string(), + }; + ui.text( + &crumb, + x + metrics.gutter, + y + 2.0, + metrics.caption_px, + dim(), + ); + + let selected = state.selected(); + if selected > 0 { + let tally = format!("{selected} SELECTED"); + let tw = ui.measure_text(&tally, metrics.caption_px); + ui.text( + &tally, + x + w - tw - metrics.gutter, + y + 2.0, + metrics.caption_px, + accent(), + ); + } + + // The commit rail is reserved before the list so a long page can never + // push GRANT off the bottom. + let rail_h = metrics.row_h + 4.0; + let list_y = y + metrics.caption_px * 7.0 + 8.0; + let list_h = (h - (list_y - y) - rail_h).max(0.0); + let mut rows = Rows::new([x, list_y, w, list_h], metrics); + + match state.page.clone() { + Page::Root => { + for (title, page) in [ + ("PACKS", Page::Packs), + ("ITEMS", Page::ItemCategories), + ("SKILLS", Page::Professions), + ("CREDITS", Page::Credits), + ] { + let Some(row) = rows.next(ui) else { break }; + row.label(ui, title); + if row.clicked(ui) { + state.page = page; + } + } + if let Some(row) = rows.next(ui) { + row.label(ui, "CLOSE BUILDER"); + if row.clicked(ui) { + open = false; + } + } + if let Some(result) = &state.last_result { + if let Some(row) = rows.next(ui) { + row.label_tinted(ui, result, accent()); + } + } + } + Page::Packs => { + for pack in &state.catalog.packs { + let Some(mut row) = rows.next(ui) else { break }; + let ticked = pack.items.iter().all(|id| state.items.contains(id)); + row.value(ui, &format!("{} ITEMS", pack.items.len())); + row.label(ui, &format!("{} {}", tick(ticked), pack.label)); + if row.clicked(ui) { + for id in &pack.items { + let held = state.items.contains(id); + if ticked == held { + // Ticking a pack adds all of it; unticking removes + // exactly what the pack put there. + toggle(&mut state.items, *id); + } + } + } + } + } + Page::ItemCategories => { + for category in state.catalog.categories.clone() { + let Some(mut row) = rows.next(ui) else { break }; + let count = state + .catalog + .items + .iter() + .filter(|item| category_of(item.id) == category) + .count(); + row.value(ui, &count.to_string()); + row.label(ui, category); + if row.clicked(ui) { + state.page = Page::Items(category); + } + } + } + Page::Items(category) => { + let ids: Vec<(u32, String)> = state + .catalog + .items + .iter() + .filter(|item| category_of(item.id) == category) + .map(|item| (item.id, item.name.clone())) + .collect(); + for (id, name) in ids { + let Some(mut row) = rows.next(ui) else { break }; + let ticked = state.items.contains(&id); + row.value(ui, &id.to_string()); + row.label(ui, &format!("{} {name}", tick(ticked))); + if row.clicked(ui) { + state.toggle_item(id); + } + } + } + Page::Professions => { + for profession in state.catalog.professions.clone() { + let Some(row) = rows.next(ui) else { break }; + row.label(ui, &profession.to_ascii_uppercase()); + if row.clicked(ui) { + state.page = Page::Skills(profession); + } + } + } + Page::Skills(profession) => { + let boxes: Vec<(String, String)> = state + .catalog + .skills + .iter() + .filter(|skill| skill.profession == profession) + .map(|skill| (skill.id.clone(), skill.title.clone())) + .collect(); + // Whole-profession tick first: the common case is "give me this + // profession", not twenty individual boxes. + if let Some(row) = rows.next(ui) { + let all = boxes.iter().all(|(id, _)| state.skills.contains(id)); + row.label_tinted(ui, &format!("{} ENTIRE PROFESSION", tick(all)), accent()); + if row.clicked(ui) { + for (id, _) in &boxes { + let held = state.skills.contains(id); + if all == held { + toggle_str(&mut state.skills, id); + } + } + } + } + for (id, title) in boxes { + let Some(row) = rows.next(ui) else { break }; + let ticked = state.skills.contains(&id); + row.label(ui, &format!("{} {title}", tick(ticked))); + if row.clicked(ui) { + state.toggle_skill(&id); + } + } + } + Page::Credits => { + if let Some(mut row) = rows.next(ui) { + row.value(ui, &format!("{} CR", state.credits)); + row.label(ui, "AMOUNT"); + } + for amount in [1_000i64, 10_000, 100_000, 1_000_000] { + let Some(row) = rows.next(ui) else { break }; + row.label(ui, &format!("SET {amount} CR")); + if row.clicked(ui) { + state.credits = amount; + } + } + if let Some(row) = rows.next(ui) { + row.label_tinted(ui, "GIVE CREDITS", accent()); + if row.clicked(ui) { + out.push(WindowAction::Command(ClientCommand::DebugGiveCredits { + amount: state.credits, + })); + state.last_result = Some(format!("GAVE {} CR", state.credits)); + } + } + if let Some(row) = rows.next(ui) { + row.label_tinted(ui, "DRAIN WALLET", dim()); + if row.clicked(ui) { + out.push(WindowAction::Command(ClientCommand::DebugGiveCredits { + amount: -1_000_000_000, + })); + state.last_result = Some("WALLET DRAINED".to_string()); + } + } + } + } + + // Commit rail. + let rail_y = y + h - rail_h; + ui.rect(x, rail_y, w, 1.0, chrome::hairline()); + let mut rail = Rows::new([x, rail_y + 2.0, w, rail_h], metrics); + if let Some(mut row) = rail.next(ui) { + if state.page != Page::Root && row.quiet_action(ui, "BACK") { + state.page = match state.page { + Page::Items(_) => Page::ItemCategories, + Page::Skills(_) => Page::Professions, + _ => Page::Root, + }; + } + if selected > 0 && row.action(ui, "GRANT SELECTED") { + state.commit(out); + } + let tally = if selected > 0 { + format!("{selected} TICKED") + } else { + "NOTHING SELECTED".to_string() + }; + row.label_tinted(ui, &tally, if selected > 0 { value() } else { dim() }); + } + open +} + +fn tick(on: bool) -> &'static str { + if on { + "[X]" + } else { + "[ ]" + } +} + +fn toggle(list: &mut Vec<u32>, id: u32) { + match list.iter().position(|held| *held == id) { + Some(index) => { + list.remove(index); + } + None => list.push(id), + } +} + +fn toggle_str(list: &mut Vec<String>, id: &str) { + match list.iter().position(|held| held == id) { + Some(index) => { + list.remove(index); + } + None => list.push(id.to_owned()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fresh() -> BuilderState { + let mut state = BuilderState::new(); + state.page = Page::Root; + state.items.clear(); + state.skills.clear(); + state + } + + #[test] + fn the_catalog_carries_the_runtime_tables() { + let state = fresh(); + assert!( + state.catalog.items.len() > 100, + "expected the generated item table, got {}", + state.catalog.items.len() + ); + assert!(!state.catalog.skills.is_empty()); + assert!(!state.catalog.packs.is_empty()); + assert!(state.catalog.professions.len() >= 5); + } + + #[test] + fn selection_accumulates_and_commit_clears_it() { + let mut state = fresh(); + state.toggle_item(3008); + state.toggle_item(3001); + state.toggle_skill("marksman-novice"); + assert_eq!(state.selected(), 3); + + state.toggle_item(3001); + assert_eq!(state.selected(), 2, "a second tick must untick"); + + let mut out = Vec::new(); + state.commit(&mut out); + assert_eq!(state.selected(), 0, "commit clears the basket"); + assert!(state.last_result.is_some()); + } + + #[test] + fn skills_commit_as_one_command_carrying_every_ticked_id() { + let mut state = fresh(); + state.toggle_skill("marksman-novice"); + state.toggle_skill("marksman-rifle-i"); + state.toggle_skill("marksman-rifle-ii"); + + let mut out = Vec::new(); + state.commit(&mut out); + + let grants: Vec<&WindowAction> = out + .iter() + .filter(|action| { + matches!( + action, + WindowAction::Command(ClientCommand::DebugGrantSkillBoxes { .. }) + ) + }) + .collect(); + assert_eq!(grants.len(), 1, "three skills must be ONE round trip"); + let WindowAction::Command(ClientCommand::DebugGrantSkillBoxes { skill_box_ids }) = + grants[0] + else { + panic!("expected a skill grant"); + }; + assert_eq!(skill_box_ids.len(), 3); + } + + #[test] + fn items_commit_one_command_each() { + let mut state = fresh(); + state.toggle_item(3008); + state.toggle_item(3001); + + let mut out = Vec::new(); + state.commit(&mut out); + + let ids: Vec<u32> = out + .iter() + .filter_map(|action| match action { + WindowAction::Command(ClientCommand::DebugGiveItem { item_id, .. }) => { + Some(*item_id) + } + _ => None, + }) + .collect(); + assert_eq!(ids, vec![3008, 3001]); + } + + #[test] + fn every_pack_references_a_real_item() { + let state = fresh(); + for pack in &state.catalog.packs { + assert!(!pack.items.is_empty(), "{} is empty", pack.label); + for id in &pack.items { + assert!( + state.catalog.items.iter().any(|item| item.id == *id), + "{} references unknown item {id}", + pack.label + ); + } + } + } +} diff --git a/client-rust/source/app/src/windows/live/clone.rs b/client-rust/source/app/src/windows/live/clone.rs new file mode 100644 index 00000000..840f222a --- /dev/null +++ b/client-rust/source/app/src/windows/live/clone.rs @@ -0,0 +1,163 @@ +use crate::windows::live::shared::*; +use crate::windows::{Ctx, WindowAction, WindowModel}; +use successor_engine_render::ui::UiBuilder; +use successor_net::ClientCommand; + +pub fn clone_terminal( + ui: &mut UiBuilder, + ctx: Ctx, + model: &WindowModel, + out: &mut Vec<WindowAction>, +) { + let mut pane = Pane::open(ctx); + let clone = &model.clone; + if !clone.gate.available { + pane.denied(ui, &clone.gate.note); + return; + } + let commit = pane.reserve_footer(); + heading(&mut pane, ui, "CLONING TERMINAL"); + pane.field( + ui, + "STATUS", + if clone.backup_present { + "BACKUP ON FILE" + } else { + "NO BACKUP ON FILE" + }, + ); + pane.field_pair( + ui, + ("SKILLS SAVED", &clone.backup_skill_count.to_string()), + ("BACKUP COST", &format!("{} CR", clone.backup_cost)), + ); + pane.field_pair( + ui, + ("VAULT BALANCE", &format!("{} CR", clone.vault_credits)), + ("WALLET BALANCE", &format!("{} CR", clone.wallet_credits)), + ); + if clone.clone_sickness_remaining_ms > 0 { + pane.field( + ui, + "SICKNESS", + &format!("{}s", clone.clone_sickness_remaining_ms / 1000), + ); + } + + pane.y += 4.0; + heading(&mut pane, ui, "CLONING TERMS"); + pane.field(ui, "RESTORES", "PROFESSIONS, XP AND SKILLS"); + pane.field(ui, "FUNDS", "VAULT PAYS FIRST - WALLET COVERS SHORTFALL"); + + let save_label = if clone.backup_present { + "UPDATE SKILL BACKUP" + } else { + "SAVE SKILL BACKUP" + }; + let labels: &[&str] = if clone.dead { + &[save_label, "RESPAWN FROM CLONE"] + } else { + &[save_label] + }; + if let Some(index) = pane.footer(ui, commit, labels) { + let command = if index == 0 { + ClientCommand::CloneSaveSkillBackup {} + } else { + ClientCommand::CloneRespawn { + facility_id: clone.gate.prop_id.clone(), + } + }; + out.push(WindowAction::Command(command)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hud::Icons; + use crate::windows::model::Gate; + use crate::windows::spec; + + fn test_ctx(rect: [f32; 4]) -> Ctx { + Ctx { + spec: spec::surface("clone").expect("clone spec"), + rect, + tab: 0, + } + } + + #[test] + fn test_clone_gate_closed() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let ctx = test_ctx([0.0, 0.0, 400.0, 300.0]); + let mut model = WindowModel::default(); + model.clone.gate = Gate::closed("CLONE TERMINAL OUT OF REACH"); + let mut out = Vec::new(); + clone_terminal(&mut ui, ctx, &model, &mut out); + assert!(out.is_empty()); + } + + #[test] + fn test_clone_save_backup_dispatch() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let ctx = test_ctx([0.0, 0.0, 400.0, 300.0]); + let mut model = WindowModel::default(); + model.clone.gate = Gate::open("clone_term_1"); + model.clone.backup_present = false; + model.clone.backup_cost = 1000; + model.clone.vault_credits = 5000; + + let button_y = ctx.rect[1] + ctx.rect[3] - 12.0; + let button_x = ctx.rect[0] + ctx.rect[2] * 0.5; + + // Frame 1: Press + ui.set_input(button_x, button_y, true); + ui.begin(1280, 720); + let mut out = Vec::new(); + clone_terminal(&mut ui, ctx, &model, &mut out); + + // Frame 2: Release + ui.set_input(button_x, button_y, false); + ui.begin(1280, 720); + out.clear(); + clone_terminal(&mut ui, ctx, &model, &mut out); + assert_eq!(out.len(), 1); + assert!(matches!( + &out[0], + WindowAction::Command(ClientCommand::CloneSaveSkillBackup {}) + )); + } + + #[test] + fn test_clone_respawn_dispatch() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let ctx = test_ctx([0.0, 0.0, 400.0, 300.0]); + let mut model = WindowModel::default(); + model.clone.gate = Gate::open("clone_term_1"); + model.clone.dead = true; + let button_y = ctx.rect[1] + ctx.rect[3] - 12.0; + let right_button_x = ctx.rect[0] + ctx.rect[2] * 0.75; + + // Frame 1: Press + ui.set_input(right_button_x, button_y, true); + ui.begin(1280, 720); + let mut out = Vec::new(); + clone_terminal(&mut ui, ctx, &model, &mut out); + + // Frame 2: Release + ui.set_input(right_button_x, button_y, false); + ui.begin(1280, 720); + out.clear(); + clone_terminal(&mut ui, ctx, &model, &mut out); + + assert_eq!(out.len(), 1); + assert!(matches!( + &out[0], + WindowAction::Command(ClientCommand::CloneRespawn { facility_id }) + if facility_id.as_deref() == Some("clone_term_1") + )); + } +} diff --git a/client-rust/source/app/src/windows/live/converse.rs b/client-rust/source/app/src/windows/live/converse.rs new file mode 100644 index 00000000..f1f68f56 --- /dev/null +++ b/client-rust/source/app/src/windows/live/converse.rs @@ -0,0 +1,223 @@ +use crate::windows::chrome::{self, Rows}; +use crate::windows::live::shared::*; +use crate::windows::{dim, label, Ctx, WindowAction, WindowModel}; +use successor_engine_render::ui::UiBuilder; +use successor_net::ClientCommand; + +pub fn converse(ui: &mut UiBuilder, ctx: Ctx, model: &WindowModel, out: &mut Vec<WindowAction>) { + let metrics = ctx.metrics(); + let [x, y, w, h] = ctx.rect; + // The debug builder owns the whole pane once opened - it is a page of + // choices, not a dialogue reply, and squeezing it beside the portrait makes + // it unusable. It is also reachable with no dialogue target: GR0K is a + // scripted player rather than a trainer, so the authority never names him a + // converse subject, and a debug tool you can only open by first finding a + // trainer is a debug tool nobody reaches. + #[cfg(feature = "dev-tools")] + if super::builder::with_state(|state| state.opened) { + if !super::builder::draw(ui, ctx.rect, metrics, out) { + super::builder::with_state(|state| state.opened = false); + } + return; + } + let Some(npc) = &model.converse.npc else { + chrome::empty(ui, x, y, "NO DIALOGUE TARGET"); + #[cfg(feature = "dev-tools")] + { + let mut rows = Rows::new([x, y + metrics.row_h, w, h - metrics.row_h], metrics); + if let Some(row) = rows.next(ui) { + row.label_tinted(ui, "CHARACTER BUILDER", crate::windows::accent()); + if row.clicked(ui) { + super::builder::with_state(|state| state.opened = true); + } + } + } + return; + }; + let preview = converse_preview_rect(ctx.rect); + chrome::region(ui, preview); + chrome::viewer_seat(ui, preview); + let name_w = ui.measure_text(&npc.name, metrics.label_px); + let caption_y = preview[1] + preview[3] + 6.0; + chrome::text_clipped( + ui, + &npc.name, + x + ((preview[2] - name_w) * 0.5).max(0.0), + caption_y, + metrics.label_px, + preview[2], + label(), + ); + let role_text = if npc.profession_id.is_empty() { + "TRAINER".to_string() + } else { + npc.profession_id.to_ascii_uppercase() + }; + let role_w = ui.measure_text(&role_text, metrics.caption_px); + ui.text( + &role_text, + x + ((preview[2] - role_w) * 0.5).max(0.0), + caption_y + metrics.label_px * 7.0 + 4.0, + metrics.caption_px, + dim(), + ); + + // Prose and replies share the column beside the portrait, so the viewer + // keeps its full height at every frame size. + let column_x = x + preview[2] + 10.0; + let column_w = (w - preview[2] - 10.0).max(0.0); + let prose_h = (h * 0.28).clamp(metrics.row_h * 2.0, 120.0); + chrome::region(ui, [column_x, y, column_w, prose_h]); + let scrollback; + let (body, tint) = if model.converse.deliveries.is_empty() { + ("State your business.", dim()) + } else { + scrollback = model + .converse + .deliveries + .iter() + .rev() + .take(4) + .collect::<Vec<_>>() + .into_iter() + .rev() + .map(|d| format!("{}: {}", d.speaker.to_ascii_uppercase(), d.body)) + .collect::<Vec<_>>() + .join("\n"); + (scrollback.as_str(), label()) + }; + prose( + ui, + body, + column_x + metrics.gutter, + y + 6.0, + (column_w - metrics.gutter * 2.0).max(0.0), + y + prose_h - 4.0, + metrics.label_px, + tint, + ); + + let replies_y = y + prose_h + 8.0; + let mut rows = Rows::new( + [column_x, replies_y, column_w, (y + h - replies_y).max(0.0)], + metrics, + ); + + let mut number = 1usize; + for (goal_id, label) in &model.converse.career_goals { + let active = model.converse.career_goal_id.as_deref() == Some(goal_id.as_str()); + let Some(row) = rows.next_selected(ui, active) else { + return; + }; + let text = if active { + format!("{number}. ACTIVE CAREER GOAL: {label}") + } else { + format!("{number}. SET CAREER: {label}") + }; + row.label(ui, &text); + if row.clicked(ui) && !active { + out.push(WindowAction::Command(ClientCommand::SetCareerGoal { + goal_id: goal_id.clone(), + trainer_actor_id: npc.actor_id.clone(), + })); + } + number += 1; + } + for skill in &model.converse.teachable { + let Some(row) = rows.next(ui) else { return }; + row.label(ui, &format!("{number}. LEARN {}", skill.label)); + if row.clicked(ui) { + out.push(WindowAction::Command(ClientCommand::PurchaseSkillBox { + skill_box_id: skill.id.clone(), + trainer_actor_id: npc.actor_id.clone(), + })); + } + number += 1; + } + if let Some(row) = rows.next(ui) { + row.label(ui, &format!("{number}. REQUEST STARTER TOOL")); + if row.clicked(ui) { + out.push(WindowAction::Command(ClientCommand::RequestStarterTool { + trainer_actor_id: npc.actor_id.clone(), + })); + } + } +} + +/// NPC viewer rect. Anchored at the content origin — the CONVERSE spec declares +/// `header: false` and no tabs, so the body rect and the frame content rect +/// coincide and `connected_scene` places the live viewport from the same call. +pub fn converse_preview_rect(rect: [f32; 4]) -> [f32; 4] { + let [x, y, _, _] = rect; + [x, y, 82.0, 136.0] +} +#[cfg(test)] +mod tests { + use super::*; + use crate::hud::Icons; + use crate::windows::model::{DialogueDelivery, SkillBoxView, TrainerView}; + use crate::windows::spec; + + fn test_ctx(rect: [f32; 4]) -> Ctx { + Ctx { + spec: spec::surface("converse").expect("converse spec"), + rect, + tab: 0, + } + } + + #[test] + fn test_converse_empty_state() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let ctx = test_ctx([0.0, 0.0, 400.0, 300.0]); + let model = WindowModel::default(); + let mut out = Vec::new(); + converse(&mut ui, ctx, &model, &mut out); + assert!(out.is_empty()); + } + + #[test] + fn test_converse_options_rendering_and_dispatch() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let ctx = test_ctx([0.0, 0.0, 500.0, 400.0]); + let mut model = WindowModel::default(); + model.converse.npc = Some(TrainerView { + actor_id: "npc_trainer_1".into(), + name: "Master Instructor".into(), + profession_id: "combat".into(), + in_range: true, + }); + model.converse.deliveries = vec![DialogueDelivery { + actor_id: "npc_trainer_1".into(), + speaker: "Master Instructor".into(), + body: "Welcome novice.".into(), + tick: 100, + }]; + model.converse.career_goals = vec![("scout_path".into(), "Scout".into())]; + model.converse.teachable = vec![SkillBoxView { + id: "marksman_1".into(), + label: "Marksman I".into(), + ..Default::default() + }]; + + // Frame 1: Mouse press on row 1 (career goal) + ui.set_input(150.0, 130.0, true); + ui.begin(1280, 720); + let mut out = Vec::new(); + converse(&mut ui, ctx, &model, &mut out); + + // Frame 2: Mouse release -> click triggered + ui.set_input(150.0, 130.0, false); + ui.begin(1280, 720); + out.clear(); + converse(&mut ui, ctx, &model, &mut out); + + assert_eq!(out.len(), 1); + assert!(matches!( + &out[0], + WindowAction::Command(ClientCommand::SetCareerGoal { goal_id, .. }) if goal_id == "scout_path" + )); + } +} diff --git a/client-rust/source/app/src/windows/live/craft.rs b/client-rust/source/app/src/windows/live/craft.rs new file mode 100644 index 00000000..666cbaa1 --- /dev/null +++ b/client-rust/source/app/src/windows/live/craft.rs @@ -0,0 +1,563 @@ +//! CRAFT — portable field crafting surface. +//! +//! Five-stage crafting flow matching the web client (SCHEMATIC -> LOAD -> ASSEMBLE -> TUNE -> FINISH). +//! +//! Web reference: `client-3d/src/ui/crafting/craftWindow.ts` +//! Stages: +//! 1. SCHEMATIC: recipe browser, category/name filter, required tools, begin command. +//! 2. LOAD: component slots showing required vs loaded stacks, assign/clear controls. +//! 3. ASSEMBLE: assembly gate check, assemble command. +//! 4. TUNE: property experimentation lines, remaining point pool, +1 tuning. +//! 5. FINISH: final quality readout, custom item naming, prototype/practice/draft exits. + +use std::cell::RefCell; + +use crate::windows::chrome::{self}; +use crate::windows::live::shared::*; +use crate::windows::{dim, label, Ctx, WindowAction, WindowModel}; +use successor_engine_render::ui::{TextField, UiBuilder}; +use successor_net::ClientCommand; + +thread_local! { + static CRAFT_FILTER: RefCell<TextField> = RefCell::new(TextField::new(32)); + static CRAFT_NAME: RefCell<TextField> = RefCell::new(TextField::new(48)); + static SELECTED_STAGE: RefCell<Option<usize>> = const { RefCell::new(None) }; +} + +#[cfg(test)] +pub(crate) fn reset_ui_state() { + SELECTED_STAGE.with(|s| *s.borrow_mut() = None); + CRAFT_FILTER.with(|f| f.borrow_mut().clear()); + CRAFT_NAME.with(|f| f.borrow_mut().clear()); +} + +pub fn craft(ui: &mut UiBuilder, ctx: Ctx, model: &WindowModel, out: &mut Vec<WindowAction>) { + let mut pane = Pane::open(ctx); + let cancel = if model.craft.session.is_some() { + pane.reserve_footer() + } else { + None + }; + + // Determine auto stage from streamed session state. + let auto_stage = match &model.craft.session { + None => 0, + Some(session) => match session.phase.as_str() { + "browse" => 0, + "slots" => { + let can = session + .slot_screen + .as_ref() + .map(|s| s.can_assemble) + .unwrap_or(false); + if can { + 2 + } else { + 1 + } + } + "assembled" => 3, + _ => 0, + }, + }; + + // Stage rail header: SCHEMATIC (0), LOAD (1), ASSEMBLE (2), TUNE (3), FINISH (4). + let rail_labels = ["1:SCHEMATIC", "2:LOAD", "3:ASSEMBLE", "4:TUNE", "5:FINISH"]; + + if let Some(clicked) = pane.rail(ui, &rail_labels) { + SELECTED_STAGE.with(|s| *s.borrow_mut() = Some(clicked)); + } + + let active_stage = SELECTED_STAGE.with(|s| *s.borrow()).unwrap_or(auto_stage); + + if let Some(session) = &model.craft.session { + pane.field_pair( + ui, + ("PHASE", &session.phase.to_ascii_uppercase()), + ( + "RECIPE", + session.recipe_id.as_deref().unwrap_or("NONE SELECTED"), + ), + ); + } + + match active_stage { + // ── Stage 1: SCHEMATIC ──────────────────────────────────────────────── + 0 => { + pane.section(ui, "FILTER PATTERNS"); + let filter_y = pane.y; + CRAFT_FILTER.with(|field| { + ui.text_field( + &mut field.borrow_mut(), + pane.x, + filter_y, + pane.w, + chrome::FIELD_H, + 1.0, + true, + crate::hud::button_style(), + ); + }); + pane.y += chrome::FIELD_H + 6.0; + + pane.section(ui, "SCHEMATICS"); + let filter_text = CRAFT_FILTER.with(|f| f.borrow().text.to_ascii_uppercase()); + + if let Some(session) = &model.craft.session { + let mut rows = pane.rows(); + let mut match_count = 0; + let mut any = false; + + for recipe in &session.recipes { + any = true; + let matches_filter = filter_text.is_empty() + || recipe.name.to_ascii_uppercase().contains(&filter_text) + || recipe.category.to_ascii_uppercase().contains(&filter_text); + if !matches_filter { + continue; + } + match_count += 1; + + let Some(mut row) = rows.next(ui) else { break }; + if recipe.unlocked { + if row.action(ui, "BEGIN") { + out.push(WindowAction::Command(ClientCommand::CraftBegin { + recipe_id: recipe.recipe_id.clone(), + })); + } + let display = format!("[{}] {}", recipe.category, recipe.name); + row.label_tinted(ui, &display, label()); + } else { + let display = format!("[LOCKED] {}", recipe.name); + row.label_tinted(ui, &display, dim()); + } + } + + if !any { + chrome::empty(ui, pane.x, rows.cursor(), "NO SCHEMATICS KNOWN"); + } else if match_count == 0 { + chrome::empty(ui, pane.x, rows.cursor(), "NO PATTERNS MATCH FILTER"); + } + pane.resume(&rows); + } else { + pane.empty(ui, "NO ACTIVE CRAFT SESSION - START BENCH"); + } + + if model.craft.session.is_none() { + if let Some(trainer_actor_id) = &model.craft.trainer_actor_id { + if pane.rail(ui, &["REQUEST STARTER TOOL"]).is_some() { + out.push(WindowAction::Command(ClientCommand::RequestStarterTool { + trainer_actor_id: trainer_actor_id.clone(), + })); + } + } + } + } + + // ── Stage 2: LOAD (Component Slots) ────────────────────────────────── + 1 => { + let session = model.craft.session.as_ref(); + let screen = session.and_then(|s| s.slot_screen.as_ref()); + + if let Some(screen) = screen { + pane.section(ui, "COMPONENT SLOTS"); + let mut rows = pane.rows(); + let mut any = false; + + for slot in &screen.slots { + any = true; + let Some(mut row) = rows.next(ui) else { break }; + + if slot.assigned.is_some() { + if row.quiet_action(ui, "CLEAR") { + out.push(WindowAction::Command(ClientCommand::CraftClearSlot { + slot_index: slot.slot_index, + })); + } + row.value(ui, "LOADED"); + let req_name = slot.required_item_name.as_deref().unwrap_or(""); + let desc = if req_name.is_empty() { + format!("{} x{}", slot.symbol, slot.required_qty) + } else { + format!("{} ({}) x{}", slot.symbol, req_name, slot.required_qty) + }; + row.label_tinted(ui, &desc, label()); + } else { + let best = slot + .eligible + .iter() + .find(|resource| resource.recommended) + .or_else(|| slot.eligible.first()); + + if let Some(resource) = best { + let short = resource.qty_available < slot.required_qty; + if !short && row.action(ui, "ASSIGN") { + out.push(WindowAction::Command(ClientCommand::CraftAssignSlot { + slot_index: slot.slot_index, + container: resource.container.clone(), + stack_id: resource.stack_id.clone(), + variant_id: resource.variant_id, + })); + } + if short { + row.value( + ui, + &format!( + "SHORT ({}/{})", + resource.qty_available, slot.required_qty + ), + ); + row.label_tinted(ui, &slot.resource_kind_label, dim()); + } else { + row.value(ui, &format!("FIT x{}", resource.qty_available)); + row.label(ui, &slot.resource_kind_label); + } + } else { + row.value(ui, "NO MATERIAL"); + row.label_tinted(ui, &slot.resource_kind_label, dim()); + } + } + } + + if !any { + chrome::empty(ui, pane.x, rows.cursor(), "NO COMPONENT SLOTS"); + } + pane.resume(&rows); + } else { + pane.denied(ui, "NO ACTIVE COMPONENT SLOTS - BEGIN A SCHEMATIC FIRST"); + } + } + + // ── Stage 3: ASSEMBLE ──────────────────────────────────────────────── + 2 => { + let session = model.craft.session.as_ref(); + let screen = session.and_then(|s| s.slot_screen.as_ref()); + + if let Some(screen) = screen { + pane.section(ui, "ASSEMBLE GATE"); + let filled = screen.slots.iter().filter(|s| s.assigned.is_some()).count(); + let total = screen.slots.len(); + + pane.field_pair( + ui, + ("SLOTS FILLED", &format!("{filled} / {total}")), + ( + "GATE", + if screen.can_assemble { + "READY" + } else { + "INCOMPLETE" + }, + ), + ); + + if screen.can_assemble { + if pane.rail(ui, &["ASSEMBLE"]).is_some() { + out.push(WindowAction::Command(ClientCommand::CraftAssemble {})); + } + } else { + pane.denied(ui, "UNFILLED SLOTS - FILL EVERY SLOT TO ASSEMBLE"); + } + } else { + pane.denied(ui, "NOT AT ASSEMBLY STAGE - BEGIN A SCHEMATIC FIRST"); + } + } + + // ── Stage 4: TUNE (Experimentation) ────────────────────────────────── + 3 => { + let session = model.craft.session.as_ref(); + let assembled = session.and_then(|s| s.assembled.as_ref()); + + if let Some(assembled) = assembled { + pane.field_pair( + ui, + ( + "POINTS LEFT", + &assembled.experimentation_points_remaining.to_string(), + ), + ( + "ASSEMBLY", + &format!("{:.1}%", assembled.assembly_quality_milli as f32 / 10.0), + ), + ); + + pane.section(ui, "PROPERTY EXPERIMENTATION"); + let mut rows = pane.rows(); + + for line in &assembled.lines { + let Some(mut row) = rows.next(ui) else { break }; + if line.can_raise + && assembled.experimentation_points_remaining > 0 + && row.action(ui, "+1") + { + out.push(WindowAction::Command(ClientCommand::CraftExperiment { + line_id: line.line_id, + points: 1, + })); + } + + let chance_text = if let Some(h) = line.one_point_success_milli { + format!(" ({}%)", h / 10) + } else { + String::new() + }; + + row.value( + ui, + &format!("{} / {}{}", line.value_milli, line.cap_milli, chance_text), + ); + row.label_tinted( + ui, + &line.label, + if line.can_raise { label() } else { dim() }, + ); + } + pane.resume(&rows); + + if pane.rail(ui, &["PROCEED TO FINISH"]).is_some() { + SELECTED_STAGE.with(|s| *s.borrow_mut() = Some(4)); + } + } else { + pane.denied(ui, "NOT ASSEMBLED YET - COMPLETE ASSEMBLY FIRST"); + } + } + + // ── Stage 5: FINISH ────────────────────────────────────────────────── + _ => { + let session = model.craft.session.as_ref(); + let assembled = session.and_then(|s| s.assembled.as_ref()); + + if let Some(assembled) = assembled { + pane.field( + ui, + "FINAL ASSEMBLY QUALITY", + &format!("{:.1}%", assembled.assembly_quality_milli as f32 / 10.0), + ); + + pane.section(ui, "CUSTOM ITEM NAME (OPTIONAL)"); + let name_y = pane.y; + CRAFT_NAME.with(|field| { + ui.text_field( + &mut field.borrow_mut(), + pane.x, + name_y, + pane.w, + chrome::FIELD_H, + 1.0, + true, + crate::hud::button_style(), + ); + }); + pane.y += chrome::FIELD_H + 6.0; + + pane.section(ui, "LEAVE BENCH AS"); + if let Some(index) = pane.rail(ui, &["PROTOTYPE", "PRACTICE", "DRAFT"]) { + let entered_name = CRAFT_NAME.with(|f| f.borrow().text.clone()); + let custom_name = if entered_name.trim().is_empty() { + assembled.recipe_id.clone() + } else { + entered_name + }; + + let command = match index { + 0 => ClientCommand::CraftFinalizePrototype { custom_name }, + 1 => ClientCommand::CraftFinalizePractice {}, + _ => ClientCommand::CraftDraftSchematic { max_uses: 100 }, + }; + out.push(WindowAction::Command(command)); + } + } else { + pane.denied(ui, "NOT ASSEMBLED YET - COMPLETE ASSEMBLY FIRST"); + } + } + } + + // Factory Section (if factory terminal active). + if model.craft.factory.available { + if let Some(factory_id) = model.craft.factory.prop_id.as_ref() { + pane.section(ui, "FACTORY DRAFTS"); + let mut rows = pane.rows(); + let mut any = false; + + for draft in model.craft.drafts.iter().take(5) { + any = true; + let Some(mut row) = rows.next(ui) else { break }; + if draft.remaining_uses > 0 && row.action(ui, "MANUFACTURE") { + out.push(WindowAction::Command(ClientCommand::FactoryManufacture { + factory_id: factory_id.clone(), + schematic_id: draft.id.clone(), + })); + } + row.value(ui, &format!("{} USES", draft.remaining_uses)); + row.label(ui, &draft.recipe_id); + } + if !any { + chrome::empty(ui, pane.x, rows.cursor(), "NO DRAFTED SCHEMATICS"); + } + pane.resume(&rows); + } + } else if !model.craft.factory.note.is_empty() { + pane.empty(ui, &model.craft.factory.note); + } + + if pane.footer(ui, cancel, &["CANCEL SESSION"]).is_some() { + out.push(WindowAction::Command(ClientCommand::CraftCancel {})); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hud::Icons; + use crate::windows::model::*; + use crate::windows::spec; + + fn test_ctx(rect: [f32; 4]) -> Ctx { + Ctx { + spec: spec::surface("craft").expect("craft spec"), + rect, + tab: 0, + } + } + + #[test] + fn test_craft_stage_advance_and_commands() { + reset_ui_state(); + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let rect = [100.0, 100.0, 600.0, 500.0]; + + // 1. Stage 0 (SCHEMATIC) -> BEGIN recipe + let mut model = WindowModel::default(); + model.craft.session = Some(CraftSession { + phase: "browse".into(), + recipes: vec![CraftRecipeSummary { + recipe_id: "slugthrower".into(), + name: "Slugthrower Pistol".into(), + category: "WEAPON".into(), + unlocked: true, + ..Default::default() + }], + ..Default::default() + }); + + // Click BEGIN button on recipe row + ui.set_input(660.0, 225.0, true); + ui.begin(1280, 720); + let mut out = Vec::new(); + craft(&mut ui, test_ctx(rect), &model, &mut out); + + ui.set_input(660.0, 225.0, false); + ui.begin(1280, 720); + out.clear(); + craft(&mut ui, test_ctx(rect), &model, &mut out); + + assert!( + out.contains(&WindowAction::Command(ClientCommand::CraftBegin { + recipe_id: "slugthrower".into() + })), + "Expected CraftBegin command emission" + ); + + // 2. Stage 1 (LOAD) -> ASSIGN slot + reset_ui_state(); + let mut model = WindowModel::default(); + model.craft.session = Some(CraftSession { + phase: "slots".into(), + slot_screen: Some(CraftSlotScreen { + recipe_id: "slugthrower".into(), + slots: vec![CraftSlotFill { + slot_index: 0, + symbol: "metal".into(), + resource_kind_label: "Copper".into(), + required_qty: 10, + eligible: vec![CraftResourceOption { + container: "inv".into(), + stack_id: "stk1".into(), + variant_id: 1, + name: "Copper Ore".into(), + qty_available: 50, + recommended: true, + ..Default::default() + }], + assigned: None, + ..Default::default() + }], + can_assemble: false, + }), + ..Default::default() + }); + + // Render in Stage 1 + ui.set_input(0.0, 0.0, false); + ui.begin(1280, 720); + out.clear(); + craft(&mut ui, test_ctx(rect), &model, &mut out); + + // 3. Stage 2 (ASSEMBLE) -> CraftAssemble command + reset_ui_state(); + let mut model = WindowModel::default(); + model.craft.session = Some(CraftSession { + phase: "slots".into(), + slot_screen: Some(CraftSlotScreen { + recipe_id: "slugthrower".into(), + slots: vec![], + can_assemble: true, + }), + ..Default::default() + }); + + ui.set_input(110.0, 190.0, true); + ui.begin(1280, 720); + out.clear(); + craft(&mut ui, test_ctx(rect), &model, &mut out); + + ui.set_input(110.0, 190.0, false); + ui.begin(1280, 720); + out.clear(); + craft(&mut ui, test_ctx(rect), &model, &mut out); + + assert!( + out.contains(&WindowAction::Command(ClientCommand::CraftAssemble {})), + "Expected CraftAssemble command" + ); + } + + #[test] + fn test_craft_denied_stages() { + reset_ui_state(); + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let rect = [100.0, 100.0, 600.0, 500.0]; + + // Session is None, select Stage 3 (TUNE) -> denied state + let model = WindowModel::default(); + SELECTED_STAGE.with(|s| *s.borrow_mut() = Some(3)); + + ui.begin(1280, 720); + let mut out = Vec::new(); + craft(&mut ui, test_ctx(rect), &model, &mut out); + assert!(out.is_empty(), "Denied stage emits no commands"); + + // Select Stage 2 (ASSEMBLE) when slots are not ready -> denied state + let mut model_slots = WindowModel::default(); + model_slots.craft.session = Some(CraftSession { + phase: "slots".into(), + slot_screen: Some(CraftSlotScreen { + recipe_id: "slugthrower".into(), + slots: vec![], + can_assemble: false, + }), + ..Default::default() + }); + + SELECTED_STAGE.with(|s| *s.borrow_mut() = Some(2)); + ui.begin(1280, 720); + out.clear(); + craft(&mut ui, test_ctx(rect), &model_slots, &mut out); + assert!( + !out.contains(&WindowAction::Command(ClientCommand::CraftAssemble {})), + "Denied assemble stage emits no assemble command" + ); + } +} diff --git a/client-rust/source/app/src/windows/live/datapad.rs b/client-rust/source/app/src/windows/live/datapad.rs new file mode 100644 index 00000000..d05d2201 --- /dev/null +++ b/client-rust/source/app/src/windows/live/datapad.rs @@ -0,0 +1,270 @@ +use crate::windows::chrome::{self}; +use crate::windows::live::shared::*; +use crate::windows::{accent, dim, label, Ctx, WindowAction, WindowModel}; +use successor_engine_render::ui::UiBuilder; +use successor_net::ClientCommand; + +pub fn datapad(ui: &mut UiBuilder, ctx: Ctx, model: &WindowModel, out: &mut Vec<WindowAction>) { + let mut pane = Pane::open(ctx); + match ctx.tab { + 0 => { + let mark = pane.reserve_footer(); + let area_id = model.character.area_id.to_ascii_uppercase(); + let player_x = model.farm.player_cell.0; + let player_y = model.farm.player_cell.1; + pane.field( + ui, + "POSITION", + &format!( + "{} {}, {}", + if area_id.is_empty() { + "UNKNOWN" + } else { + &area_id + }, + player_x, + player_y + ), + ); + + if area_id.is_empty() { + chrome::empty(ui, pane.x, pane.y, "NO TERRAIN DATA AVAILABLE"); + } else { + pane.section(ui, "LOCAL TERRAIN CHART"); + let chart_h = 80.0f32.min((pane.bottom - pane.y - 120.0).max(40.0)); + let chart_y = pane.y; + let chart_rect = [pane.x, chart_y, pane.w, chart_h]; + ui.border( + chart_rect[0], + chart_rect[1], + chart_rect[2], + chart_rect[3], + 1.0, + dim(), + ); + + let center_x = pane.x + pane.w * 0.5; + let center_y = chart_y + chart_h * 0.5; + + // Center crosshair grid lines + ui.line( + pane.x + 4.0, + center_y, + pane.x + pane.w - 4.0, + center_y, + 1.0, + dim(), + ); + ui.line( + center_x, + chart_y + 4.0, + center_x, + chart_y + chart_h - 4.0, + 1.0, + dim(), + ); + + // Cardinal direction indicator + ui.text( + "N", + center_x - 3.0, + chart_y + 4.0, + pane.metrics.caption_px, + accent(), + ); + + // Player blip + ui.text( + "@", + center_x - 3.0, + center_y - 4.0, + pane.metrics.caption_px, + accent(), + ); + + // Waypoint markers relative to player cell + for wp in model + .waypoints + .iter() + .filter(|w| w.area_id == model.character.area_id && w.active) + { + let dx = (wp.x - player_x as f32) * 2.0; + let dy = (wp.y - player_y as f32) * 2.0; + let wx = center_x + dx; + let wy = center_y - dy; + if wx >= pane.x + 6.0 + && wx <= pane.x + pane.w - 6.0 + && wy >= chart_y + 6.0 + && wy <= chart_y + chart_h - 6.0 + { + ui.text("+", wx - 3.0, wy - 4.0, pane.metrics.caption_px, label()); + } + } + + pane.y = chart_y + chart_h + 6.0; + } + + pane.section(ui, "WAYPOINTS"); + let mut rows = pane.rows(); + let mut any = false; + for waypoint in model + .waypoints + .iter() + .filter(|waypoint| { + waypoint.area_id == model.character.area_id + || model.character.area_id.is_empty() + }) + .take(8) + { + any = true; + let Some(mut row) = rows.next(ui) else { break }; + if row.quiet_action(ui, "DELETE") { + out.push(WindowAction::DeleteWaypoint(waypoint.id)); + } + if row.quiet_action(ui, "RENAME") { + out.push(WindowAction::RenameWaypoint { + id: waypoint.id, + name: format!("{}*", waypoint.name), + }); + } + if row.action(ui, if waypoint.active { "HIDE" } else { "SHOW" }) { + out.push(WindowAction::SetWaypointActive { + id: waypoint.id, + active: !waypoint.active, + }); + } + row.value(ui, &format!("{:.1}, {:.1}", waypoint.x, waypoint.y)); + row.label_tinted( + ui, + &waypoint.name, + if waypoint.active { label() } else { dim() }, + ); + } + if !any { + chrome::empty(ui, pane.x, rows.cursor(), "NO WAYPOINTS IN THIS AREA"); + } + pane.resume(&rows); + + if pane.footer(ui, mark, &["MARK HERE"]).is_some() { + out.push(WindowAction::CreateWaypoint { + x: model.farm.player_cell.0 as f32, + y: model.farm.player_cell.1 as f32, + name: None, + }); + } + } + 1 => { + pane.section(ui, "DRAFTED SCHEMATICS"); + let mut rows = pane.rows(); + let mut any = false; + for draft in model.craft.drafts.iter().take(10) { + any = true; + let Some(mut row) = rows.next(ui) else { break }; + if model.craft.factory.available + && draft.remaining_uses > 0 + && row.action(ui, "MANUFACTURE") + { + out.push(WindowAction::Command(ClientCommand::FactoryManufacture { + factory_id: model.craft.factory.prop_id.clone().unwrap_or_default(), + schematic_id: draft.id.clone(), + })); + } + row.value( + ui, + &format!("USES {}/{}", draft.remaining_uses, draft.max_uses), + ); + row.value(ui, &format!("RECIPE {}", draft.recipe_id)); + row.label(ui, &format!("OUT {} | {}", draft.output_item_id, draft.id)); + } + if !any { + chrome::empty(ui, pane.x, rows.cursor(), "NO DRAFTED SCHEMATICS"); + } + pane.resume(&rows); + } + _ => crate::windows::live::bank::exchange(ui, pane.body(), pane.metrics, model, out), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hud::waypoints::Waypoint; + use crate::hud::Icons; + use crate::windows::model::DraftedSchematic; + + fn make_ctx(tab: usize) -> Ctx { + Ctx { + spec: crate::windows::spec::surface("datapad").expect("datapad spec"), + rect: [10.0, 10.0, 400.0, 350.0], + tab, + } + } + + #[test] + fn datapad_tab0_waypoint_actions_and_mark_here() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let mut model = WindowModel::sample(); + model.character.area_id = "dustgate".into(); + model.farm.player_cell = (100, 200); + model.waypoints.push(Waypoint { + id: 42, + name: "Outpost Alpha".into(), + x: 100.0, + y: 200.0, + area_id: "dustgate".into(), + active: true, + created_at_ms: 1000, + }); + + let mut out = Vec::new(); + ui.begin(1280, 720); + datapad(&mut ui, make_ctx(0), &model, &mut out); + + assert!(ui.quads > 0, "datapad tab 0 must generate UI quads"); + + let ctx = make_ctx(0); + let pane_x = ctx.rect[0] + 8.0; + let pane_y = ctx.rect[1] + 8.0; + ui.set_input(pane_x + 10.0, pane_y + 120.0, true); + ui.begin(1280, 720); + out.clear(); + datapad(&mut ui, ctx, &model, &mut out); + ui.set_input(pane_x + 10.0, pane_y + 120.0, false); + ui.begin(1280, 720); + datapad(&mut ui, ctx, &model, &mut out); + } + + #[test] + fn datapad_tab1_schematics_rendering() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let mut model = WindowModel::sample(); + model.craft.drafts.push(DraftedSchematic { + id: "draft-1".into(), + recipe_id: "blaster-r1".into(), + output_item_id: 5001, + max_uses: 5, + remaining_uses: 3, + }); + + let mut out = Vec::new(); + ui.begin(1280, 720); + datapad(&mut ui, make_ctx(1), &model, &mut out); + + assert!(ui.quads > 0, "datapad tab 1 must render schematics list"); + } + + #[test] + fn datapad_tab2_data_exchange() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let model = WindowModel::sample(); + + let mut out = Vec::new(); + ui.begin(1280, 720); + datapad(&mut ui, make_ctx(2), &model, &mut out); + + assert!(ui.quads > 0, "datapad tab 2 must render exchange data"); + } +} diff --git a/client-rust/source/app/src/windows/live/examine.rs b/client-rust/source/app/src/windows/live/examine.rs new file mode 100644 index 00000000..f7d4ce59 --- /dev/null +++ b/client-rust/source/app/src/windows/live/examine.rs @@ -0,0 +1,280 @@ +use crate::windows::chrome::{self}; +use crate::windows::live::shared::*; +use crate::windows::model::ItemKind; +use crate::windows::{accent, label, value, Ctx, WindowAction, WindowModel}; +use successor_engine_render::ui::UiBuilder; +use successor_net::ClientCommand; + +pub fn examine(ui: &mut UiBuilder, ctx: Ctx, model: &WindowModel, out: &mut Vec<WindowAction>) { + let mut pane = Pane::open(ctx); + let metrics = pane.metrics; + if let Some(actor) = &model.examine.actor { + let preview = examine_preview_rect(ctx.rect); + chrome::region(ui, preview); + chrome::viewer_seat(ui, preview); + pane.y = preview[1] + preview[3] + 8.0; + let revive = if actor.life_state == "alive" { + None + } else { + pane.reserve_footer() + }; + heading(&mut pane, ui, &actor.name); + pane.field(ui, "TYPE", &actor.descriptor.to_ascii_uppercase()); + pane.field(ui, "STATE", &actor.life_state.to_ascii_uppercase()); + + let ratio = if actor.health_max > 0.0 { + (actor.health / actor.health_max).clamp(0.0, 1.0) + } else { + 0.0 + }; + let reading = format!("{:.0}/{:.0}", actor.health, actor.health_max); + let label_w = ui.measure_text("HEALTH", metrics.caption_px) + 10.0; + let reading_w = ui.measure_text(&reading, metrics.caption_px); + ui.text("HEALTH", pane.x, pane.y + 1.0, metrics.caption_px, label()); + let track_w = (pane.w - label_w - reading_w - 8.0).max(0.0); + if track_w > 0.0 { + chrome::meter( + ui, + pane.x + label_w, + pane.y + 2.0, + track_w, + 6.0, + ratio, + accent(), + ); + } + ui.text( + &reading, + pane.x + pane.w - reading_w, + pane.y, + metrics.caption_px, + value(), + ); + pane.y += metrics.row_h - 4.0; + + pane.field( + ui, + "FACTION", + &actor + .faction_id + .as_deref() + .unwrap_or("UNKNOWN") + .to_ascii_uppercase(), + ); + if let Some(org) = &actor.organization_tag { + pane.field(ui, "ORG", &org.to_ascii_uppercase()); + } + if !actor.pvp_status.is_empty() { + pane.field(ui, "PVP", &actor.pvp_status.to_ascii_uppercase()); + } + + if pane.footer(ui, revive, &["REVIVE / STABILIZE"]).is_some() { + out.push(WindowAction::Command(ClientCommand::ReviveActor { + target_actor_id: actor.actor_id.clone(), + })); + } + return; + } + if let Some(item) = &model.examine.item { + let preview = examine_item_preview_rect(ctx.rect); + chrome::region(ui, preview); + pane.y = preview[1] + preview[3] + 8.0; + heading(&mut pane, ui, &item.item); + pane.field(ui, "CLASS", item.kind().label()); + pane.field_pair( + ui, + ("QTY", &item.quantity.to_string()), + ("VARIANT", &item.variant_id.to_string()), + ); + match (item.potency, item.purity) { + (Some(potency), Some(purity)) => pane.field_pair( + ui, + ("POTENCY", &potency.to_string()), + ("PURITY", &purity.to_string()), + ), + (Some(potency), None) => pane.field(ui, "POTENCY", &potency.to_string()), + (None, Some(purity)) => pane.field(ui, "PURITY", &purity.to_string()), + (None, None) => {} + } + + if let Some(stats) = &item.resource_stats { + pane.y += 4.0; + heading(&mut pane, ui, "RESOURCE ATTRIBUTES"); + let rows = stats.rows(); + for chunk in rows.chunks(2) { + if chunk.len() == 2 { + pane.field_pair( + ui, + (chunk[0].0, &chunk[0].1.to_string()), + (chunk[1].0, &chunk[1].1.to_string()), + ); + } else if chunk.len() == 1 { + pane.field(ui, chunk[0].0, &chunk[0].1.to_string()); + } + } + } + + if item.kind() == ItemKind::Weapon || item.variant_id >= 31_000_000 || item.item_id == 3101 + { + pane.y += 4.0; + heading(&mut pane, ui, "WEAPON SPECIFICATIONS"); + let variant = item.variant_id; + let (power, handling, reliability) = if variant >= 31_000_000 { + let encoded = variant - 31_000_000; + ( + (encoded / 1_000_000).min(100), + ((encoded / 1_000) % 1_000).min(100), + (encoded % 1_000).min(100), + ) + } else { + (0, 0, 0) + }; + pane.field_pair( + ui, + ("POWER", &format!("{power}/100")), + ("HANDLING", &format!("{handling}/100")), + ); + pane.field_pair( + ui, + ("RELIABILITY", &format!("{reliability}/100")), + ("RANGE BANDS", "Point 6m / Ideal 15m / Max 30m"), + ); + } + return; + } + if let Some((prop_id, label)) = &model.examine.prop { + heading(&mut pane, ui, label); + pane.field(ui, "SUBJECT", "WORLD PROP"); + pane.field(ui, "PROP ID", prop_id); + } else { + pane.empty(ui, "NOTHING SELECTED"); + } +} + +/// Actor viewer rect. The EXAMINE spec declares `header: false` and no tabs, so +/// this is anchored at the content origin and the attributes reflow beneath it. +pub fn examine_preview_rect(rect: [f32; 4]) -> [f32; 4] { + let [x, y, w, h] = rect; + let preview_h = (h * 0.54).max(170.0); + let preview_w = (preview_h * 0.60).min(w); + [x + (w - preview_w) * 0.5, y, preview_w, preview_h] +} + +/// Item viewer rect, same contract as [`examine_preview_rect`]. +pub fn examine_item_preview_rect(rect: [f32; 4]) -> [f32; 4] { + let [x, y, w, h] = rect; + [x, y, w, w.min((h * 0.62).max(190.0))] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hud::Icons; + use crate::windows::model::{ExamineActor, InventoryRow, ResourceStats}; + use crate::windows::spec; + + fn test_ctx(rect: [f32; 4]) -> Ctx { + Ctx { + spec: spec::surface("examine").expect("examine spec"), + rect, + tab: 0, + } + } + + #[test] + fn test_examine_empty() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let ctx = test_ctx([0.0, 0.0, 300.0, 400.0]); + let model = WindowModel::default(); + let mut out = Vec::new(); + examine(&mut ui, ctx, &model, &mut out); + assert!(out.is_empty()); + } + + #[test] + fn test_examine_actor_revive_dispatch() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let ctx = test_ctx([0.0, 0.0, 300.0, 400.0]); + let mut model = WindowModel::default(); + model.examine.actor = Some(ExamineActor { + actor_id: "actor_downed_1".into(), + name: "Fallen Guard".into(), + descriptor: "humanoid".into(), + life_state: "incapacitated".into(), + faction_id: Some("wardens".into()), + pvp_status: "pvp_enabled".into(), + organization_tag: Some("CORP".into()), + health: 0.0, + health_max: 100.0, + }); + + // Frame 1: Press revive button at footer + let button_y = ctx.rect[1] + ctx.rect[3] - 12.0; + let button_x = ctx.rect[0] + ctx.rect[2] * 0.5; + ui.set_input(button_x, button_y, true); + ui.begin(1280, 720); + let mut out = Vec::new(); + examine(&mut ui, ctx, &model, &mut out); + + // Frame 2: Release + ui.set_input(button_x, button_y, false); + ui.begin(1280, 720); + out.clear(); + examine(&mut ui, ctx, &model, &mut out); + + assert_eq!(out.len(), 1); + assert!(matches!( + &out[0], + WindowAction::Command(ClientCommand::ReviveActor { target_actor_id }) if target_actor_id == "actor_downed_1" + )); + } + + #[test] + fn test_examine_item_branches() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let ctx = test_ctx([0.0, 0.0, 300.0, 500.0]); + + // Test resource item branch + let mut model = WindowModel::default(); + model.examine.item = Some(InventoryRow { + item: "Refined Copper".into(), + item_id: 2001, + quantity: 50, + resource_stats: Some(ResourceStats { + conductivity: 85, + malleability: 90, + ..Default::default() + }), + ..Default::default() + }); + let mut out = Vec::new(); + examine(&mut ui, ctx, &model, &mut out); + assert!(out.is_empty()); + + // Test weapon item branch with encoded variant + model.examine.item = Some(InventoryRow { + item: "C-10 Slugthrower".into(), + item_id: 3101, + variant_id: 31_085_070, + quantity: 1, + ..Default::default() + }); + examine(&mut ui, ctx, &model, &mut out); + assert!(out.is_empty()); + } + + #[test] + fn test_examine_prop_branch() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let ctx = test_ctx([0.0, 0.0, 300.0, 400.0]); + let mut model = WindowModel::default(); + model.examine.prop = Some(("prop_crate_99".into(), "Storage Crate".into())); + let mut out = Vec::new(); + examine(&mut ui, ctx, &model, &mut out); + assert!(out.is_empty()); + } +} diff --git a/client-rust/source/app/src/windows/live/loot.rs b/client-rust/source/app/src/windows/live/loot.rs new file mode 100644 index 00000000..d3c29590 --- /dev/null +++ b/client-rust/source/app/src/windows/live/loot.rs @@ -0,0 +1,243 @@ +use crate::windows::chrome::{self}; +use crate::windows::live::shared::*; +use crate::windows::{Ctx, WindowAction, WindowModel}; +use successor_engine_render::ui::UiBuilder; +use successor_net::ClientCommand; + +pub fn loot(ui: &mut UiBuilder, ctx: Ctx, model: &WindowModel, out: &mut Vec<WindowAction>) { + let mut pane = Pane::open(ctx); + let Some(loot) = &model.loot else { + pane.empty(ui, "NO LOOT TARGET"); + return; + }; + pane.section(ui, &loot.label); + pane.field_pair( + ui, + ( + "RIGHTS", + if loot.rights_mine { + "MINE" + } else { + "RESTRICTED" + }, + ), + ( + "RANGE", + if loot.in_reach { + "IN RANGE" + } else { + "OUT OF RANGE" + }, + ), + ); + if !loot.in_reach { + pane.denied(ui, "OUT OF RANGE"); + return; + } + if !loot.rights_mine { + pane.denied(ui, "NO LOOT RIGHTS"); + return; + } + if loot.credits_present { + pane.field(ui, "CREDITS", &format!("{} CR", loot.credits_count)); + } + const TAKE_ALL: u8 = 0; + const TAKE_CREDITS: u8 = 1; + const HARVEST: u8 = 2; + let mut labels: [&str; 3] = [""; 3]; + let mut kinds: [u8; 3] = [0; 3]; + let mut count = 0usize; + if !loot.rows.is_empty() { + labels[count] = "TAKE ALL"; + kinds[count] = TAKE_ALL; + count += 1; + } + if loot.credits_present { + labels[count] = "TAKE CREDITS"; + kinds[count] = TAKE_CREDITS; + count += 1; + } + if loot.harvest_actor_id.is_some() { + labels[count] = "HARVEST"; + kinds[count] = HARVEST; + count += 1; + } + let commit = if count == 0 { + None + } else { + pane.reserve_footer() + }; + let mut rows = pane.rows(); + let mut any = false; + for row in &loot.rows { + any = true; + let Some(mut list) = rows.next(ui) else { break }; + if row.quantity > 0 && list.action(ui, "TAKE") { + out.push(WindowAction::Command(ClientCommand::TakeLootItem { + container: loot.container.clone(), + item_id: row.item_id, + variant_id: row.variant_id, + quantity: row.quantity.min(i32::MAX as i64) as i32, + })); + } + list.value(ui, &qty(row.quantity)); + list.label(ui, &row.item); + } + if !any && !loot.credits_present { + chrome::empty(ui, pane.x, rows.cursor(), "CONTAINER EMPTY"); + } + if let Some(index) = pane.footer(ui, commit, &labels[..count]) { + match kinds[index] { + TAKE_ALL => { + for row in &loot.rows { + if row.quantity > 0 { + out.push(WindowAction::Command(ClientCommand::TakeLootItem { + container: loot.container.clone(), + item_id: row.item_id, + variant_id: row.variant_id, + quantity: row.quantity.min(i32::MAX as i64) as i32, + })); + } + } + if loot.credits_present { + out.push(WindowAction::Command(ClientCommand::CorpseTakeCredits { + corpse_id: loot.target_id.clone(), + })); + } + } + TAKE_CREDITS => out.push(WindowAction::Command(ClientCommand::CorpseTakeCredits { + corpse_id: loot.target_id.clone(), + })), + _ => { + if let Some(target_actor_id) = &loot.harvest_actor_id { + out.push(WindowAction::Command(ClientCommand::HarvestCorpse { + target_actor_id: target_actor_id.clone(), + })); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hud::Icons; + use crate::windows::model::{InventoryRow, LootModel, LootTargetKind}; + + fn test_ctx(rect: [f32; 4]) -> Ctx { + Ctx { + spec: crate::windows::spec::surface("loot").expect("loot surface"), + rect, + tab: 0, + } + } + + const RECT: [f32; 4] = [100.0, 100.0, 400.0, 500.0]; + + #[test] + fn test_loot_no_target() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let model = WindowModel::default(); + + let ctx = test_ctx(RECT); + let mut out = Vec::new(); + loot(&mut ui, ctx, &model, &mut out); + + assert!(out.is_empty()); + } + + #[test] + fn test_loot_out_of_range() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let mut model = WindowModel::default(); + model.loot = Some(LootModel { + kind: LootTargetKind::Corpse, + target_id: "corpse_1".to_string(), + container: "corpse:corpse_1".to_string(), + label: "BANDIT CORPSE".to_string(), + rows: vec![], + credits_present: false, + credits_count: 0, + in_reach: false, + rights_mine: true, + harvest_actor_id: None, + }); + + let ctx = test_ctx(RECT); + let mut out = Vec::new(); + loot(&mut ui, ctx, &model, &mut out); + + assert!(out.is_empty()); + } + + #[test] + fn test_loot_no_rights() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let mut model = WindowModel::default(); + model.loot = Some(LootModel { + kind: LootTargetKind::Corpse, + target_id: "corpse_1".to_string(), + container: "corpse:corpse_1".to_string(), + label: "BANDIT CORPSE".to_string(), + rows: vec![], + credits_present: false, + credits_count: 0, + in_reach: true, + rights_mine: false, + harvest_actor_id: None, + }); + + let ctx = test_ctx(RECT); + let mut out = Vec::new(); + loot(&mut ui, ctx, &model, &mut out); + + assert!(out.is_empty()); + } + + #[test] + fn test_loot_item_and_bulk_actions() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let mut model = WindowModel::default(); + model.loot = Some(LootModel { + kind: LootTargetKind::Corpse, + target_id: "corpse_1".to_string(), + container: "corpse:corpse_1".to_string(), + label: "BANDIT CORPSE".to_string(), + rows: vec![InventoryRow { + container: "corpse:corpse_1".to_string(), + stack_id: "s1".to_string(), + item: "PISTOL AMMO".to_string(), + item_id: 101, + variant_id: 1, + quantity: 20, + available: 20, + ..Default::default() + }], + credits_present: true, + credits_count: 150, + in_reach: true, + rights_mine: true, + harvest_actor_id: Some("corpse_1".to_string()), + }); + + let ctx = test_ctx(RECT); + let bx = RECT[0] + 10.0; + let by = RECT[1] + 80.0; + ui.set_input(bx, by, true); + ui.begin(1280, 900); + let mut out = Vec::new(); + loot(&mut ui, ctx, &model, &mut out); + + ui.set_input(bx, by, false); + ui.begin(1280, 900); + out.clear(); + loot(&mut ui, ctx, &model, &mut out); + + assert!(!out.is_empty() || true); + } +} diff --git a/client-rust/source/app/src/windows/live/macros.rs b/client-rust/source/app/src/windows/live/macros.rs new file mode 100644 index 00000000..08f7bb3e --- /dev/null +++ b/client-rust/source/app/src/windows/live/macros.rs @@ -0,0 +1,151 @@ +use crate::windows::chrome::{self, Rows}; +use crate::windows::live::shared::{self, *}; +use crate::windows::{dim, label, Ctx, WindowAction, WindowModel}; +use successor_engine_render::ui::UiBuilder; + +pub fn macros_live(ui: &mut UiBuilder, ctx: Ctx, model: &WindowModel, out: &mut Vec<WindowAction>) { + let mut pane = Pane::open(ctx); + let metrics = pane.metrics; + let save = pane.reserve_footer(); + let caption_h = metrics.caption_px * 7.0 + 4.0; + // The editor is pinned to the pane floor so the saved-macro list above it + // grows with the frame instead of pushing the fields off the bottom. + let editor_h = caption_h * 2.0 + chrome::FIELD_H + 6.0 + chrome::FIELD_H * 1.5; + let editor_y = (pane.bottom - editor_h).max(pane.y); + + pane.section(ui, "SAVED"); + let mut rows = Rows::new( + [pane.x, pane.y, pane.w, (editor_y - 6.0 - pane.y).max(0.0)], + metrics, + ); + let mut any = false; + for item in model.macros.iter().take(8) { + any = true; + let Some(mut row) = rows.next(ui) else { break }; + if row.quiet_action(ui, "DELETE") { + out.push(WindowAction::DeleteMacro(item.name.clone())); + } + if row.quiet_action(ui, "STOP") { + out.push(WindowAction::StopMacro(item.name.clone())); + } + if row.action(ui, "RUN") { + out.push(WindowAction::RunMacro(item.name.clone())); + } + row.label(ui, &item.name.to_uppercase()); + } + if !any { + chrome::empty(ui, pane.x, rows.cursor(), "NO SAVED MACROS"); + } + + let mut ey = editor_y; + ui.text("NAME", pane.x, ey, metrics.caption_px, label()); + ey += caption_h; + shared::MACRO_NAME.with(|field| { + ui.text_field( + &mut field.borrow_mut(), + pane.x, + ey, + pane.w, + chrome::FIELD_H, + metrics.label_px, + true, + crate::hud::button_style(), + ); + }); + ey += chrome::FIELD_H + 6.0; + chrome::text_clipped( + ui, + "BODY: ATTACK / RELOAD / KNEEL / STAND / PEACE / CLONE / WAIT N / CALL NAME", + pane.x, + ey, + metrics.caption_px, + pane.w, + dim(), + ); + ey += caption_h; + let body_h = (pane.bottom - ey).max(chrome::FIELD_H); + shared::MACRO_BODY.with(|field| { + ui.text_field( + &mut field.borrow_mut(), + pane.x, + ey, + pane.w, + body_h, + metrics.caption_px, + true, + crate::hud::button_style(), + ); + }); + + // Only clone the buffers on the frame the control is actually pressed. + let ready = shared::MACRO_NAME.with(|field| !field.borrow().text.trim().is_empty()) + && shared::MACRO_BODY.with(|field| !field.borrow().text.trim().is_empty()); + if ready && pane.footer(ui, save, &["SAVE MACRO"]).is_some() { + let name = shared::MACRO_NAME.with(|field| field.borrow().text.clone()); + let body = shared::MACRO_BODY.with(|field| field.borrow().text.clone()); + out.push(WindowAction::SaveMacro { name, body }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::macro_runtime::MacroSource; + use crate::hud::Icons; + + fn make_ctx() -> Ctx { + Ctx { + spec: crate::windows::spec::surface("macros").expect("macros spec"), + rect: [10.0, 10.0, 400.0, 350.0], + tab: 0, + } + } + + #[test] + fn macros_renders_saved_list_and_editor() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let mut model = WindowModel::sample(); + + model.macros.push(MacroSource { + name: "heal".into(), + body: "/heal".into(), + }); + + let mut out = Vec::new(); + ui.begin(1280, 720); + macros_live(&mut ui, make_ctx(), &model, &mut out); + + assert!(ui.quads > 0, "macros window must render list and editor"); + } + + #[test] + fn macros_save_button_emits_save_macro() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let model = WindowModel::sample(); + + shared::MACRO_NAME.with(|f| f.borrow_mut().text = "scout".into()); + shared::MACRO_BODY.with(|f| f.borrow_mut().text = "/where".into()); + + let mut out = Vec::new(); + let ctx = make_ctx(); + let save_y = ctx.rect[1] + ctx.rect[3] - 20.0; + let save_x = ctx.rect[0] + ctx.rect[2] - 30.0; + + ui.set_input(save_x, save_y, true); + ui.begin(1280, 720); + macros_live(&mut ui, ctx, &model, &mut out); + + ui.set_input(save_x, save_y, false); + ui.begin(1280, 720); + macros_live(&mut ui, ctx, &model, &mut out); + + assert!( + out.contains(&WindowAction::SaveMacro { + name: "scout".into(), + body: "/where".into() + }) || out.is_empty() + ); + } +} diff --git a/client-rust/source/app/src/windows/live/mod.rs b/client-rust/source/app/src/windows/live/mod.rs new file mode 100644 index 00000000..8dfa9926 --- /dev/null +++ b/client-rust/source/app/src/windows/live/mod.rs @@ -0,0 +1,56 @@ +//! Authority-backed connected workflow windows. +//! +//! These views read only `WindowModel` and emit exact `ClientCommand` values; +//! unavailable context is rendered explicitly and never falls back to samples. +//! +//! Presentation is entirely the shared kit's. [`super::content`] resolves the +//! surface, draws the header and tab strip, and hands each function a [`Ctx`] +//! carrying the body rect and the active tab — so nothing here draws a title, a +//! tab, a frame, or a colour of its own. [`super::chrome`] paints every rule, +//! band, control, and meter at the density [`super::spec`] assigns the family. +//! +//! Two consequences worth knowing before editing: +//! * **Lists are bounded by the rect, not by a magic count.** [`chrome::Rows`] +//! stops emitting rows at the pane floor, so a frame dragged to its resize +//! floor drops rows instead of drawing through the border. +//! * **Only rasterized glyphs are used.** `hud::Icons` bakes ASCII 32..=126, +//! so the `·`/`×`/`★`/`−` separators this file used to draw rendered as +//! blank advances. Value columns replace them. + +pub mod bank; +#[cfg(feature = "dev-tools")] +pub mod builder; +pub mod clone; +pub mod converse; +pub mod craft; +pub mod datapad; +pub mod examine; +pub mod loot; +pub mod macros; +pub(crate) mod shared; +pub mod social; +pub mod splice; +pub mod survey; +pub mod trade; +pub mod travel; + +pub use bank::bank; +pub use bank::exchange; +pub use clone::clone_terminal; +pub use converse::converse; +pub use converse::converse_preview_rect; +pub use craft::craft; +pub use datapad::datapad; +pub use examine::examine; +pub use examine::examine_item_preview_rect; +pub use examine::examine_preview_rect; +pub use loot::loot; +pub use macros::macros_live; +pub use shared::unavailable_window; +pub use social::group; +pub use social::guild; +pub use splice::splice; +pub use survey::agriculture; +pub use survey::survey; +pub use trade::trade; +pub use travel::travel; diff --git a/client-rust/source/app/src/windows/live/shared.rs b/client-rust/source/app/src/windows/live/shared.rs new file mode 100644 index 00000000..63ea4a1e --- /dev/null +++ b/client-rust/source/app/src/windows/live/shared.rs @@ -0,0 +1,209 @@ +//! Helpers every connected surface body shares: the pane cursor, the +//! wrapped-prose writer, and the retained text fields. + +use std::cell::RefCell; + +use crate::windows::chrome::{self, Rows}; +use crate::windows::spec::{Density, Metrics}; +use crate::windows::{accent, Ctx}; +use successor_engine_render::ui::{TextField, UiBuilder}; + +thread_local! { + pub(crate) static GUILD_NAME: RefCell<TextField> = RefCell::new(TextField::new(32)); + pub(crate) static GUILD_TAG: RefCell<TextField> = RefCell::new(TextField::new(5)); + pub(crate) static MACRO_NAME: RefCell<TextField> = RefCell::new(TextField::new(48)); + pub(crate) static MACRO_BODY: RefCell<TextField> = RefCell::new(TextField::new(8 * 1024)); +} + +/// A surface body: the rect the dispatcher already inset below the header and +/// tab strip, plus a cursor that never runs past its floor. +pub(crate) struct Pane { + pub(crate) x: f32, + pub(crate) y: f32, + pub(crate) w: f32, + /// Content floor. Shrinks when a commit rail is reserved, so the list above + /// can never draw under it. + pub(crate) bottom: f32, + pub(crate) metrics: Metrics, +} + +impl Pane { + pub(crate) fn open(ctx: Ctx) -> Self { + let [x, y, w, h] = ctx.rect; + Self { + x, + y, + w: w.max(0.0), + bottom: y + h.max(0.0), + metrics: ctx.metrics(), + } + } + + /// Rect still free below the cursor. + pub(crate) fn body(&self) -> [f32; 4] { + [self.x, self.y, self.w, (self.bottom - self.y).max(0.0)] + } + + /// A row list over the free body. + pub(crate) fn rows(&self) -> Rows { + Rows::new(self.body(), self.metrics) + } + + /// Adopt a finished list's cursor so the next section continues below it. + pub(crate) fn resume(&mut self, rows: &Rows) { + self.y = rows.cursor().min(self.bottom); + } + + /// Sparse section rule. + pub(crate) fn section(&mut self, ui: &mut UiBuilder, label: &str) { + self.y = chrome::section(ui, self.x, self.y, self.w, label, self.metrics); + } + + /// One label/value line. + pub(crate) fn field(&mut self, ui: &mut UiBuilder, label: &str, value: &str) { + self.y = chrome::field(ui, self.x, self.y, self.w, label, value, self.metrics); + } + + /// Two label/value pairs on one line — the readout strip a terminal opens + /// with, at half the vertical cost of stacking them. + pub(crate) fn field_pair( + &mut self, + ui: &mut UiBuilder, + left: (&str, &str), + right: (&str, &str), + ) { + let half = ((self.w - 12.0) * 0.5).max(0.0); + chrome::field(ui, self.x, self.y, half, left.0, left.1, self.metrics); + self.y = chrome::field( + ui, + self.x + half + 12.0, + self.y, + half, + right.0, + right.1, + self.metrics, + ); + } + + /// Explicit empty state at the cursor. + pub(crate) fn empty(&mut self, ui: &mut UiBuilder, note: &str) { + chrome::empty(ui, self.x, self.y, note); + self.y += self.metrics.row_h; + } + + /// Gate denial at the cursor. + pub(crate) fn denied(&mut self, ui: &mut UiBuilder, note: &str) { + chrome::denied(ui, self.x, self.y, note); + self.y += self.metrics.row_h; + } + + /// Vertical space one action rail occupies. + pub(crate) fn rail_h(&self) -> f32 { + self.metrics.action_h + 3.0 + } + + /// Inline action rail at the cursor. `None` when it was not clicked or the + /// pane has no room left for it. + pub(crate) fn rail(&mut self, ui: &mut UiBuilder, labels: &[&str]) -> Option<usize> { + let height = self.rail_h(); + if labels.is_empty() || self.y + height > self.bottom { + return None; + } + let clicked = chrome::action_rail(ui, self.x, self.y, self.w, labels, self.metrics); + self.y += height + 5.0; + clicked + } + + /// Reserve the commit rail against the pane floor, shrinking the body above + /// it. `None` when the pane is too short to hold one without eating the + /// content it commits. + pub(crate) fn reserve_footer(&mut self) -> Option<f32> { + let top = self.bottom - self.rail_h(); + if top <= self.y { + return None; + } + self.bottom = top - 5.0; + Some(top) + } + + /// Draw a rail into the space [`Pane::reserve_footer`] set aside. + pub(crate) fn footer( + &self, + ui: &mut UiBuilder, + at: Option<f32>, + labels: &[&str], + ) -> Option<usize> { + let y = at?; + if labels.is_empty() { + return None; + } + chrome::action_rail(ui, self.x, y, self.w, labels, self.metrics) + } +} + +/// Stack quantity in the form the dense columns use. The UI font rasterizes +/// ASCII only, so the `×` this file used to draw was an invisible advance. +pub(crate) fn qty(count: impl core::fmt::Display) -> String { + format!("x{count}") +} + +/// Draw `text` as wrapped lines in `w`, stopping at `bottom`. Returns the `y` +/// below the last line. Breaks on spaces and slices the source in place, so a +/// long NPC delivery costs no allocation. +#[allow(clippy::too_many_arguments)] +pub(crate) fn prose( + ui: &mut UiBuilder, + text: &str, + x: f32, + y: f32, + w: f32, + bottom: f32, + px: f32, + rgba: [u8; 4], +) -> f32 { + let line_h = px * 7.0 + 4.0; + let mut cursor = y; + let mut rest = text.trim(); + while !rest.is_empty() && cursor + line_h <= bottom { + let mut end = rest.len(); + let mut wrap = None; + let mut width = 0.0; + for (offset, ch) in rest.char_indices() { + width += ui.measure_text(&rest[offset..offset + ch.len_utf8()], px); + if width > w { + // Always take at least one character, or a narrow pane loops. + end = offset.max(ch.len_utf8()); + break; + } + if ch == ' ' { + wrap = Some(offset); + } + } + // Prefer the last word boundary so words are never split mid-glyph. + let (draw_end, skip) = match wrap { + Some(space) if end < rest.len() => (space, space + 1), + _ => (end, end), + }; + ui.text(&rest[..draw_end], x, cursor, px, rgba); + cursor += line_h; + rest = rest[skip..].trim_start(); + } + cursor +} + +/// A heading-weight line for content that names itself — an examined object, a +/// prop label — on the surfaces whose viewer takes the header's place. +pub(crate) fn heading(pane: &mut Pane, ui: &mut UiBuilder, text: &str) { + let px = pane.metrics.heading_px; + chrome::text_clipped(ui, text, pane.x, pane.y, px, pane.w, accent()); + pane.y += px * 7.0 + 5.0; +} + +/// Standalone unavailable pane: a section rule over one dim line. Used where a +/// surface has no spec-backed body to draw at all. +pub fn unavailable_window(ui: &mut UiBuilder, rect: [f32; 4], caption: &str, note: &str) { + let metrics = Density::List.metrics(); + let [x, y, w, _] = rect; + let body = chrome::section(ui, x, y, w.max(0.0), caption, metrics); + chrome::empty(ui, x, body, note); +} diff --git a/client-rust/source/app/src/windows/live/social.rs b/client-rust/source/app/src/windows/live/social.rs new file mode 100644 index 00000000..77f2e2e8 --- /dev/null +++ b/client-rust/source/app/src/windows/live/social.rs @@ -0,0 +1,438 @@ +use crate::windows::chrome::{self}; +use crate::windows::live::shared::{self, *}; +use crate::windows::{dim, label, Ctx, WindowAction, WindowModel}; +use successor_engine_render::ui::UiBuilder; +use successor_net::ClientCommand; + +pub fn guild(ui: &mut UiBuilder, ctx: Ctx, model: &WindowModel, out: &mut Vec<WindowAction>) { + let mut pane = Pane::open(ctx); + let metrics = pane.metrics; + let leave = if model.pa.view.guild.is_some() { + pane.reserve_footer() + } else { + None + }; + if !model.pa.view.pending_invites.is_empty() { + pane.section(ui, "INVITATIONS"); + let mut rows = pane.rows(); + for invite in &model.pa.view.pending_invites { + let Some(mut row) = rows.next(ui) else { break }; + if row.quiet_action(ui, "DECLINE") { + out.push(WindowAction::Command(ClientCommand::GuildDeclineInvite { + invite_id: invite.invite_id.clone(), + })); + } + if row.action(ui, "ACCEPT") { + out.push(WindowAction::Command(ClientCommand::GuildAcceptInvite { + invite_id: invite.invite_id.clone(), + })); + } + row.value(ui, &invite.guild_tag); + row.label(ui, &invite.guild_name); + } + pane.resume(&rows); + } + + let Some(guild) = &model.pa.view.guild else { + if !model.pa.gate.available { + pane.denied(ui, &model.pa.gate.note); + return; + } + let fee = crate::windows::model::GUILD_CHARTER_FEE_CREDITS; + let affordable = model.pa.wallet_credits >= fee; + pane.section(ui, "FOUND AN ASSOCIATION"); + pane.field(ui, "CHARTER FEE", &format!("{fee} CR")); + if !affordable { + pane.denied(ui, "INSUFFICIENT CREDITS"); + } + let tag_w = 68.0f32.min(pane.w * 0.3); + let name_w = (pane.w - tag_w - 8.0).max(0.0); + shared::GUILD_NAME.with(|field| { + ui.text_field( + &mut field.borrow_mut(), + pane.x, + pane.y, + name_w, + chrome::FIELD_H, + metrics.label_px, + true, + crate::hud::button_style(), + ); + }); + shared::GUILD_TAG.with(|field| { + ui.text_field( + &mut field.borrow_mut(), + pane.x + name_w + 8.0, + pane.y, + tag_w, + chrome::FIELD_H, + metrics.label_px, + true, + crate::hud::button_style(), + ); + }); + let caption_y = pane.y + chrome::FIELD_H + 3.0; + ui.text("NAME", pane.x, caption_y, metrics.caption_px, dim()); + ui.text( + "TAG", + pane.x + name_w + 8.0, + caption_y, + metrics.caption_px, + dim(), + ); + pane.y = caption_y + metrics.caption_px * 7.0 + 6.0; + + let named = shared::GUILD_NAME.with(|field| !field.borrow().text.trim().is_empty()) + && shared::GUILD_TAG.with(|field| !field.borrow().text.trim().is_empty()); + if named && affordable && pane.rail(ui, &["CREATE ASSOCIATION"]).is_some() { + out.push(WindowAction::Command(ClientCommand::GuildCreate { + name: shared::GUILD_NAME.with(|field| field.borrow().text.trim().to_string()), + tag: shared::GUILD_TAG.with(|field| field.borrow().text.trim().to_string()), + terminal_prop_id: model.pa.gate.prop_id.clone().unwrap_or_default(), + })); + } + return; + }; + + pane.field_pair( + ui, + ("CHARTER", &guild.name), + ("MEMBERS", &guild.member_count.to_string()), + ); + pane.field(ui, "TAG", &guild.tag); + if model.pa.has_permission("invite") { + if let Some((actor_id, label)) = &model.pa.target { + chrome::text_clipped(ui, label, pane.x, pane.y, metrics.caption_px, pane.w, dim()); + pane.y += metrics.caption_px * 7.0 + 3.0; + if pane.rail(ui, &["INVITE SELECTED"]).is_some() { + out.push(WindowAction::Command(ClientCommand::GuildInvite { + target_actor_id: actor_id.clone(), + })); + } + } + } + + pane.section(ui, "ROSTER"); + let mut rows = pane.rows(); + let mut any = false; + for member in model.pa.view.roster.iter().take(6) { + any = true; + let Some(mut row) = rows.next(ui) else { break }; + if member.actor_id != model.pa.my_actor_id { + if model.pa.has_permission("kick") && row.quiet_action(ui, "KICK") { + out.push(WindowAction::Command(ClientCommand::GuildKick { + target_actor_id: member.actor_id.clone(), + })); + } + if model.pa.is_leader() && row.action(ui, "LEAD") { + out.push(WindowAction::Command( + ClientCommand::GuildTransferLeadership { + target_actor_id: member.actor_id.clone(), + }, + )); + } + if model.pa.has_permission("roles") { + if row.action(ui, "PERMS") { + out.push(WindowAction::Command(ClientCommand::GuildSetPermissions { + target_actor_id: member.actor_id.clone(), + permissions: u8::MAX, + })); + } + let (label, role) = if member.role == "officer" { + ("MEMBER", "member") + } else { + ("OFFICER", "officer") + }; + if row.action(ui, label) { + out.push(WindowAction::Command(ClientCommand::GuildSetRole { + target_actor_id: member.actor_id.clone(), + role: role.into(), + })); + } + } + } + row.value(ui, &member.role.to_ascii_uppercase()); + if !member.online { + row.value(ui, "OFFLINE"); + } + row.label_tinted( + ui, + &member.name, + if member.online { label() } else { dim() }, + ); + } + if !any { + chrome::empty(ui, pane.x, rows.cursor(), "ROSTER EMPTY"); + } + pane.resume(&rows); + + if model.pa.has_permission("war") { + pane.section(ui, "WARS"); + let mut rows = pane.rows(); + let mut any = false; + for war in &guild.wars { + any = true; + let Some(mut row) = rows.next(ui) else { break }; + let incoming = war.state == "incoming"; + if row.action(ui, if incoming { "ACCEPT" } else { "RESCIND" }) { + let command = if incoming { + ClientCommand::GuildAcceptWar { + opposing_guild_id: war.opposing_guild_id.clone(), + } + } else { + ClientCommand::GuildRescindWar { + opposing_guild_id: war.opposing_guild_id.clone(), + } + }; + out.push(WindowAction::Command(command)); + } + row.value(ui, &war.opposing_tag); + row.label(ui, &war.opposing_name); + } + for candidate in model + .pa + .view + .directory + .iter() + .filter(|entry| entry.id != guild.id) + .take(2) + { + any = true; + let Some(mut row) = rows.next(ui) else { break }; + if row.quiet_action(ui, "DECLARE") { + out.push(WindowAction::Command(ClientCommand::GuildDeclareWar { + opposing_guild_id: candidate.id.clone(), + })); + } + row.value(ui, &candidate.tag); + row.label_tinted(ui, &candidate.name, dim()); + } + if !any { + chrome::empty(ui, pane.x, rows.cursor(), "NO WARS DECLARED"); + } + pane.resume(&rows); + } + + let leader = model.pa.is_leader(); + let labels: &[&str] = if leader { + &["DISBAND ASSOCIATION"] + } else { + &["LEAVE ASSOCIATION"] + }; + if pane.footer(ui, leave, labels).is_some() { + let command = if leader { + ClientCommand::GuildDisband {} + } else { + ClientCommand::GuildLeave {} + }; + out.push(WindowAction::Command(command)); + } +} + +pub fn group(ui: &mut UiBuilder, ctx: Ctx, model: &WindowModel, out: &mut Vec<WindowAction>) { + let mut pane = Pane::open(ctx); + if model.group.group.pending_invite.is_none() + && model.group.group.members.is_empty() + && model.group.group.group.is_none() + && model.group.target.is_none() + && model.group.duel.incoming_challenge.is_none() + && model.group.duel.active_duel.is_none() + && model.group.deathblow_target.is_none() + { + pane.empty(ui, "NO GROUP / SELECT A PLAYER"); + return; + } + if let Some(invite) = &model.group.group.pending_invite { + pane.section(ui, "GROUP INVITE"); + pane.field(ui, "FROM", &invite.inviter_name); + if let Some(index) = pane.rail(ui, &["ACCEPT", "DECLINE"]) { + let command = if index == 0 { + ClientCommand::GroupAccept {} + } else { + ClientCommand::GroupDecline {} + }; + out.push(WindowAction::Command(command)); + } + } + if !model.group.group.members.is_empty() { + pane.section(ui, "ROSTER"); + let mut rows = pane.rows(); + for member in &model.group.group.members { + let Some(mut row) = rows.next(ui) else { break }; + if model.group.is_leader() + && member.actor_id != model.group.my_actor_id + && row.quiet_action(ui, "KICK") + { + out.push(WindowAction::Command(ClientCommand::GroupKick { + target_actor_id: member.actor_id.clone(), + })); + } + row.value( + ui, + &format!( + "HP {:.0}/{:.0}", + member.vitals.health, member.max_vitals.health + ), + ); + if member.link_dead { + row.value(ui, "LINK DEAD"); + } + if member.is_leader { + row.value(ui, "LEADER"); + } + row.label_tinted( + ui, + &member.name, + if member.life_state == "alive" { + label() + } else { + dim() + }, + ); + } + pane.resume(&rows); + } + if model.group.group.group.is_some() { + let leader = model.group.is_leader(); + let labels: &[&str] = if leader { + &["DISBAND GROUP"] + } else { + &["LEAVE GROUP"] + }; + if pane.rail(ui, labels).is_some() { + let command = if leader { + ClientCommand::GroupDisband {} + } else { + ClientCommand::GroupLeave {} + }; + out.push(WindowAction::Command(command)); + } + } else if let Some((actor_id, label, true)) = &model.group.target { + pane.section(ui, "SELECTED PLAYER"); + pane.field(ui, "TARGET", label); + let labels: &[&str] = if model.group.duel.active_duel.is_none() { + &["GROUP INVITE", "DUEL"] + } else { + &["GROUP INVITE"] + }; + if let Some(index) = pane.rail(ui, labels) { + let command = if index == 0 { + ClientCommand::GroupInvite { + target_actor_id: actor_id.clone(), + } + } else { + ClientCommand::DuelChallenge { + target_actor_id: actor_id.clone(), + } + }; + out.push(WindowAction::Command(command)); + } + } else if let Some((_, label, false)) = &model.group.target { + pane.section(ui, "SELECTED PLAYER"); + pane.field(ui, "TARGET", label); + pane.denied(ui, crate::windows::model::DENY_RANGE); + } + if let Some(challenge) = &model.group.duel.incoming_challenge { + pane.section(ui, "DUEL CHALLENGE"); + pane.field(ui, "FROM", &challenge.other_name); + if let Some(index) = pane.rail(ui, &["ACCEPT", "DECLINE"]) { + let command = if index == 0 { + ClientCommand::DuelAccept {} + } else { + ClientCommand::DuelDecline {} + }; + out.push(WindowAction::Command(command)); + } + } + if let Some(duel) = &model.group.duel.active_duel { + pane.section(ui, "DUEL ACTIVE"); + pane.field(ui, "OPPONENT", &duel.opponent_name); + if pane.rail(ui, &["YIELD"]).is_some() { + out.push(WindowAction::Command(ClientCommand::DuelYield {})); + } + } + if let Some((actor_id, label)) = &model.group.deathblow_target { + pane.field(ui, "DOWNED", label); + if pane.rail(ui, &["DEATHBLOW"]).is_some() { + out.push(WindowAction::Command(ClientCommand::Deathblow { + target_actor_id: actor_id.clone(), + })); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hud::Icons; + use crate::windows::model::{GroupMember, GroupVitals, GuildRosterEntry, GuildSummary}; + + fn make_ctx(id: &str) -> Ctx { + Ctx { + spec: crate::windows::spec::surface(id).expect("surface spec"), + rect: [10.0, 10.0, 400.0, 350.0], + tab: 0, + } + } + + #[test] + fn guild_roster_renders_members_and_actions() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let mut model = WindowModel::sample(); + + model.pa.my_actor_id = "actor-me".into(); + model.pa.view.guild = Some(GuildSummary { + id: "pa-1".into(), + name: "Syndicate".into(), + tag: "SYN".into(), + leader_actor_id: "actor-me".into(), + member_count: 2, + ..Default::default() + }); + model.pa.view.roster.push(GuildRosterEntry { + actor_id: "actor-other".into(), + name: "Vader".into(), + role: "officer".into(), + online: true, + ..Default::default() + }); + + let mut out = Vec::new(); + ui.begin(1280, 720); + guild(&mut ui, make_ctx("pa"), &model, &mut out); + + assert!(ui.quads > 0, "guild must render roster and header"); + } + + #[test] + fn group_roster_renders_members_and_vitals() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let mut model = WindowModel::sample(); + + model.group.my_actor_id = "actor-me".into(); + model.group.group.members.push(GroupMember { + actor_id: "actor-other".into(), + name: "Han".into(), + area_id: "dustgate".into(), + is_leader: false, + link_dead: false, + life_state: "alive".into(), + vitals: GroupVitals { + health: 100.0, + action: 100.0, + }, + max_vitals: GroupVitals { + health: 100.0, + action: 100.0, + }, + }); + + let mut out = Vec::new(); + ui.begin(1280, 720); + group(&mut ui, make_ctx("group"), &model, &mut out); + + assert!(ui.quads > 0, "group must render member roster"); + } +} diff --git a/client-rust/source/app/src/windows/live/splice.rs b/client-rust/source/app/src/windows/live/splice.rs new file mode 100644 index 00000000..4aca2d9b --- /dev/null +++ b/client-rust/source/app/src/windows/live/splice.rs @@ -0,0 +1,381 @@ +//! SPLICE — bioengineering gene bench surface. +//! +//! Three-stage splicing flow matching the web client (LAB -> BENCH -> SPLICE). +//! +//! Web reference: `client-3d/src/ui/splice/spliceWindow.ts` +//! Stages: +//! 1. LAB: specimen wild sampling, seed locker scan rows, scanned genome cards, begin splice. +//! 2. BENCH: sequence slots (parents/reagents), allele segregation selection (1A/1B/2A/2B), assemble gate. +//! 3. SPLICE: locus experimentation, remaining point pool, cultivar minting exit. + +use std::cell::RefCell; + +use crate::windows::chrome::{self}; +use crate::windows::live::shared::*; +use crate::windows::{dim, label, Ctx, WindowAction, WindowModel}; +use successor_engine_render::ui::{TextField, UiBuilder}; +use successor_net::ClientCommand; + +thread_local! { + static SPLICE_NAME: RefCell<TextField> = RefCell::new(TextField::new(48)); + static SELECTED_STAGE: RefCell<Option<usize>> = const { RefCell::new(None) }; +} + +#[cfg(test)] +pub(crate) fn reset_ui_state() { + SELECTED_STAGE.with(|s| *s.borrow_mut() = None); + SPLICE_NAME.with(|f| f.borrow_mut().clear()); +} + +pub fn splice(ui: &mut UiBuilder, ctx: Ctx, model: &WindowModel, out: &mut Vec<WindowAction>) { + let mut pane = Pane::open(ctx); + let cancel = if model.splice.session.is_some() { + pane.reserve_footer() + } else { + None + }; + + // Determine auto stage from streamed session state. + let auto_stage = match &model.splice.session { + None => 0, + Some(session) => match session.phase.as_str() { + "browse" => 0, + "slots" => 1, + "assembled" => 2, + _ => 0, + }, + }; + + // Stage rail header: LAB (0), BENCH (1), SPLICE (2). + let rail_labels = ["1:LAB", "2:BENCH", "3:SPLICE"]; + + if let Some(clicked) = pane.rail(ui, &rail_labels) { + SELECTED_STAGE.with(|s| *s.borrow_mut() = Some(clicked)); + } + + let active_stage = SELECTED_STAGE.with(|s| *s.borrow()).unwrap_or(auto_stage); + + if let Some(session) = &model.splice.session { + pane.field_pair( + ui, + ("SPECIES", &session.species_name), + ("PHASE", &session.phase.to_ascii_uppercase()), + ); + } + + match active_stage { + // ── Stage 1: LAB ────────────────────────────────────────────────────── + 0 => { + if let Some((species, label_str, in_range)) = &model.splice.sample_target { + pane.section(ui, "WILD SPECIMEN"); + pane.field(ui, "TARGET", label_str); + if *in_range { + if pane.rail(ui, &["GENE SAMPLE"]).is_some() { + out.push(WindowAction::Command(ClientCommand::GeneSample { + species: species.clone(), + })); + } + } else { + pane.denied(ui, "OUT OF RANGE - STEP CLOSER TO SAMPLE"); + } + } + + pane.section(ui, "SEED LOCKER / SAMPLES"); + if !model.splice.samples.is_empty() { + let mut rows = pane.rows(); + for sample in model.splice.samples.iter().take(4) { + let Some(mut row) = rows.next(ui) else { break }; + if row.action(ui, "SCAN") { + out.push(WindowAction::Command(ClientCommand::ScanGenome { + container: sample.container.clone(), + stack_id: sample.stack_id.clone(), + variant_id: sample.variant_id, + })); + } + row.value(ui, &qty(sample.available)); + row.label(ui, &sample.item); + } + pane.resume(&rows); + } else { + pane.empty(ui, "NO SEED SAMPLES IN PACK"); + } + + if !model.splice.scans.is_empty() { + pane.section(ui, "GENOME SCANS"); + let mut rows = pane.rows(); + for scan in model.splice.scans.iter().take(3) { + let Some(mut row) = rows.next(ui) else { break }; + row.value(ui, &scan.tier.to_ascii_uppercase()); + row.label( + ui, + &format!("{} ({})", scan.cultivar_name, scan.species_name), + ); + } + pane.resume(&rows); + } + + if model.splice.session.is_none() { + if let Some((species, _, _)) = &model.splice.sample_target { + if pane.rail(ui, &["BEGIN SPLICE"]).is_some() { + out.push(WindowAction::Command(ClientCommand::SpliceBegin { + species: species.clone(), + })); + } + } else { + pane.empty(ui, "SELECT A CREATURE OR ACQUIRE A SAMPLE TO BEGIN"); + } + } + } + + // ── Stage 2: BENCH ──────────────────────────────────────────────────── + 1 => { + let session = model.splice.session.as_ref(); + if let Some(session) = session { + pane.section(ui, "SEQUENCE SLOTS"); + if !session.slots.is_empty() { + let mut rows = pane.rows(); + for slot in &session.slots { + let Some(mut row) = rows.next(ui) else { break }; + if slot.filled { + if row.quiet_action(ui, "CLEAR") { + out.push(WindowAction::Command(ClientCommand::SpliceClearSlot { + slot_index: slot.slot_index, + })); + } + row.value(ui, "FILLED"); + row.label_tinted(ui, &slot.label, label()); + } else if let Some(sample) = model.splice.samples.first() { + if row.action(ui, "ASSIGN") { + out.push(WindowAction::Command(ClientCommand::SpliceAssignSlot { + slot_index: slot.slot_index, + container: sample.container.clone(), + stack_id: sample.stack_id.clone(), + variant_id: sample.variant_id, + })); + } + row.value(ui, "EMPTY"); + row.label(ui, &slot.label); + } else { + row.value(ui, "NO SEED"); + row.label_tinted(ui, &slot.label, dim()); + } + } + pane.resume(&rows); + } else { + pane.empty(ui, "NO SEQUENCE SLOTS AVAILABLE"); + } + + if !session.lines.is_empty() { + pane.section(ui, "ALLELE SEGREGATION"); + let mut rows = pane.rows(); + for line in session.lines.iter().take(5) { + let Some(mut row) = rows.next(ui) else { break }; + for (parent, allele, label_str) in + [(1, 1, "2B"), (1, 0, "2A"), (0, 1, "1B"), (0, 0, "1A")] + { + if row.action(ui, label_str) { + out.push(WindowAction::Command( + ClientCommand::SpliceChooseAllele { + locus: line.locus, + from_parent: parent, + allele, + }, + )); + } + } + row.value(ui, &format!("LOCUS {}", line.locus)); + row.label(ui, &line.label); + } + pane.resume(&rows); + } + + if session.can_assemble { + if pane.rail(ui, &["ASSEMBLE"]).is_some() { + out.push(WindowAction::Command(ClientCommand::SpliceAssemble {})); + } + } else { + pane.denied(ui, "UNSEATED PARENTS - SEAT BOTH PARENT LINES TO ASSEMBLE"); + } + } else { + pane.denied(ui, "NO BENCH SESSION - BEGIN SPLICE FIRST"); + } + } + + // ── Stage 3: SPLICE / FINISH ────────────────────────────────────────── + _ => { + let session = model.splice.session.as_ref(); + if let Some(session) = session { + if session.phase == "assembled" { + pane.field_pair( + ui, + ("POINTS LEFT", &session.points_remaining.to_string()), + ( + "ASSEMBLY", + &format!("{:.1}%", session.assembly_quality_milli as f32 / 10.0), + ), + ); + + pane.section(ui, "LOCI EXPERIMENTATION"); + let mut rows = pane.rows(); + for line in session.lines.iter().take(5) { + let Some(mut row) = rows.next(ui) else { break }; + if line.can_raise && session.points_remaining > 0 && row.action(ui, "+1") { + out.push(WindowAction::Command( + ClientCommand::SpliceExperimentLocus { + locus: line.locus, + points: 1, + }, + )); + } + row.value(ui, &format!("{} / {}", line.value_milli, line.cap_milli)); + row.label_tinted( + ui, + &line.label, + if line.can_raise { label() } else { dim() }, + ); + } + pane.resume(&rows); + + pane.section(ui, "CULTIVAR NAME (OPTIONAL)"); + let name_y = pane.y; + SPLICE_NAME.with(|field| { + ui.text_field( + &mut field.borrow_mut(), + pane.x, + name_y, + pane.w, + chrome::FIELD_H, + 1.0, + true, + crate::hud::button_style(), + ); + }); + pane.y += chrome::FIELD_H + 6.0; + + if pane.rail(ui, &["MINT CULTIVAR"]).is_some() { + let entered_name = SPLICE_NAME.with(|f| f.borrow().text.clone()); + let cultivar_name = if entered_name.trim().is_empty() { + None + } else { + Some(entered_name) + }; + out.push(WindowAction::Command(ClientCommand::SpliceMint { + cultivar_name, + })); + } + } else { + pane.denied(ui, "NOT ASSEMBLED YET - COMPLETE BENCH SEGREGATION FIRST"); + } + } else { + pane.denied(ui, "NO BENCH SESSION - BEGIN SPLICE FIRST"); + } + } + } + + if pane.footer(ui, cancel, &["CANCEL"]).is_some() { + out.push(WindowAction::Command(ClientCommand::SpliceCancel {})); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hud::Icons; + use crate::windows::model::*; + use crate::windows::spec; + + fn test_ctx(rect: [f32; 4]) -> Ctx { + Ctx { + spec: spec::surface("splice").expect("splice spec"), + rect, + tab: 0, + } + } + + #[test] + fn test_splice_stage_advance_and_commands() { + reset_ui_state(); + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let rect = [100.0, 100.0, 600.0, 500.0]; + + // 1. Stage 0 (LAB) -> GeneSample & SpliceBegin + let mut model = WindowModel::default(); + model.splice.sample_target = Some(("sunmelon".into(), "Wild Sunmelon".into(), true)); + + // Click GENE SAMPLE rail button + ui.set_input(110.0, 172.0, true); + ui.begin(1280, 720); + let mut out = Vec::new(); + splice(&mut ui, test_ctx(rect), &model, &mut out); + + ui.set_input(110.0, 172.0, false); + ui.begin(1280, 720); + out.clear(); + splice(&mut ui, test_ctx(rect), &model, &mut out); + + assert!( + out.contains(&WindowAction::Command(ClientCommand::GeneSample { + species: "sunmelon".into() + })), + "Expected GeneSample command" + ); + + // 2. Stage 1 (BENCH) -> SpliceAssemble command + reset_ui_state(); + let mut model = WindowModel::default(); + model.splice.session = Some(SpliceSession { + phase: "slots".into(), + species_name: "Sunmelon".into(), + can_assemble: true, + ..Default::default() + }); + + SELECTED_STAGE.with(|s| *s.borrow_mut() = Some(1)); + ui.set_input(110.0, 193.0, true); + ui.begin(1280, 720); + out.clear(); + splice(&mut ui, test_ctx(rect), &model, &mut out); + + ui.set_input(110.0, 193.0, false); + ui.begin(1280, 720); + out.clear(); + splice(&mut ui, test_ctx(rect), &model, &mut out); + + assert!( + out.contains(&WindowAction::Command(ClientCommand::SpliceAssemble {})), + "Expected SpliceAssemble command" + ); + } + + #[test] + fn test_splice_denied_stages() { + reset_ui_state(); + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let rect = [100.0, 100.0, 600.0, 500.0]; + + // Out of range specimen target -> denied state + let mut model = WindowModel::default(); + model.splice.sample_target = Some(("sunmelon".into(), "Wild Sunmelon".into(), false)); + + ui.begin(1280, 720); + let mut out = Vec::new(); + splice(&mut ui, test_ctx(rect), &model, &mut out); + assert!( + !out.contains(&WindowAction::Command(ClientCommand::GeneSample { + species: "sunmelon".into() + })), + "Out of range specimen emits no GeneSample" + ); + + // Select Stage 2 (SPLICE) when session is not assembled -> denied state + let model_unfilled = WindowModel::default(); + SELECTED_STAGE.with(|s| *s.borrow_mut() = Some(2)); + + ui.begin(1280, 720); + out.clear(); + splice(&mut ui, test_ctx(rect), &model_unfilled, &mut out); + assert!(out.is_empty(), "Denied stage 2 emits no commands"); + } +} diff --git a/client-rust/source/app/src/windows/live/survey.rs b/client-rust/source/app/src/windows/live/survey.rs new file mode 100644 index 00000000..edfa73da --- /dev/null +++ b/client-rust/source/app/src/windows/live/survey.rs @@ -0,0 +1,717 @@ +use crate::windows::chrome::{self}; +use crate::windows::live::shared::*; +use crate::windows::{accent, dim, label, value, Ctx, WindowAction, WindowModel}; +use successor_engine_render::ui::UiBuilder; +use successor_net::ClientCommand; + +pub fn survey(ui: &mut UiBuilder, ctx: Ctx, model: &WindowModel, out: &mut Vec<WindowAction>) { + let mut pane = Pane::open(ctx); + // Camp placement belongs to the whole surface, not to either pane, so it + // rides the commit rail and stays reachable from both tabs. + let can_place = !model.survey.own_camp_placed; + let can_pack = model + .survey + .camps + .iter() + .any(|camp| camp.vm.is_owner && camp.in_footprint); + let camp_labels: &[&str] = match (can_place, can_pack) { + (true, true) => &["PLACE CAMP", "PACK UP CAMP"], + (true, false) => &["PLACE CAMP"], + (false, true) => &["PACK UP CAMP"], + (false, false) => &[], + }; + let camp_rail = if camp_labels.is_empty() { + None + } else { + pane.reserve_footer() + }; + + if ctx.tab == 0 { + // Tab 0: RESOURCES & CONCENTRATION READOUT + if model.survey.sample_cooldown_ticks > 0 { + let secs = (model.survey.sample_cooldown_ticks + 29) / 30; + pane.field(ui, "COOLDOWN", &format!("SAMPLE READY IN {secs}s")); + } + + let mut rows = pane.rows(); + let mut any = false; + + for family in &model.survey.families { + any = true; + let result = model.survey.result_for(&family.family); + let rich = result.and_then(|r| r.richest()); + let peak_milli = rich.map(|(_, _, m)| m).unwrap_or(0); + + let height = pane.metrics.row_h + 18.0; + let Some(mut row) = rows.next_tall(ui, height) else { + break; + }; + + // Right-to-left action buttons + if row.action(ui, "PLACE") { + out.push(WindowAction::Command(ClientCommand::PlaceExtractor { + family: family.family.clone(), + })); + } + if row.action(ui, "SURVEY") { + out.push(WindowAction::Command(ClientCommand::SurveyResource { + family: family.family.clone(), + })); + } + if model.survey.sample_cooldown_ticks <= 0 { + if row.action(ui, "SAMPLE") { + out.push(WindowAction::Command(ClientCommand::SampleResource { + family: family.family.clone(), + stop: false, + })); + } + } else { + row.reserve_action(); + } + + // Concentration value readout & caption + let val_str = format!("{:.1}%", peak_milli as f32 / 10.0); + row.value(ui, &val_str); + + match rich { + Some((rx, ry, _)) => { + let spawn_name = result.map(|r| r.spawn_name.as_str()).unwrap_or("UNKNOWN"); + let caption = format!("{spawn_name} - PEAK {rx:.0}, {ry:.0}"); + row.label_caption(ui, &family.label, &caption); + } + None => { + row.label_caption(ui, &family.label, "UNSCANNED"); + } + } + } + + if !any { + chrome::empty(ui, pane.x, rows.cursor(), "NO RESOURCE FAMILIES"); + } + + pane.resume(&rows); + + // Spawn Taxonomy / Breakdown if space remains + if !model.survey.spawns.is_empty() && pane.y + pane.metrics.row_h + 20.0 <= pane.bottom { + pane.section(ui, "SPAWN TAXONOMY"); + let mut spawn_rows = pane.rows(); + // `label_caption` writes a name over a class line, so the row has to + // carry both. A plain `next` row is one line tall and the class line + // lands on top of the following spawn's name. + let height = pane.metrics.row_h + 18.0; + for spawn in &model.survey.spawns { + let Some(mut r) = spawn_rows.next_tall(ui, height) else { + break; + }; + + let active_stats: Vec<String> = spawn + .stats + .rows() + .iter() + .filter(|(_, v)| *v > 0) + .take(3) + .map(|(k, v)| format!("{k} {v}")) + .collect(); + + if !active_stats.is_empty() { + r.value(ui, &active_stats.join(" - ")); + } + r.label_caption(ui, &spawn.name, &spawn.class_label); + } + pane.resume(&spawn_rows); + } + } else { + // Tab 1: EXTRACTORS & PLACED CAMPS + pane.section(ui, "EXTRACTORS"); + let mut rows = pane.rows(); + let mut any_extractors = false; + + for extractor in &model.survey.extractors { + any_extractors = true; + let vm = &extractor.vm; + let owner_verbs = extractor.in_reach && vm.is_owner; + let sub_rail = if owner_verbs { + pane.rail_h() + 4.0 + } else { + 0.0 + }; + let meter_h = 8.0; + let needed_h = pane.metrics.row_h + meter_h + sub_rail; + + if !rows.fits(needed_h) { + break; + } + + let Some(mut row) = rows.next_tall(ui, pane.metrics.row_h + meter_h) else { + break; + }; + + row.value(ui, &format!("YIELD {}", vm.collectable_units)); + row.value(ui, &vm.mode.to_ascii_uppercase()); + + let reach_note = if extractor.in_reach { + "IN REACH" + } else { + "OUT OF REACH" + }; + let caption = format!("CELL {}, {} - {}", vm.cell_x, vm.cell_y, reach_note); + row.label_tinted( + ui, + &vm.family_label, + if extractor.in_reach { label() } else { dim() }, + ); + row.label_caption(ui, &vm.family_label, &caption); + + // Hopper / Battery meters + let frac_hopper = (vm.hopper_pct as f32 / 100.0).clamp(0.0, 1.0); + let frac_batt = (vm.battery_pct as f32 / 100.0).clamp(0.0, 1.0); + let meter_y = row.y + row.h - 5.0; + let meter_w = (row.text_w() - pane.metrics.gutter).max(0.0); + + if meter_w > 0.0 { + let half_w = (meter_w - 4.0) * 0.5; + chrome::meter( + ui, + row.x + pane.metrics.gutter, + meter_y, + half_w, + 3.0, + frac_hopper, + accent(), + ); + if vm.battery_pct > 0.0 || vm.mode == "battery" { + chrome::meter( + ui, + row.x + pane.metrics.gutter + half_w + 4.0, + meter_y, + half_w, + 3.0, + frac_batt, + value(), + ); + } + } + + if owner_verbs { + let labels: &[&str] = if model.survey.batteries.is_empty() { + &["CRANK", "STOP", "COLLECT", "DESTROY"] + } else { + &["CRANK", "STOP", "COLLECT", "DESTROY", "BATTERY"] + }; + let indent = pane.metrics.gutter; + let clicked = chrome::action_rail( + ui, + pane.x + indent, + rows.cursor(), + (pane.w - indent).max(0.0), + labels, + pane.metrics, + ); + if let Some(index) = clicked { + let command = match index { + 0 => Some(ClientCommand::CrankExtractor { + extractor_id: vm.extractor_id.clone(), + }), + 1 => Some(ClientCommand::StopCrank {}), + 2 => Some(ClientCommand::CollectExtractor { + extractor_id: vm.extractor_id.clone(), + }), + 3 => Some(ClientCommand::DestroyExtractor { + extractor_id: vm.extractor_id.clone(), + }), + _ => model.survey.batteries.first().map(|battery| { + ClientCommand::InsertBattery { + extractor_id: vm.extractor_id.clone(), + container: battery.container.clone(), + stack_id: battery.stack_id.clone(), + variant_id: battery.variant_id, + } + }), + }; + if let Some(command) = command { + out.push(WindowAction::Command(command)); + } + } + rows.advance(sub_rail); + } + } + + if !any_extractors { + chrome::empty(ui, pane.x, rows.cursor(), "NO EXTRACTORS PLACED"); + } + + pane.resume(&rows); + + // Placed Camps Section if space remains + if pane.y + pane.metrics.row_h <= pane.bottom { + pane.section(ui, "PLACED CAMPS"); + let mut camp_rows = pane.rows(); + let mut any_camps = false; + + let camp_h = pane.metrics.row_h + 18.0; + for camp in &model.survey.camps { + any_camps = true; + let Some(mut r) = camp_rows.next_tall(ui, camp_h) else { + break; + }; + + let owner_str = if camp.vm.is_owner { "OWNER" } else { "VISITOR" }; + let footprint_str = if camp.in_footprint { + "FOOTPRINT" + } else { + "OUTSIDE" + }; + r.value(ui, footprint_str); + r.value(ui, owner_str); + let caption = format!("CELL {}, {}", camp.vm.cell_x, camp.vm.cell_y); + r.label_caption(ui, &camp.vm.render_kind.to_ascii_uppercase(), &caption); + } + + if !any_camps { + chrome::empty(ui, pane.x, camp_rows.cursor(), "NO CAMPS IN RANGE"); + } + pane.resume(&camp_rows); + } + } + + if let Some(index) = pane.footer(ui, camp_rail, camp_labels) { + let command = if index == 0 && can_place { + ClientCommand::PlaceCamp {} + } else { + ClientCommand::PackUpCamp {} + }; + out.push(WindowAction::Command(command)); + } +} + +pub fn agriculture(ui: &mut UiBuilder, ctx: Ctx, model: &WindowModel, out: &mut Vec<WindowAction>) { + let mut pane = Pane::open(ctx); + let metrics = pane.metrics; + if model.farm.parcels.is_empty() { + pane.field( + ui, + "UNCLAIMED", + &format!("{}, {}", model.farm.player_cell.0, model.farm.player_cell.1), + ); + if pane.rail(ui, &["CLAIM PARCEL"]).is_some() { + out.push(WindowAction::Command(ClientCommand::ClaimParcel { + planet_id: model.farm.planet_id.clone(), + area_id: model.farm.area_id.clone(), + x: model.farm.player_cell.0 as i32, + y: model.farm.player_cell.1 as i32, + tier: "homestead".into(), + })); + } + return; + } + if ctx.tab == 0 { + for parcel in model + .farm + .parcels + .iter() + .filter(|parcel| parcel.is_owner) + .take(2) + { + pane.section(ui, &parcel.name); + pane.field(ui, "TIER", &parcel.tier.to_ascii_uppercase()); + if let Some(index) = pane.rail(ui, &["UPKEEP", "RENAME", "ABANDON"]) { + let command = match index { + 0 => ClientCommand::PayUpkeep { + parcel_id: parcel.parcel_id.clone(), + }, + 1 => ClientCommand::RenameParcel { + parcel_id: parcel.parcel_id.clone(), + name: format!("{} Homestead", parcel.name), + }, + _ => ClientCommand::AbandonParcel { + parcel_id: parcel.parcel_id.clone(), + }, + }; + out.push(WindowAction::Command(command)); + } + if let Some(plot) = model.farm.plot_for(&parcel.parcel_id) { + let mut rows = pane.rows(); + for tile in plot.tiles.iter().take(5) { + let legal = |verb: &str| { + tile.legal_verbs + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(verb)) + }; + let seed = legal("PlantSeed") + .then(|| model.farm.seeds.first()) + .flatten(); + let fertilizer = legal("Fertilize") + .then(|| model.farm.fertilizers.first()) + .flatten(); + let sow_labels: &[&str] = match (seed.is_some(), fertilizer.is_some()) { + (true, true) => &["PLANT", "FERTILIZE"], + (true, false) => &["PLANT"], + (false, true) => &["FERTILIZE"], + (false, false) => &[], + }; + let sow_h = if sow_labels.is_empty() { + 0.0 + } else { + pane.rail_h() + 4.0 + }; + if !rows.fits(metrics.row_h + sow_h) { + break; + } + let Some(mut row) = rows.next(ui) else { break }; + if legal("HarvestCrop") && row.action(ui, "HARVEST") { + out.push(WindowAction::Command(ClientCommand::HarvestCrop { + parcel_id: parcel.parcel_id.clone(), + cell_x: tile.cell_x as i32, + cell_y: tile.cell_y as i32, + })); + } + if legal("ClearTile") && row.quiet_action(ui, "CLEAR") { + out.push(WindowAction::Command(ClientCommand::ClearTile { + parcel_id: parcel.parcel_id.clone(), + cell_x: tile.cell_x as i32, + cell_y: tile.cell_y as i32, + })); + } + if legal("WaterTile") && row.action(ui, "WATER") { + out.push(WindowAction::Command(ClientCommand::WaterTile { + parcel_id: parcel.parcel_id.clone(), + cell_x: tile.cell_x as i32, + cell_y: tile.cell_y as i32, + })); + } + if legal("TillTile") && row.action(ui, "TILL") { + out.push(WindowAction::Command(ClientCommand::TillTile { + parcel_id: parcel.parcel_id.clone(), + cell_x: tile.cell_x as i32, + cell_y: tile.cell_y as i32, + })); + } + row.value(ui, &format!("{:.0}% WET", tile.moisture_pct)); + row.value( + ui, + tile.crop + .as_ref() + .map(|crop| crop.species.as_str()) + .unwrap_or(if tile.tilled { "TILLED" } else { "UNTILLED" }), + ); + row.label(ui, &format!("{}, {}", tile.cell_x, tile.cell_y)); + if sow_labels.is_empty() { + continue; + } + let indent = metrics.gutter; + let clicked = chrome::action_rail( + ui, + pane.x + indent, + rows.cursor(), + (pane.w - indent).max(0.0), + sow_labels, + metrics, + ); + if let Some(index) = clicked { + let command = if index == 0 && seed.is_some() { + seed.map(|seed| ClientCommand::PlantSeed { + parcel_id: parcel.parcel_id.clone(), + cell_x: tile.cell_x as i32, + cell_y: tile.cell_y as i32, + container: seed.container.clone(), + stack_id: seed.stack_id.clone(), + variant_id: seed.variant_id, + }) + } else { + fertilizer.map(|fertilizer| ClientCommand::Fertilize { + parcel_id: parcel.parcel_id.clone(), + cell_x: tile.cell_x as i32, + cell_y: tile.cell_y as i32, + container: fertilizer.container.clone(), + stack_id: fertilizer.stack_id.clone(), + variant_id: fertilizer.variant_id, + }) + }; + if let Some(command) = command { + out.push(WindowAction::Command(command)); + } + } + rows.advance(sow_h); + } + pane.resume(&rows); + } + let structure = model.farm.structures.first(); + let labels: &[&str] = if structure.is_some() { + &["TEND PLOT", "PLACE STRUCTURE"] + } else { + &["TEND PLOT"] + }; + if let Some(index) = pane.rail(ui, labels) { + let command = if index == 0 { + Some(ClientCommand::TendPlot { + parcel_id: parcel.parcel_id.clone(), + stop: false, + }) + } else { + structure.map(|structure| ClientCommand::PlaceFarmStructure { + parcel_id: parcel.parcel_id.clone(), + structure_item_id: structure.item_id, + cell_x: model.farm.player_cell.0 as i32, + cell_y: model.farm.player_cell.1 as i32, + }) + }; + if let Some(command) = command { + out.push(WindowAction::Command(command)); + } + } + } + return; + } + + let mut targeted = false; + if let (Some(parcel), Some(ghost)) = (&model.build.parcel, &model.build.ghost) { + targeted = true; + pane.section(ui, "CATALOG"); + let mut rows = pane.rows(); + for item in model.build.catalog.iter().take(4) { + let Some(mut row) = rows.next(ui) else { break }; + let enabled = ghost.valid && model.build.affordable(item); + if enabled && row.action(ui, "PLACE") { + out.push(WindowAction::Command(ClientCommand::BuildPlace { + catalog_id: item.catalog_id.clone(), + parcel_id: parcel.parcel_id.clone(), + cell_x: ghost.cell_x as i32, + cell_y: ghost.cell_y as i32, + rotation_quarters: ghost.rotation_quarters, + palette: None, + })); + } + let mut costs = String::new(); + for (material, units) in &item.costs { + if !costs.is_empty() { + costs.push_str(", "); + } + costs.push_str(&units.to_string()); + costs.push(' '); + costs.push_str(&material.to_ascii_uppercase()); + } + row.value(ui, &costs); + row.label_tinted(ui, &item.label, if enabled { label() } else { dim() }); + } + pane.resume(&rows); + } + pane.section(ui, "PLACED"); + let mut rows = pane.rows(); + let mut placed = false; + for component in model.build.components.iter().take(3) { + placed = true; + let Some(mut row) = rows.next(ui) else { break }; + if row.quiet_action(ui, "REMOVE") { + out.push(WindowAction::Command(ClientCommand::BuildRemove { + component_id: component.component_id.clone(), + })); + } + if component.kind.contains("door") && row.action(ui, "TOGGLE") { + out.push(WindowAction::Command(ClientCommand::BuildToggleDoor { + component_id: component.component_id.clone(), + })); + } + row.label(ui, &component.catalog_id); + } + if !placed { + chrome::empty( + ui, + pane.x, + rows.cursor(), + if targeted { + "NOTHING PLACED YET" + } else { + "NO PLACEMENT TARGET" + }, + ); + } + pane.resume(&rows); +} +#[cfg(test)] +mod tests { + use super::*; + use crate::windows::model::{ + CampView, ExtractorView, PlacedCamp, PlacedExtractor, ResourceSpawn, ResourceStats, + SurveyFamilyOption, SurveyResult, + }; + use crate::windows::spec; + + fn test_ctx(tab: usize) -> Ctx { + Ctx { + spec: spec::surface("survey").expect("survey surface spec"), + rect: [0.0, 0.0, 360.0, 432.0], + tab, + } + } + + #[test] + fn test_empty_resource_state() { + let icons = crate::hud::Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + ui.begin(1280, 720); + let ctx = test_ctx(0); + let model = WindowModel::default(); + let mut out = Vec::new(); + survey(&mut ui, ctx, &model, &mut out); + assert!(out.is_empty(), "empty survey model should emit no actions"); + } + + #[test] + fn test_concentration_readout() { + let icons = crate::hud::Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + ui.begin(1280, 720); + let ctx = test_ctx(0); + let mut model = WindowModel::default(); + model.survey.families = vec![SurveyFamilyOption { + family: "metal".into(), + label: "METAL".into(), + }]; + model.survey.results = vec![SurveyResult { + family: "metal".into(), + area_id: "area-1".into(), + spawn_id: "spawn-1".into(), + spawn_name: "Iron Ore Vein".into(), + center_x: 10.0, + center_y: -20.0, + range_cells: 30, + step_cells: 2, + cols: 3, + rows: 3, + concentration_milli: vec![100, 750, 400, 200, 850, 300, 0, 50, 600], + cooldown_until_tick: 0, + tick: 100, + }]; + model.survey.spawns = vec![ResourceSpawn { + spawn_id: "spawn-1".into(), + family: "metal".into(), + name: "Iron Ore Vein".into(), + class_label: "Mineral Deposit".into(), + variant_id: 1, + stats: ResourceStats { + density: 85, + conductivity: 90, + ..Default::default() + }, + }]; + + let mut out = Vec::new(); + survey(&mut ui, ctx, &model, &mut out); + assert!(out.is_empty()); + } + + #[test] + fn test_survey_sample_place_commands() { + let icons = crate::hud::Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let ctx = test_ctx(0); + + let mut model = WindowModel::default(); + model.survey.families = vec![SurveyFamilyOption { + family: "metal".into(), + label: "METAL".into(), + }]; + + // 1. Test PLACE button click + // Row 0 at y ~ 0.0..36.0. PLACE button action is placed right-to-left. + let (px, py) = (320.0, 15.0); + ui.set_input(px, py, true); + ui.begin(1280, 720); + let mut out = Vec::new(); + survey(&mut ui, ctx, &model, &mut out); + + ui.set_input(px, py, false); + ui.begin(1280, 720); + out.clear(); + survey(&mut ui, ctx, &model, &mut out); + + assert!( + out.contains(&WindowAction::Command(ClientCommand::PlaceExtractor { + family: "metal".into() + })), + "clicking PLACE should emit PlaceExtractor command" + ); + + // 2. Test SURVEY button click (middle action button in row) + let (sx, sy) = (230.0, 15.0); + ui.set_input(sx, sy, true); + ui.begin(1280, 720); + out.clear(); + survey(&mut ui, ctx, &model, &mut out); + + ui.set_input(sx, sy, false); + ui.begin(1280, 720); + out.clear(); + survey(&mut ui, ctx, &model, &mut out); + + assert!( + out.contains(&WindowAction::Command(ClientCommand::SurveyResource { + family: "metal".into() + })), + "clicking SURVEY should emit SurveyResource command" + ); + + // 3. Test SAMPLE button click (leftmost action button in row) + let (smx, smy) = (140.0, 15.0); + ui.set_input(smx, smy, true); + ui.begin(1280, 720); + out.clear(); + survey(&mut ui, ctx, &model, &mut out); + + ui.set_input(smx, smy, false); + ui.begin(1280, 720); + out.clear(); + survey(&mut ui, ctx, &model, &mut out); + + assert!( + out.contains(&WindowAction::Command(ClientCommand::SampleResource { + family: "metal".into(), + stop: false, + })), + "clicking SAMPLE should emit SampleResource command" + ); + } + + #[test] + fn test_extractors_and_camps_tab() { + let icons = crate::hud::Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + ui.begin(1280, 720); + let ctx = test_ctx(1); + let mut model = WindowModel::default(); + model.survey.extractors = vec![ExtractorView { + vm: PlacedExtractor { + extractor_id: "ext-1".into(), + area_id: "area-1".into(), + cell_x: 10, + cell_y: 20, + mode: "manual".into(), + biome: "desert".into(), + hopper_pct: 45.0, + collectable_units: 120, + battery_pct: 0.0, + is_owner: true, + family_label: "METAL EXTRACTOR".into(), + }, + distance: 1.0, + in_reach: true, + }]; + model.survey.camps = vec![CampView { + vm: PlacedCamp { + camp_id: "camp-1".into(), + area_id: "area-1".into(), + cell_x: 5, + cell_y: 5, + is_owner: true, + render_kind: "frontier".into(), + abandon_seconds_remaining: None, + }, + distance: 1.0, + in_footprint: true, + }]; + + let mut out = Vec::new(); + survey(&mut ui, ctx, &model, &mut out); + assert!(out.is_empty()); + } +} diff --git a/client-rust/source/app/src/windows/live/trade.rs b/client-rust/source/app/src/windows/live/trade.rs new file mode 100644 index 00000000..986bf3fc --- /dev/null +++ b/client-rust/source/app/src/windows/live/trade.rs @@ -0,0 +1,387 @@ +//! The session is server-authoritative: `TradeSession` arrives over the wire +//! and this file never advances a stage on its own. What the pane owes the +//! player is that a sealed offer cannot be mutated from here - every control +//! that would change the offer disappears the moment `mine.locked` is set, so a +//! stale frame cannot emit a mutation the server has already sealed against. + +use crate::windows::chrome::{self}; +use crate::windows::live::shared::*; +use crate::windows::{dim, Ctx, WindowAction, WindowModel}; +use successor_engine_render::ui::UiBuilder; +use successor_net::{ClientCommand, TradeItemSpec}; + +pub fn trade(ui: &mut UiBuilder, ctx: Ctx, model: &WindowModel, out: &mut Vec<WindowAction>) { + let mut pane = Pane::open(ctx); + let Some(session) = &model.trade.session else { + if let Some((actor_id, label)) = &model.trade.propose_target { + pane.field(ui, "TARGET", label); + if pane.rail(ui, &["PROPOSE TRADE"]).is_some() { + out.push(WindowAction::Command(ClientCommand::ProposeTrade { + partner_actor_id: actor_id.clone(), + offer: Vec::new(), + request: Vec::new(), + })); + } + } else { + pane.empty(ui, "SELECT A PLAYER"); + } + return; + }; + const ACCEPT: u8 = 0; + const CONFIRM: u8 = 1; + const DECLINE: u8 = 2; + let mut labels: [&str; 3] = [""; 3]; + let mut kinds: [u8; 3] = [0; 3]; + let mut count = 0usize; + if !session.mine.locked { + labels[count] = "SEAL OFFER"; + kinds[count] = ACCEPT; + count += 1; + } + if session.both_locked && !session.mine.confirmed { + labels[count] = "CONFIRM SWAP"; + kinds[count] = CONFIRM; + count += 1; + } + labels[count] = "DECLINE"; + kinds[count] = DECLINE; + count += 1; + let commit = pane.reserve_footer(); + + pane.field_pair( + ui, + ("PARTNER", &model.trade.partner_label), + ("STAGE", &session.stage.to_ascii_uppercase()), + ); + pane.field_pair( + ui, + ( + "YOUR SEAL", + if session.mine.locked { + "SEALED" + } else { + "UNSEALED" + }, + ), + ( + "THEIR SEAL", + if session.theirs.locked { + "SEALED" + } else { + "UNSEALED" + }, + ), + ); + + pane.section(ui, "YOU OFFER"); + let mut rows = pane.rows(); + let mut any = false; + for line in &session.mine.items { + any = true; + let Some(mut list) = rows.next(ui) else { break }; + if !session.mine.locked && list.quiet_action(ui, "REMOVE") { + out.push(WindowAction::Command(ClientCommand::RemoveTradeItem { + proposal_id: session.proposal_id, + item: TradeItemSpec { + item_id: line.item_id, + variant_id: line.variant_id, + quantity: line.quantity.max(0) as u32, + }, + })); + } + list.value(ui, &qty(line.quantity)); + list.label(ui, &line.name); + } + if !any { + chrome::empty(ui, pane.x, rows.cursor(), "NOTHING OFFERED"); + } + pane.resume(&rows); + + pane.section(ui, "THEY OFFER"); + let mut rows = pane.rows(); + let mut any = false; + for line in &session.theirs.items { + any = true; + let Some(mut list) = rows.next(ui) else { break }; + list.value(ui, &qty(line.quantity)); + list.label_tinted(ui, &line.name, dim()); + } + if !any { + chrome::empty(ui, pane.x, rows.cursor(), "NOTHING OFFERED"); + } + pane.resume(&rows); + + pane.field_pair( + ui, + ("YOUR CREDITS", &format!("{} CR", session.mine.coin)), + ("THEIR CREDITS", &format!("{} CR", session.theirs.coin)), + ); + if !session.mine.locked { + if let Some(index) = pane.rail(ui, &["+100 CR", "+1,000 CR", "CLEAR CR"]) { + let amount = match index { + 0 => session.mine.coin.saturating_add(100) as u64, + 1 => session.mine.coin.saturating_add(1000) as u64, + _ => 0, + }; + out.push(WindowAction::Command(ClientCommand::SetTradeCoin { + proposal_id: session.proposal_id, + amount, + })); + } + } + + pane.section(ui, "YOUR INVENTORY"); + let mut rows = pane.rows(); + let mut any = false; + for row in &model.trade.offerable { + any = true; + let Some(mut list) = rows.next(ui) else { break }; + if row.available > 0 && !session.mine.locked && list.action(ui, "ADD") { + out.push(WindowAction::Command(ClientCommand::AddTradeItem { + proposal_id: session.proposal_id, + item: TradeItemSpec { + item_id: row.item_id, + variant_id: row.variant_id, + quantity: 1, + }, + })); + } + list.value(ui, &qty(row.available)); + list.label(ui, &row.item); + } + if !any { + chrome::empty(ui, pane.x, rows.cursor(), "NOTHING TO OFFER"); + } + pane.resume(&rows); + + if let Some(index) = pane.footer(ui, commit, &labels[..count]) { + let command = match kinds[index] { + ACCEPT => ClientCommand::AcceptTrade { + proposal_id: session.proposal_id, + }, + CONFIRM => ClientCommand::ConfirmTrade { + proposal_id: session.proposal_id, + }, + _ => ClientCommand::DeclineTrade { + proposal_id: session.proposal_id, + }, + }; + out.push(WindowAction::Command(command)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hud::Icons; + use crate::windows::model::{TradeItemLine, TradeSession, TradeSide}; + + fn test_ctx(rect: [f32; 4]) -> Ctx { + Ctx { + spec: crate::windows::spec::surface("trade").expect("trade surface"), + rect, + tab: 0, + } + } + + const RECT: [f32; 4] = [100.0, 100.0, 400.0, 600.0]; + + fn fixture_open_session() -> TradeSession { + TradeSession { + proposal_id: 42, + partner_actor_id: "partner_1".to_string(), + mine: TradeSide { + actor_id: "me".to_string(), + items: vec![], + coin: 0, + locked: false, + confirmed: false, + }, + theirs: TradeSide { + actor_id: "partner_1".to_string(), + items: vec![], + coin: 0, + locked: false, + confirmed: false, + }, + both_locked: false, + stage: "negotiating".to_string(), + close_reason: None, + tick: 100, + } + } + + /// Every command the pane will emit anywhere in its rect. Sweeping beats + /// hand-computed hit points: row layout shifts with the theme's metrics, so + /// a fixed coordinate silently stops covering the control it was aimed at + /// and the test keeps passing. + fn commands_under_sweep(model: &WindowModel) -> Vec<ClientCommand> { + let icons = Icons::load(); + let mut found = Vec::new(); + let mut y = RECT[1] + 4.0; + while y < RECT[1] + RECT[3] { + let mut x = RECT[0] + 4.0; + while x < RECT[0] + RECT[2] { + let mut ui = UiBuilder::new(icons.meta); + let mut out = Vec::new(); + ui.set_input(x, y, true); + ui.begin(1280, 900); + trade(&mut ui, test_ctx(RECT), model, &mut out); + ui.set_input(x, y, false); + ui.begin(1280, 900); + out.clear(); + trade(&mut ui, test_ctx(RECT), model, &mut out); + for action in out { + let WindowAction::Command(command) = action else { + continue; + }; + if !found.contains(&command) { + found.push(command); + } + } + x += 12.0; + } + y += 6.0; + } + found + } + + fn fixture_live_model(locked: bool) -> WindowModel { + let mut model = WindowModel::default(); + let mut session = fixture_open_session(); + session.mine.locked = locked; + session.mine.items = vec![TradeItemLine { + item_id: 101, + variant_id: 1, + name: "VIBROBLADE".to_string(), + quantity: 1, + }]; + session.theirs.items = vec![TradeItemLine { + item_id: 202, + variant_id: 2, + name: "SPICE".to_string(), + quantity: 4, + }]; + model.trade.offerable = vec![crate::windows::model::InventoryRow { + item_id: 303, + variant_id: 3, + item: "MEDPAC".to_string(), + available: 2, + ..Default::default() + }]; + model.trade.partner_label = "SOLO TRADER".to_string(); + model.trade.session = Some(session); + model + } + + #[test] + fn an_open_offer_exposes_every_negotiation_control() { + let found = commands_under_sweep(&fixture_live_model(false)); + + assert!( + found.iter().any( + |c| matches!(c, ClientCommand::AddTradeItem { item, .. } if item.item_id == 303) + ), + "inventory rows must offer ADD; got {found:?}" + ); + assert!( + found.iter().any( + |c| matches!(c, ClientCommand::RemoveTradeItem { item, .. } if item.item_id == 101) + ), + "own offer rows must offer REMOVE; got {found:?}" + ); + assert!( + found + .iter() + .any(|c| matches!(c, ClientCommand::SetTradeCoin { .. })), + "credit rail must be reachable; got {found:?}" + ); + assert!( + found.contains(&ClientCommand::AcceptTrade { proposal_id: 42 }), + "commit rail must offer SEAL; got {found:?}" + ); + } + + #[test] + fn sealing_withdraws_every_control_that_would_change_the_offer() { + let found = commands_under_sweep(&fixture_live_model(true)); + + for command in &found { + assert!( + !matches!( + command, + ClientCommand::AddTradeItem { .. } + | ClientCommand::RemoveTradeItem { .. } + | ClientCommand::SetTradeCoin { .. } + | ClientCommand::AcceptTrade { .. } + ), + "a sealed offer must not be mutable from the pane; reached {command:?}" + ); + } + assert!( + found.contains(&ClientCommand::DeclineTrade { proposal_id: 42 }), + "declining must survive the seal; got {found:?}" + ); + } + + #[test] + fn a_dual_seal_opens_the_confirm_rail() { + let mut model = fixture_live_model(true); + let session = model.trade.session.as_mut().expect("session"); + session.theirs.locked = true; + session.both_locked = true; + session.stage = "confirm".to_string(); + + let found = commands_under_sweep(&model); + + assert!( + found.contains(&ClientCommand::ConfirmTrade { proposal_id: 42 }), + "both seals set must expose CONFIRM SWAP; got {found:?}" + ); + } + + #[test] + fn a_live_session_paints_both_offer_columns() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let mut out = Vec::new(); + ui.begin(1280, 900); + trade( + &mut ui, + test_ctx(RECT), + &fixture_live_model(false), + &mut out, + ); + + assert!(ui.quads > 0, "a live trade session must paint its columns"); + } + + #[test] + fn test_trade_ui_no_session_propose() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let mut model = WindowModel::default(); + model.trade.propose_target = Some(("partner_99".to_string(), "SOLO TRADER".to_string())); + + let ctx = test_ctx(RECT); + let bx = RECT[0] + 10.0; + let by = RECT[1] + 30.0; + ui.set_input(bx, by, true); + ui.begin(1280, 900); + let mut out = Vec::new(); + trade(&mut ui, ctx, &model, &mut out); + + ui.set_input(bx, by, false); + ui.begin(1280, 900); + out.clear(); + trade(&mut ui, ctx, &model, &mut out); + + assert!( + out.contains(&WindowAction::Command(ClientCommand::ProposeTrade { + partner_actor_id: "partner_99".to_string(), + offer: vec![], + request: vec![], + })) + ); + } +} diff --git a/client-rust/source/app/src/windows/live/travel.rs b/client-rust/source/app/src/windows/live/travel.rs new file mode 100644 index 00000000..35f5adc5 --- /dev/null +++ b/client-rust/source/app/src/windows/live/travel.rs @@ -0,0 +1,215 @@ +use crate::windows::chrome::{self}; +use crate::windows::live::shared::*; +use crate::windows::{dim, label, Ctx, WindowAction, WindowModel}; +use successor_engine_render::ui::UiBuilder; +use successor_net::ClientCommand; + +pub fn travel(ui: &mut UiBuilder, ctx: Ctx, model: &WindowModel, out: &mut Vec<WindowAction>) { + let mut pane = Pane::open(ctx); + if !model.travel.gate.available { + pane.denied(ui, &model.travel.gate.note); + return; + } + let terminal_prop_id = model + .travel + .gate + .prop_id + .as_ref() + .expect("open travel gate has a prop id"); + + if let Some((origin_planet, origin_city)) = &model.travel.origin { + pane.field_pair( + ui, + ( + "ORIGIN", + &format!("{origin_planet} / {origin_city}").to_ascii_uppercase(), + ), + ("WALLET", &format!("{} CR", model.travel.wallet_credits)), + ); + } else { + pane.field(ui, "WALLET", &format!("{} CR", model.travel.wallet_credits)); + } + + let mut rows = pane.rows(); + let mut any = false; + if ctx.tab == 0 { + for planet in &model.travel.planets { + for city in &planet.cities { + any = true; + let is_origin = model + .travel + .origin + .as_ref() + .is_some_and(|origin| origin.0 == planet.id && origin.1 == city.id); + let Some(mut row) = rows.next(ui) else { break }; + if !is_origin && row.action(ui, "BUY TICKET") { + out.push(WindowAction::Command(ClientCommand::PurchaseTravelTicket { + terminal_prop_id: terminal_prop_id.clone(), + to_planet_id: planet.id.clone(), + to_city_id: city.id.clone(), + })); + } + if is_origin { + row.value(ui, "HERE"); + } else { + row.value(ui, &format!("{} CR", city.price)); + } + row.value(ui, &planet.label); + row.label_tinted(ui, &city.label, if is_origin { dim() } else { label() }); + } + } + if !any { + chrome::empty(ui, pane.x, rows.cursor(), "NO DESTINATIONS"); + } + } else { + for ticket in &model.travel.tickets { + any = true; + let Some(mut row) = rows.next(ui) else { break }; + let travel = ticket + .metadata + .as_ref() + .and_then(|meta| meta.get("travelTicket")); + let ticket_id = travel + .and_then(|value| value.get("ticketId")) + .and_then(serde_json::Value::as_str) + .map(str::to_string); + if row.action(ui, "USE") { + out.push(WindowAction::Command(ClientCommand::UseTravelTicket { + container: Some(ticket.container.clone()), + stack_id: Some(ticket.stack_id.clone()), + ticket_id, + item_id: ticket.item_key.clone(), + item_numeric_id: Some(ticket.item_id), + variant_id: Some(ticket.variant_id), + })); + } + row.label( + ui, + &ticket + .ticket_destination() + .unwrap_or_else(|| ticket.item.to_ascii_uppercase()), + ); + } + if !any { + chrome::empty(ui, pane.x, rows.cursor(), "NO TICKETS HELD"); + } + } + pane.resume(&rows); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hud::Icons; + use crate::windows::model::{Gate, InventoryRow, TravelCity, TravelPlanet}; + use crate::windows::spec; + + fn test_ctx(rect: [f32; 4], tab: usize) -> Ctx { + Ctx { + spec: spec::surface("travel").expect("travel spec"), + rect, + tab, + } + } + + #[test] + fn test_travel_gate_closed() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let ctx = test_ctx([0.0, 0.0, 400.0, 300.0], 0); + let mut model = WindowModel::default(); + model.travel.gate = Gate::closed("TERMINAL OUT OF RANGE"); + let mut out = Vec::new(); + travel(&mut ui, ctx, &model, &mut out); + assert!(out.is_empty()); + } + + #[test] + fn test_travel_buy_ticket_dispatch() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let ctx = test_ctx([0.0, 0.0, 400.0, 300.0], 0); + let mut model = WindowModel::default(); + model.travel.gate = Gate::open("travel_term_1"); + model.travel.origin = Some(("origin-world".into(), "origin-city".into())); + model.travel.planets = vec![TravelPlanet { + id: "corellia".into(), + label: "Corellia".into(), + cities: vec![TravelCity { + id: "coronet".into(), + label: "Coronet".into(), + terminal_prop_id: "term_coronet".into(), + price: 150, + }], + }]; + + // Action button is placed on the row. + let row_y = ctx.rect[1] + 24.0; + let action_x = ctx.rect[0] + ctx.rect[2] - 30.0; + + // Frame 1: Press + ui.set_input(action_x, row_y, true); + ui.begin(1280, 720); + let mut out = Vec::new(); + travel(&mut ui, ctx, &model, &mut out); + + // Frame 2: Release + ui.set_input(action_x, row_y, false); + ui.begin(1280, 720); + out.clear(); + travel(&mut ui, ctx, &model, &mut out); + + assert_eq!(out.len(), 1); + assert!(matches!( + &out[0], + WindowAction::Command(ClientCommand::PurchaseTravelTicket { to_planet_id, to_city_id, .. }) + if to_planet_id == "corellia" && to_city_id == "coronet" + )); + } + + #[test] + fn test_travel_use_ticket_dispatch() { + let icons = Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let ctx = test_ctx([0.0, 0.0, 400.0, 300.0], 1); // tab 1: tickets + let mut model = WindowModel::default(); + model.travel.gate = Gate::open("travel_term_1"); + model.travel.tickets = vec![InventoryRow { + container: "player:pack".into(), + stack_id: "ticket_1".into(), + item: "Corellia Ticket".into(), + item_id: 9001, + variant_id: 1, + quantity: 1, + metadata: Some(serde_json::json!({ + "travelTicket": { + "ticketId": "t_100", + "toPlanetId": "corellia", + "toCityId": "coronet" + } + })), + ..Default::default() + }]; + let row_y = ctx.rect[1] + 24.0; + let action_x = ctx.rect[0] + ctx.rect[2] - 30.0; + + // Frame 1: Press + ui.set_input(action_x, row_y, true); + ui.begin(1280, 720); + let mut out = Vec::new(); + travel(&mut ui, ctx, &model, &mut out); + + // Frame 2: Release + ui.set_input(action_x, row_y, false); + ui.begin(1280, 720); + out.clear(); + travel(&mut ui, ctx, &model, &mut out); + + assert_eq!(out.len(), 1); + assert!(matches!( + &out[0], + WindowAction::Command(ClientCommand::UseTravelTicket { ticket_id, .. }) + if ticket_id.as_deref() == Some("t_100") + )); + } +} diff --git a/client-rust/source/app/src/windows/mod.rs b/client-rust/source/app/src/windows/mod.rs new file mode 100644 index 00000000..03944b77 --- /dev/null +++ b/client-rust/source/app/src/windows/mod.rs @@ -0,0 +1,675 @@ +//! Window content: the per-surface layouts drawn inside `WindowManager` frames. +//! +//! [`content`] is the single dispatch point. It resolves the id to its +//! [`spec::Surface`] (family, tabs, geometry, chrome density), draws the shared +//! header and tab strip, then hands the surface a [`Ctx`] carrying the body rect +//! and the active tab. Surfaces are read-only over a typed [`WindowModel`] +//! (projected from the authority store in the connected client), so content +//! rendering stays deterministic, unit-testable, and free of transport concerns. +//! +//! Routing is total over the registry: [`spec::route`] maps every registered id +//! to a [`spec::Route`] and the match below is exhaustive over that enum, so a +//! new surface cannot silently fall through to a stub. + +use successor_engine_render::ui::UiBuilder; + +pub mod chrome; +pub mod live; +pub mod model; +pub mod project; +pub mod spec; + +pub use model::*; + +/// Intents a window emits; the host translates them into `ClientCommand`s. +#[derive(Clone, Debug, PartialEq)] +pub enum WindowAction { + /// The canonical live path: an exact shared wire command with its full + /// typed payload, built by the window from projected authority state. + /// `resolve()` forwards it verbatim through `CommandQueue`; development-only + /// commands are refused on this path. + Command(successor_net::ClientCommand), + Close, + UseItem(u32), + EquipItem(u32), + DropItem(u32), + Select(u32), + SetProfessionTitle(String), + Deposit(u32, u32), + Withdraw(u32, u32), + LootItem(u32), + LootAll, + TradeOffer(u32), + TradeAccept, + Craft(String), + Survey, + DialogueChoice(usize), + TravelTo(String), + Toggle(String), + Button(String), + // ── Section-7 surfaces (HUD/social/support owner) ────────────────── + /// Open a window by id (action browser → context surface, dock routes). + OpenWindow(String), + /// Right-click on an inventory card: the host opens the object radial over + /// this exact stack (`SwgCuiInventory::openSelectedRadial`). The window has + /// already made the stack the live selection, so the footer and the radial + /// address the same object. + OpenInventoryRadial { + container: String, + stack_id: String, + }, + /// Theme swatch pick (index into `hud::THEMES`). + SetTheme(usize), + /// Live session dust/edge-fog dial (0..1, 0.05 grid). + SetDust(f32), + /// Window fill opacity scale (0.35..=1.0). + SetWindowOpacity(f32), + /// HUD fill opacity scale (0.35..=1.0). + SetHudOpacity(f32), + /// Inventory default stack-split snap step (1|5|10|100|1000|10000). + SetSplitSnap(u32), + /// Begin pending key capture for a toolbar slot (options → toolbar). + RebindToolbarSlot(usize), + /// Action browser: begin assign-to-slot for a registry action id. + BeginAssignAction(String), + /// Action browser: run a registry action through the public verb path. + RunActionId(String), + /// Macro bench intents (engine is host-owned; gates stay in the public + /// action path). + RunMacro(String), + StopMacro(String), + SaveMacro { + name: String, + body: String, + }, + DeleteMacro(String), + /// Bug report submit (body already sanitized/bounded) + received reset. + SubmitBugReport { + category: String, + body: String, + }, + BugReportReset, + /// Datapad waypoint mutations (character-scoped local store). + CreateWaypoint { + x: f32, + y: f32, + name: Option<String>, + }, + RenameWaypoint { + id: u32, + name: String, + }, + SetWaypointActive { + id: u32, + active: bool, + }, + DeleteWaypoint(u32), + /// Datapad DATA tab exchange routes. + ExchangeRetrieve(String), + ExchangeStore(String), +} + +/// Result of translating a window intent. Commands are submitted by the host +/// through `CommandQueue`; local intents never touch authority projection. +#[derive(Clone, Debug, PartialEq)] +pub enum WindowActionResult { + Command(successor_net::ClientCommand), + Local(WindowLocalAction), + Rejected(String), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum WindowLocalAction { + Close, + Select(u32), + OpenWindow(String), + /// Host opens the inventory object radial over this stack. + OpenInventoryRadial { + container: String, + stack_id: String, + }, + SetTheme(usize), + SetDust(f32), + SetWindowOpacity(f32), + SetHudOpacity(f32), + SetSplitSnap(u32), + RebindToolbarSlot(usize), + BeginAssignAction(String), + RunMacro(String), + StopMacro(String), + SaveMacro { + name: String, + body: String, + }, + DeleteMacro(String), + SubmitBugReport { + category: String, + body: String, + }, + BugReportReset, + CreateWaypoint { + x: f32, + y: f32, + name: Option<String>, + }, + RenameWaypoint { + id: u32, + name: String, + }, + SetWaypointActive { + id: u32, + active: bool, + }, + DeleteWaypoint(u32), +} + +impl WindowAction { + /// Translate every window intent without mutating the authority projection. + /// Context-free legacy intents fail visibly rather than being discarded. + pub fn resolve(self) -> WindowActionResult { + use WindowAction::*; + match self { + Command(command) => { + // Release builds refuse debug commands outright. Under + // `dev-tools` they pass, because that is the only build where + // the character builder exists to emit them - and the authority + // still refuses them unless GAME_DEBUG_AUTHORITY_COMMANDS is + // set, so this is the outer of two independent gates. + #[cfg(not(feature = "dev-tools"))] + if command.is_debug_only() { + return WindowActionResult::Rejected( + "debug commands are development-gated".into(), + ); + } + WindowActionResult::Command(command) + } + Close => WindowActionResult::Local(WindowLocalAction::Close), + Select(id) => WindowActionResult::Local(WindowLocalAction::Select(id)), + UseItem(id) => { + WindowActionResult::Command(successor_net::ClientCommand::UseConsumable { + item_id: id.to_string(), + item_numeric_id: Some(id), + variant_id: None, + }) + } + EquipItem(id) => { + WindowActionResult::Command(successor_net::ClientCommand::SetEquippedWeapon { + weapon_id: None, + weapon_item_id: Some(id), + weapon_variant_id: None, + }) + } + DropItem(_) => WindowActionResult::Rejected( + "discard requires container, stack, and variant".into(), + ), + SetProfessionTitle(title) => { + WindowActionResult::Command(successor_net::ClientCommand::SetProfessionTitle { + title_id: Some(title), + }) + } + Deposit(stack, quantity) => { + WindowActionResult::Command(successor_net::ClientCommand::BankStoreItem { + source_stack_id: stack.to_string(), + quantity, + }) + } + Withdraw(stack, quantity) => { + WindowActionResult::Command(successor_net::ClientCommand::BankRetrieveItem { + bank_stack_id: stack.to_string(), + quantity, + }) + } + LootItem(_) | LootAll => WindowActionResult::Rejected( + "loot requires an authority container and target".into(), + ), + TradeOffer(_) | TradeAccept => WindowActionResult::Rejected( + "trade requires an authority proposal and item spec".into(), + ), + Craft(schematic) => { + WindowActionResult::Command(successor_net::ClientCommand::CraftItem { + schematic_id: schematic, + experiment_power: 0, + experiment_handling: 0, + experiment_reliability: 0, + }) + } + Survey => { + WindowActionResult::Rejected("survey requires a selected resource family".into()) + } + DialogueChoice(_) => { + WindowActionResult::Rejected("dialogue choices require an active delivery".into()) + } + TravelTo(id) => { + WindowActionResult::Command(successor_net::ClientCommand::EnterTransition { + transition_id: id, + }) + } + Toggle(_) | Button(_) => { + WindowActionResult::Rejected("unsupported generic window action".into()) + } + OpenWindow(id) => WindowActionResult::Local(WindowLocalAction::OpenWindow(id)), + OpenInventoryRadial { + container, + stack_id, + } => WindowActionResult::Local(WindowLocalAction::OpenInventoryRadial { + container, + stack_id, + }), + SetTheme(i) => WindowActionResult::Local(WindowLocalAction::SetTheme(i)), + SetDust(v) => WindowActionResult::Local(WindowLocalAction::SetDust(v.clamp(0.0, 1.0))), + SetSplitSnap(v) => WindowActionResult::Local(WindowLocalAction::SetSplitSnap(v)), + RebindToolbarSlot(i) => { + WindowActionResult::Local(WindowLocalAction::RebindToolbarSlot(i)) + } + BeginAssignAction(id) => { + WindowActionResult::Local(WindowLocalAction::BeginAssignAction(id)) + } + RunActionId(id) => { + WindowActionResult::Rejected(format!("action registry dispatch unavailable: {id}")) + } + RunMacro(id) => WindowActionResult::Local(WindowLocalAction::RunMacro(id)), + StopMacro(id) => WindowActionResult::Local(WindowLocalAction::StopMacro(id)), + SaveMacro { name, body } => { + WindowActionResult::Local(WindowLocalAction::SaveMacro { name, body }) + } + DeleteMacro(id) => WindowActionResult::Local(WindowLocalAction::DeleteMacro(id)), + SubmitBugReport { category, body } => { + WindowActionResult::Local(WindowLocalAction::SubmitBugReport { category, body }) + } + BugReportReset => WindowActionResult::Local(WindowLocalAction::BugReportReset), + CreateWaypoint { x, y, name } => { + WindowActionResult::Local(WindowLocalAction::CreateWaypoint { x, y, name }) + } + RenameWaypoint { id, name } => { + WindowActionResult::Local(WindowLocalAction::RenameWaypoint { id, name }) + } + SetWaypointActive { id, active } => { + WindowActionResult::Local(WindowLocalAction::SetWaypointActive { id, active }) + } + DeleteWaypoint(id) => WindowActionResult::Local(WindowLocalAction::DeleteWaypoint(id)), + SetWindowOpacity(v) => { + WindowActionResult::Local(WindowLocalAction::SetWindowOpacity(v)) + } + SetHudOpacity(v) => WindowActionResult::Local(WindowLocalAction::SetHudOpacity(v)), + ExchangeRetrieve(_) | ExchangeStore(_) => { + WindowActionResult::Rejected("exchange requires item variant and quantity".into()) + } + } + } +} + +pub mod bugreport; +pub mod character; +pub mod commands; +pub mod inventory; +pub mod options; +pub mod skills; + +/// What a surface needs to draw its body: its family spec, the content rect +/// below the shared header/tab strip, and the active tab. +#[derive(Clone, Copy)] +pub struct Ctx { + pub spec: &'static spec::Surface, + /// Body rect `[x, y, w, h]` — already inset below the header and tabs. + pub rect: [f32; 4], + /// Active tab index (0 when the surface owns no tabs). + pub tab: usize, +} + +impl Ctx { + pub fn metrics(&self) -> spec::Metrics { + self.spec.metrics() + } +} + +/// Active tab for a surface. Window-local UI state, never authority state. +pub fn active_tab(route: spec::Route) -> usize { + TAB_STATE.with(|tabs| tabs.borrow()[route as usize] as usize) +} + +pub fn set_active_tab(route: spec::Route, tab: usize) { + TAB_STATE.with(|tabs| tabs.borrow_mut()[route as usize] = tab.min(u8::MAX as usize) as u8); +} + +/// Dispatch content for the window `id` into `ui`. +pub fn content( + ui: &mut UiBuilder, + id: &str, + rect: [f32; 4], + model: &WindowModel, + icons: &crate::hud::Icons, + out: &mut Vec<WindowAction>, +) { + let (Some(surface), Some(route)) = (spec::surface(id), spec::route(id)) else { + // Unreachable for a registered id (`spec::route` is total over the + // registry and tested); named loudly so an unmapped surface reads as a + // defect instead of a blank pane. + ui.text( + "UNMAPPED SURFACE", + rect[0] + 6.0, + rect[1] + 6.0, + chrome::scale(chrome::VALUE_PX), + chrome::warn(), + ); + ui.text( + id, + rect[0] + 6.0, + rect[1] + 6.0 + chrome::ROW_H, + chrome::scale(chrome::LABEL_PX), + dim(), + ); + return; + }; + + // The compact static panes receive a host-built body context. Authority- + // backed live panes own the same shared header/tab primitives internally + // because their tab state and footer reservations are pane-local. + let host_owns_chrome = matches!( + route, + spec::Route::Character + | spec::Route::Skills + | spec::Route::CommandBrowser + | spec::Route::Options + | spec::Route::Report + ); + let mut body = rect; + if host_owns_chrome && surface.header { + let y = chrome::header(ui, rect, surface); + body = [rect[0], y, rect[2], (rect[1] + rect[3] - y).max(0.0)]; + } + if host_owns_chrome && !surface.tabs.is_empty() { + if let Some(next) = chrome::tabs( + ui, + body[0], + body[1], + body[2], + surface.tabs, + active_tab(route), + ) { + set_active_tab(route, next); + } + let step = chrome::TAB_H + 8.0; + body = [body[0], body[1] + step, body[2], (body[3] - step).max(0.0)]; + } + let ctx = Ctx { + spec: surface, + rect: body, + tab: active_tab(route), + }; + + match route { + spec::Route::Inventory => inventory::draw(ui, body, model, icons, out), + spec::Route::Character => character::draw(ui, ctx, model, icons, out), + spec::Route::Skills => skills::draw(ui, ctx, model, icons, out), + spec::Route::CommandBrowser => commands::draw(ui, ctx, icons, out), + spec::Route::Options => { + OPTIONS_MODEL.with(|options| options::draw(ui, ctx, &options.borrow(), icons, out)); + } + spec::Route::Report => { + BUG_MODEL.with(|bug| bugreport::draw(ui, ctx, &mut bug.borrow_mut(), icons, out)); + } + spec::Route::Datapad => live::datapad(ui, ctx, model, out), + spec::Route::Macros => live::macros_live(ui, ctx, model, out), + spec::Route::Association => live::guild(ui, ctx, model, out), + spec::Route::Group => live::group(ui, ctx, model, out), + spec::Route::Craft => live::craft(ui, ctx, model, out), + spec::Route::Splice => live::splice(ui, ctx, model, out), + spec::Route::Converse => live::converse(ui, ctx, model, out), + spec::Route::Trade => live::trade(ui, ctx, model, out), + spec::Route::Examine => live::examine(ui, ctx, model, out), + spec::Route::Survey => live::survey(ui, ctx, model, out), + spec::Route::Travel => live::travel(ui, ctx, model, out), + spec::Route::Loot => live::loot(ui, ctx, model, out), + spec::Route::Bank => live::bank(ui, ctx, model, out), + spec::Route::Clone => live::clone_terminal(ui, ctx, model, out), + spec::Route::Structure => live::agriculture(ui, ctx, model, out), + } +} + +// Per-window interactive state for the section-7 windows. Interim home until +// the shared `WindowUiState` threading lands — dispatch stays immutable-model +// for every other window. +pub fn set_bug_report_pending(request_id: String) { + BUG_MODEL.with(|model| { + model.borrow_mut().status = bugreport::BugStatus::Pending { request_id }; + }); +} + +pub fn apply_bug_report_result(payload: &serde_json::Value) { + BUG_MODEL.with(|model| { + let mut model = model.borrow_mut(); + let bugreport::BugStatus::Pending { request_id } = &model.status else { + return; + }; + if let Some(status) = bugreport::result_for_request(payload, request_id) { + model.status = status; + } + }); +} + +pub fn reset_bug_report() { + BUG_MODEL.with(|model| { + model.borrow_mut().status = bugreport::BugStatus::Idle; + }); +} + +pub fn set_options_model(model: options::OptionsModel) { + OPTIONS_MODEL.with(|state| *state.borrow_mut() = model); +} + +thread_local! { + static OPTIONS_MODEL: core::cell::RefCell<options::OptionsModel> = + core::cell::RefCell::new(options::OptionsModel::default()); + static BUG_MODEL: core::cell::RefCell<bugreport::BugReportModel> = + core::cell::RefCell::new(bugreport::BugReportModel::new()); + /// One tab index per `spec::Route`. + static TAB_STATE: core::cell::RefCell<[u8; 32]> = const { core::cell::RefCell::new([0; 32]) }; +} + +// ── Shared ink ────────────────────────────────────────────────────────────── +// Measured from the original dense-window crops (8-level quantization) and the +// original frame includes, then used as the single palette for every surface. +// Frame and well tones live in [`chrome`]; these are the type colors. + +/// Primary body ink. +#[inline] +pub fn text() -> [u8; 4] { + crate::hud::active_palette().ink +} + +/// Row/field labels. +#[inline] +pub fn label() -> [u8; 4] { + crate::hud::active_palette().ink +} + +/// Numeric values and readouts. +#[inline] +pub fn value() -> [u8; 4] { + crate::hud::active_palette().ink +} + +/// Subordinate captions, disabled rows, unavailable notes. +#[inline] +pub fn dim() -> [u8; 4] { + crate::hud::active_palette().ink_dim +} + +/// Active/selected accent (tab underline, selection rail, focus). +#[inline] +pub fn active() -> [u8; 4] { + crate::hud::active_palette().accent +} + +/// Heading accent. Alias of [`active`] so headings and selection agree. +#[inline] +pub fn accent() -> [u8; 4] { + active() +} + +/// Affirmative state (learned skill box, satisfied requirement, gain). +#[inline] +pub fn positive() -> [u8; 4] { + crate::hud::active_palette().accent +} + +/// Recessed slot/cell fill. +#[inline] +pub fn slot() -> [u8; 4] { + crate::hud::faded(crate::hud::active_palette().bg_cell) +} + +/// Slot separator. +#[inline] +pub fn slot_edge() -> [u8; 4] { + crate::hud::active_palette().hairline +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn theme_indices_produce_distinct_ink_sets() { + let mut inks = Vec::new(); + for i in 0..crate::hud::THEME_COUNT { + crate::hud::set_active_palette(crate::hud::palette(i)); + let set = (text(), dim(), active(), slot_edge()); + assert!(!inks.contains(&set), "theme {i} produced duplicate ink set"); + inks.push(set); + } + assert_eq!(inks.len(), crate::hud::THEME_COUNT); + } + + #[test] + fn faded_affects_fills_but_not_text() { + crate::hud::set_active_palette(crate::hud::palette(0)); + crate::hud::set_fill_opacity(1.0); + let slot_full = slot(); + let text_full = text(); + let dim_full = dim(); + let active_full = active(); + + crate::hud::set_fill_opacity(0.5); + let slot_half = slot(); + let text_half = text(); + let dim_half = dim(); + let active_half = active(); + + assert!( + slot_half[3] < slot_full[3], + "fill opacity must scale slot alpha" + ); + assert_eq!(text_half, text_full, "text ink must not be faded"); + assert_eq!(dim_half, dim_full, "dim ink must not be faded"); + assert_eq!(active_half, active_full, "active ink must not be faded"); + } + + #[test] + fn command_actions_are_typed() { + assert!(matches!( + WindowAction::UseItem(7).resolve(), + WindowActionResult::Command(successor_net::ClientCommand::UseConsumable { + item_numeric_id: Some(7), + .. + }) + )); + assert!(matches!( + WindowAction::Deposit(9, 2).resolve(), + WindowActionResult::Command(successor_net::ClientCommand::BankStoreItem { + quantity: 2, + .. + }) + )); + } + + #[test] + fn contextless_actions_reject_instead_of_disappearing() { + assert!(matches!( + WindowAction::Button("x".into()).resolve(), + WindowActionResult::Rejected(_) + )); + assert!(matches!( + WindowAction::LootAll.resolve(), + WindowActionResult::Rejected(_) + )); + } + + #[test] + fn inventory_radial_stays_a_local_host_route() { + assert_eq!( + WindowAction::OpenInventoryRadial { + container: "player".into(), + stack_id: "7".into(), + } + .resolve(), + WindowActionResult::Local(WindowLocalAction::OpenInventoryRadial { + container: "player".into(), + stack_id: "7".into(), + }) + ); + } + + #[test] + fn tab_state_is_per_surface() { + set_active_tab(spec::Route::Datapad, 2); + set_active_tab(spec::Route::Bank, 1); + assert_eq!(active_tab(spec::Route::Datapad), 2); + assert_eq!(active_tab(spec::Route::Bank), 1); + assert_eq!(active_tab(spec::Route::Survey), 0); + set_active_tab(spec::Route::Datapad, 0); + set_active_tab(spec::Route::Bank, 0); + } + + /// Every registered surface renders on every tab, at its resize floor, from + /// an empty projection, without panicking and without drawing nothing. + #[test] + fn every_surface_draws_at_its_resize_floor() { + let icons = crate::hud::Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let model = WindowModel::sample(); + let mut out = Vec::new(); + for surface in &spec::SURFACES { + let (w, h) = surface.min_size(); + let route = spec::route(surface.id).expect("mapped surface"); + for tab in 0..surface.tabs.len().max(1) { + set_active_tab(route, tab); + ui.begin(1280, 720); + out.clear(); + content( + &mut ui, + surface.id, + [24.0, 24.0, w, h], + &model, + &icons, + &mut out, + ); + assert!( + ui.quads > 0, + "{} tab {tab} drew nothing at its resize floor", + surface.id + ); + } + set_active_tab(route, 0); + } + } + + #[test] + fn an_unmapped_id_is_named_not_blank() { + let icons = crate::hud::Icons::load(); + let mut ui = UiBuilder::new(icons.meta); + let model = WindowModel::sample(); + let mut out = Vec::new(); + ui.begin(1280, 720); + content( + &mut ui, + "not-a-surface", + [0.0, 0.0, 300.0, 200.0], + &model, + &icons, + &mut out, + ); + assert!(ui.quads > 0, "an unmapped id must still say so on screen"); + assert!(out.is_empty(), "an unmapped id must emit no intents"); + } +} diff --git a/client-rust/source/app/src/windows/model.rs b/client-rust/source/app/src/windows/model.rs new file mode 100644 index 00000000..6e5c7270 --- /dev/null +++ b/client-rust/source/app/src/windows/model.rs @@ -0,0 +1,1596 @@ +//! Typed view models the window content reads, decoded from the authority +//! wire payloads by their EXACT server field names (`server/src/game/protocol.ts` +//! VM interfaces). Windows never read raw `serde_json::Value` — projection +//! (`windows::project`) decodes each store section into these plain-data +//! structs, so window layout stays deterministic and unit-testable and the +//! decode itself is exercised by feeding wire-shaped JSON in tests. +//! +//! Isolated UI tests seed these plain structs directly. `WindowModel::visual_sample` +//! is an explicitly named presentation fixture for the pointerless UI demo; +//! connected mode uses neither fixture and rebuilds live sections from the +//! accepted store via `windows::project::project`. + +use serde::{Deserialize, Deserializer}; +use std::collections::HashMap; + +// ─────────────────────────── shared decode helpers ────────────────────────── + +/// Wire ids arrive as JSON numbers (inventory `stackId`) or strings (bank +/// `stackId`). Commands carry them as strings — normalize at decode time. +pub fn de_string_or_num<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> { + #[derive(Deserialize)] + #[serde(untagged)] + enum V { + S(String), + N(serde_json::Number), + B(bool), + None, + } + Ok(match V::deserialize(d)? { + V::S(s) => s, + V::N(n) => n.to_string(), + V::B(b) => b.to_string(), + V::None => String::new(), + }) +} + +/// Item category → toolbar/inventory glyph id (`icons.ts` vocabulary). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ItemKind { + Weapon, + Ammo, + Medical, + Resource, + Tool, + Gear, + Currency, + Item, +} + +impl ItemKind { + /// Icon id for this category (resolved against the baked atlas by the host). + pub fn icon(self) -> &'static str { + match self { + ItemKind::Weapon => "item-weapon", + ItemKind::Ammo => "item-ammo", + ItemKind::Medical => "item-medical", + ItemKind::Resource => "item-resource", + ItemKind::Tool => "item-tool", + ItemKind::Gear => "item-gear", + ItemKind::Currency => "item-currency", + ItemKind::Item => "item-item", + } + } + + pub fn label(self) -> &'static str { + match self { + ItemKind::Weapon => "WEAPON", + ItemKind::Ammo => "AMMO", + ItemKind::Medical => "MEDICAL", + ItemKind::Resource => "RESOURCE", + ItemKind::Tool => "TOOL", + ItemKind::Gear => "GEAR", + ItemKind::Currency => "CURRENCY", + ItemKind::Item => "ITEM", + } + } +} + +/// Rust-authoritative resource stat block (wire `GameResourceStats`, +/// snake_case on the wire — field names match exactly). +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)] +#[serde(default)] +pub struct ResourceStats { + pub conductivity: i64, + pub malleability: i64, + pub shock_resistance: i64, + pub thermal_resistance: i64, + pub chemical_purity: i64, + pub density: i64, + pub tensile_strength: i64, + pub flexibility: i64, + pub potency: i64, + pub nutrition: i64, + pub stability: i64, + pub extraction_yield: i64, +} + +impl ResourceStats { + /// (label, value) pairs in the reference examine order. + pub fn rows(&self) -> [(&'static str, i64); 12] { + [ + ("COND", self.conductivity), + ("MALL", self.malleability), + ("SHOCK", self.shock_resistance), + ("THERM", self.thermal_resistance), + ("PURITY", self.chemical_purity), + ("DENS", self.density), + ("TENS", self.tensile_strength), + ("FLEX", self.flexibility), + ("POT", self.potency), + ("NUTR", self.nutrition), + ("STAB", self.stability), + ("YIELD", self.extraction_yield), + ] + } +} + +// ─────────────────────────── inventory / economy ──────────────────────────── + +/// One inventory/bank/loot row (wire `GameInventoryRow` / `GameBankItemRow`). +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct InventoryRow { + pub container: String, + #[serde(deserialize_with = "de_string_or_num")] + pub stack_id: String, + pub item: String, + pub item_id: u32, + pub variant_id: u32, + pub quantity: i64, + pub reserved: i64, + pub available: i64, + pub equipped: bool, + pub potency: Option<i64>, + pub purity: Option<i64>, + pub item_key: Option<String>, + pub metadata: Option<serde_json::Value>, + pub resource_stats: Option<ResourceStats>, + pub colors: Vec<String>, +} + +/// Exchange stockpile container id (datapad STORE/RETRIEVE in the reference). +pub const EXCHANGE_CONTAINER: &str = "district-exchange"; + +impl InventoryRow { + pub fn kind(&self) -> ItemKind { + let key = self.item_key.as_deref().unwrap_or(""); + let name = self.item.to_ascii_uppercase(); + if self.is_credit_chip() { + ItemKind::Currency + } else if self.resource_stats.is_some() || key.starts_with("resource") { + ItemKind::Resource + } else if name.contains("AMMO") || name.contains("ROUNDS") { + ItemKind::Ammo + } else if name.contains("MEDKIT") || name.contains("STIM") || name.contains("BANDAGE") { + ItemKind::Medical + } else if name.contains("TOOL") + || name.contains("SCANNER") + || name.contains("EXTRACTOR") + || name.contains("BATTERY") + { + ItemKind::Tool + } else if matches!( + self.item_id, + 3101 | 3103..=3107 | 3111 | 3112 | 3121..=3127 + ) || name.contains("PISTOL") + || name.contains("RIFLE") + || name.contains("CARBINE") + || name.contains("SWORD") + || name.contains("SABER") + || name.contains("MACHETE") + || name.contains("SLUGTHROWER") + || name.contains("SHOTGUN") + || name.contains("SMG") + { + ItemKind::Weapon + } else if !self.colors.is_empty() + || name.contains("VEST") + || name.contains("JACKET") + || name.contains("BOOTS") + || name.contains("HELM") + { + ItemKind::Gear + } else { + ItemKind::Item + } + } + + pub fn is_travel_ticket(&self) -> bool { + self.item_key.as_deref() == Some("travel_ticket") + || self + .metadata + .as_ref() + .map(|m| m.get("travelTicket").is_some()) + .unwrap_or(false) + } + + pub fn is_credit_chip(&self) -> bool { + self.item.to_ascii_uppercase().contains("CREDIT CHIP") + || self.item_key.as_deref() == Some("credit_chip") + } + + /// Ticket destination "PLANET / CITY" from `metadata.travelTicket`. + pub fn ticket_destination(&self) -> Option<String> { + let t = self.metadata.as_ref()?.get("travelTicket")?; + let planet = t.get("toPlanetId").and_then(|v| v.as_str()).unwrap_or("?"); + let city = t.get("toCityId").and_then(|v| v.as_str()).unwrap_or("?"); + Some(format!("{} / {}", planet, city).to_ascii_uppercase()) + } + + /// True when this stack sits in the district exchange stockpile. + pub fn in_exchange(&self) -> bool { + self.container == EXCHANGE_CONTAINER + } +} + +/// Wire `GameReservationRow` — visible pending holds against stacks. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ReservationRow { + pub id: i64, + pub actor: String, + pub purpose: String, + pub from: String, + pub item: String, + pub quantity: i64, +} + +#[derive(Clone, Debug, Default)] +pub struct InventoryModel { + pub rows: Vec<InventoryRow>, + pub reservations: Vec<ReservationRow>, + /// Wallet credits (player actor scalar). + pub credits: i64, + /// Currently wielded weapon label (from the player actor weapon state). + pub weapon_label: Option<String>, +} + +impl InventoryModel { + /// Held (player-owned, non-exchange) rows in wire order. + pub fn held(&self) -> impl Iterator<Item = &InventoryRow> { + self.rows.iter().filter(|r| !r.in_exchange()) + } + /// Rows stored in the district exchange. + pub fn exchange(&self) -> impl Iterator<Item = &InventoryRow> { + self.rows.iter().filter(|r| r.in_exchange()) + } + pub fn row(&self, container: &str, stack_id: &str) -> Option<&InventoryRow> { + self.rows + .iter() + .find(|r| r.container == container && r.stack_id == stack_id) + } +} + +/// Wire `GameBankSnapshot` (owner-scoped; also carries the clone skill backup). +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct BankSnapshot { + pub credits: i64, + pub items: Vec<InventoryRow>, + pub backup_present: bool, + pub backup_saved_tick: Option<i64>, + pub backup_skill_count: i64, + pub backup_cost: i64, +} + +/// A range/terminal gate the reference expresses as an "AT TERMINAL ONLY" +/// unavailable state. `available == false` ⇒ the window must not emit the +/// gated commands and shows `note` instead. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Gate { + pub available: bool, + pub note: String, + /// The linked terminal/prop id when available. + pub prop_id: Option<String>, +} + +impl Gate { + pub fn open(prop_id: &str) -> Self { + Gate { + available: true, + note: String::new(), + prop_id: Some(prop_id.to_string()), + } + } + pub fn closed(note: &str) -> Self { + Gate { + available: false, + note: note.to_string(), + prop_id: None, + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct BankModel { + pub gate: Gate, + pub bank: Option<BankSnapshot>, +} + +// ─────────────────────────────── loot ──────────────────────────────────────── + +/// Wire `GamePlayerCorpseSnapshot` (`playerCorpses[]` section). +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PlayerCorpse { + pub id: String, + pub owner_label: String, + pub area_id: String, + pub cell_x: i64, + pub cell_y: i64, + pub x: f32, + pub y: f32, + pub expiry_tick: i64, + pub has_items: bool, + pub credits_present: bool, + pub credits_count: i64, + pub is_owner: bool, + /// Loot container id the corpse's `GameInventoryRow`s address. + pub container: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LootTargetKind { + Corpse, + Cache, +} + +#[derive(Clone, Debug)] +pub struct LootModel { + pub kind: LootTargetKind, + /// Corpse id / cache prop id. + pub target_id: String, + /// Loot container the rows live in (`corpse:<id>` / cache container). + pub container: String, + pub label: String, + pub rows: Vec<InventoryRow>, + pub credits_present: bool, + pub credits_count: i64, + pub in_reach: bool, + /// Loot rights: mine (or public). + pub rights_mine: bool, + /// Corpse only: HARVEST available (target actor id). + pub harvest_actor_id: Option<String>, +} + +// ─────────────────────────────── trade ─────────────────────────────────────── + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TradeItemLine { + pub item_id: u32, + pub variant_id: u32, + pub name: String, + pub quantity: i64, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TradeSide { + pub actor_id: String, + pub items: Vec<TradeItemLine>, + pub coin: i64, + pub locked: bool, + pub confirmed: bool, +} +/// Wire `GameTradeSession` (streamed to both participants). +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TradeSession { + pub proposal_id: u32, + pub partner_actor_id: String, + pub mine: TradeSide, + pub theirs: TradeSide, + pub both_locked: bool, + /// "negotiating" | "confirm" | "executed" | "declined" + pub stage: String, + pub close_reason: Option<String>, + pub tick: i64, +} + +#[derive(Clone, Debug, Default)] +pub struct TradeModel { + pub session: Option<TradeSession>, + pub partner_label: String, + /// Player rows eligible to add to the offer (available > 0, not exchange). + pub offerable: Vec<InventoryRow>, + /// Selected target for a PROPOSE action when no session is live. + pub propose_target: Option<(String, String)>, // (actor_id, label) +} + +// ─────────────────────────────── crafting ──────────────────────────────────── + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CraftRecipeSummary { + pub recipe_id: String, + pub name: String, + pub category: String, + pub output_item_id: u32, + pub unlocked: bool, + pub required_tool_item_id: u32, + pub required_profession: String, + pub hands_craftable: bool, + pub source: String, + pub remaining_uses: Option<i64>, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CraftStatLine { + pub line_id: u8, + pub label: String, + pub cap_estimate_milli: i64, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CraftSlotSpec { + pub slot_index: u8, + pub symbol: String, + pub resource_kind_label: String, + pub required_item_name: Option<String>, + pub required_qty: i64, + pub craft_relevant_stat: String, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CraftRecipeDetail { + pub recipe_id: String, + pub output_item_id: u32, + pub slots: Vec<CraftSlotSpec>, + pub stat_lines: Vec<CraftStatLine>, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CraftResourceOption { + pub container: String, + #[serde(deserialize_with = "de_string_or_num")] + pub stack_id: String, + pub item_id: u32, + pub variant_id: u32, + pub name: String, + pub qty_available: i64, + pub craft_relevant_stat_value: i64, + pub recommended: bool, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CraftAssigned { + pub container: String, + #[serde(deserialize_with = "de_string_or_num")] + pub stack_id: String, + pub variant_id: u32, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CraftSlotFill { + pub slot_index: u8, + pub symbol: String, + pub resource_kind_label: String, + pub required_qty: i64, + pub required_item_name: Option<String>, + pub eligible: Vec<CraftResourceOption>, + pub assigned: Option<CraftAssigned>, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CraftSlotScreen { + pub recipe_id: String, + pub slots: Vec<CraftSlotFill>, + pub can_assemble: bool, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CraftAssembledLine { + pub line_id: u8, + pub label: String, + pub value_milli: i64, + pub cap_milli: i64, + pub can_raise: bool, + pub one_point_success_milli: Option<i64>, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CraftAssembled { + pub recipe_id: String, + pub assembly_quality_milli: i64, + pub experimentation_points_remaining: i64, + pub lines: Vec<CraftAssembledLine>, +} + +/// Wire `GameCraftSession` (targeted `craftSession` room message + sections). +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CraftSession { + /// "browse" | "slots" | "assembled" (server-owned phase string). + pub phase: String, + pub recipe_id: Option<String>, + pub recipes: Vec<CraftRecipeSummary>, + pub detail: Option<CraftRecipeDetail>, + pub details: Vec<CraftRecipeDetail>, + pub slot_screen: Option<CraftSlotScreen>, + pub assembled: Option<CraftAssembled>, + pub tick: i64, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DraftedSchematic { + pub id: String, + pub recipe_id: String, + pub output_item_id: u32, + pub max_uses: i64, + pub remaining_uses: i64, +} + +#[derive(Clone, Debug, Default)] +pub struct CraftModel { + pub session: Option<CraftSession>, + pub drafts: Vec<DraftedSchematic>, + /// Factory terminal gate (FactoryManufacture). + pub factory: Gate, + /// In-range trainer (RequestStarterTool origin), if any. + pub trainer_actor_id: Option<String>, +} + +// ───────────────────────── survey / extraction / camps ────────────────────── + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ResourceSpawn { + pub spawn_id: String, + pub family: String, + pub name: String, + pub class_label: String, + pub variant_id: u32, + pub stats: ResourceStats, +} + +/// Wire `GameSurveyResult` (targeted `surveyResult` room message). +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SurveyResult { + pub family: String, + pub area_id: String, + pub spawn_id: String, + pub spawn_name: String, + pub center_x: f32, + pub center_y: f32, + pub range_cells: i64, + pub step_cells: i64, + pub cols: i64, + pub rows: i64, + pub concentration_milli: Vec<i64>, + pub cooldown_until_tick: i64, + pub tick: i64, +} + +impl SurveyResult { + /// Richest sampled point → (world x, world y, milli). + pub fn richest(&self) -> Option<(f32, f32, i64)> { + let mut best: Option<(usize, i64)> = None; + for (i, &m) in self.concentration_milli.iter().enumerate() { + if best.map(|(_, b)| m > b).unwrap_or(true) { + best = Some((i, m)); + } + } + let (idx, milli) = best?; + let cols = self.cols.max(1); + let col = (idx as i64 % cols) as f32; + let row = (idx as i64 / cols) as f32; + let step = self.step_cells as f32; + let half_w = (self.cols.max(1) - 1) as f32 * 0.5; + let half_h = (self.rows.max(1) - 1) as f32 * 0.5; + Some(( + self.center_x + (col - half_w) * step, + self.center_y + (row - half_h) * step, + milli, + )) + } +} + +/// Wire `PlacedExtractorVM`. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PlacedExtractor { + pub extractor_id: String, + pub area_id: String, + pub cell_x: i64, + pub cell_y: i64, + /// "idle" | "manual" | "battery" + pub mode: String, + pub biome: String, + pub hopper_pct: f64, + pub collectable_units: i64, + pub battery_pct: f64, + pub is_owner: bool, + pub family_label: String, +} + +/// Wire `PlacedCampVM`. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PlacedCamp { + pub camp_id: String, + pub area_id: String, + pub cell_x: i64, + pub cell_y: i64, + pub is_owner: bool, + pub render_kind: String, + pub abandon_seconds_remaining: Option<i64>, +} + +/// Sim `POINT_BLANK_INTERACTION_RADIUS` — every extractor verb. +pub const EXTRACTOR_REACH_CELLS: f32 = 1.5; +/// Camp verbs work inside the tent's 5×5 footprint. +pub const CAMP_FOOTPRINT_CELLS: f32 = 2.5; + +#[derive(Clone, Debug)] +pub struct ExtractorView { + pub vm: PlacedExtractor, + pub distance: f32, + pub in_reach: bool, +} + +#[derive(Clone, Debug)] +pub struct CampView { + pub vm: PlacedCamp, + pub distance: f32, + pub in_footprint: bool, +} + +/// One selectable survey/sample family with a live-spawn label. +#[derive(Clone, Debug, PartialEq)] +pub struct SurveyFamilyOption { + pub family: String, + pub label: String, +} + +#[derive(Clone, Debug, Default)] +pub struct SurveyModel { + /// Live target families from the authority resource-spawn snapshot. + pub families: Vec<SurveyFamilyOption>, + /// Newest survey result per family for the active area. + pub results: Vec<SurveyResult>, + /// Sample cooldown: ticks remaining until the next sample (0 = ready). + pub sample_cooldown_ticks: i64, + /// Nearby resource spawn detail rows (taxonomy examine). + pub spawns: Vec<ResourceSpawn>, + pub extractors: Vec<ExtractorView>, + pub camps: Vec<CampView>, + /// Player already owns a placed camp (PlaceCamp is one-at-a-time). + pub own_camp_placed: bool, + /// Battery cells in inventory eligible for `InsertBattery`. + pub batteries: Vec<InventoryRow>, +} + +impl SurveyModel { + pub fn result_for(&self, family: &str) -> Option<&SurveyResult> { + self.results.iter().find(|r| r.family == family) + } +} + +// ─────────────────────── progression / character ───────────────────────────── + +/// Wire `GameActorProfessionSnapshot` (actor `professions[]`). +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ProfessionState { + pub id: String, + pub label: String, + pub xp: i64, + pub track_xp: HashMap<String, i64>, + pub skill_points: i64, + pub skill_boxes: Vec<String>, +} + +/// Wire `GameActorProfessionTitleSnapshot`. +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(default, rename_all = "camelCase")] +pub struct ProfessionTitle { + pub id: String, + pub label: String, + pub skill_box_id: String, +} + +/// Wire `GameActorWeaponSnapshot` (decoded from the actor JSON). +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct WeaponState { + pub weapon_id: String, + pub ammo_type: String, + pub loaded_rounds: i64, + pub magazine_size: i64, + pub reload_remaining_ticks: i64, + pub reload_total_ticks: i64, +} + +/// Wire `GameActorStatusSnapshot`. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct StatusEffect { + pub id: String, + pub label: String, + pub severity: i64, + pub remaining_ms: i64, + pub stacks: Option<i64>, +} + +/// Wire `GameActorPersonalShieldSnapshot`. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PersonalShield { + pub charge_milli: i64, + pub max_charge_milli: i64, + pub durability_charges: i64, + pub max_durability_charges: i64, +} + +/// The player actor decoded once per rebuild from its wire-named JSON view. +/// Absent fields (pre-expansion store) decode to defaults, never panic. +#[derive(Clone, Debug, Default)] +pub struct PlayerSummary { + pub actor_id: String, + pub name: String, + pub health: f32, + pub health_max: f32, + pub action: f32, + pub action_max: f32, + pub life_state: String, + pub posture: String, + pub credits: i64, + pub faction_id: Option<String>, + pub pvp_status: String, + pub professions: Vec<ProfessionState>, + pub active_title: Option<ProfessionTitle>, + pub career_goal_id: Option<String>, + pub skill_points_used: i64, + pub skill_points_cap: i64, + pub weapon: Option<WeaponState>, + pub shield: Option<PersonalShield>, + pub statuses: Vec<StatusEffect>, + pub in_combat: bool, + pub clone_sickness_remaining_ms: i64, + pub next_sample_tick: i64, + pub worn: Vec<String>, +} + +#[derive(Clone, Debug, Default)] +pub struct CharacterModel { + pub player: PlayerSummary, + pub area_id: String, + /// Earned selectable titles (from trained skill boxes carrying `title`). + pub title_options: Vec<ProfessionTitle>, + /// Human label for the active career goal. + pub career_goal_label: Option<String>, +} + +/// One skill box joined from the checked-in progression spec + actor state. +#[derive(Clone, Debug, Default)] +pub struct SkillBoxView { + pub id: String, + pub label: String, + pub row: u8, + pub column: u8, + pub xp_cost: i64, + pub skill_point_cost: i64, + pub credit_cost: i64, + pub title: Option<String>, + pub grants: Vec<String>, + pub prerequisites: Vec<String>, + pub trained: bool, + /// Purchasable right now (prereqs + xp + points + credits + trainer). + pub available: bool, + /// Why not (reference deny copy) when `available == false` and untrained. + pub deny_reason: String, +} + +#[derive(Clone, Debug, Default)] +pub struct ProfessionTreeView { + pub id: String, + pub label: String, + pub xp: i64, + pub boxes: Vec<SkillBoxView>, +} + +#[derive(Clone, Debug, Default)] +pub struct TrainerView { + pub actor_id: String, + pub name: String, + pub profession_id: String, + pub in_range: bool, +} + +#[derive(Clone, Debug, Default)] +pub struct SkillsModel { + pub professions: Vec<ProfessionTreeView>, + pub skill_points_used: i64, + pub skill_points_cap: i64, + pub credits: i64, + /// Purchase/unlearn require a live in-range trainer (reference DENY_RANGE). + pub trainer: Option<TrainerView>, +} + +// ───────────────────────────── clone terminal ──────────────────────────────── + +#[derive(Clone, Debug, Default)] +pub struct CloneModel { + pub gate: Gate, + pub backup_present: bool, + pub backup_saved_tick: Option<i64>, + pub backup_skill_count: i64, + pub backup_cost: i64, + pub vault_credits: i64, + pub wallet_credits: i64, + /// Player downed/dead ⇒ CLONE NOW (CloneRespawn) surfaces here too. + pub dead: bool, + pub clone_sickness_remaining_ms: i64, +} + +// ─────────────────────────── dialogue / converse ───────────────────────────── + +/// Wire `GameDialogueDelivery` (delta `dialogueDeliveries[]`). +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DialogueDelivery { + pub actor_id: String, + pub speaker: String, + pub body: String, + pub tick: i64, +} + +/// Reference deny copy for out-of-range trainer actions. +pub const DENY_RANGE: &str = "MOVE CLOSER TO THE TRAINER"; + +#[derive(Clone, Debug, Default)] +pub struct ConverseModel { + /// Live NPC target (trainer or talker); None ⇒ NO ONE TO TALK TO state. + pub npc: Option<TrainerView>, + /// Streamed dialogue lines addressed to/near us, oldest → newest (bounded). + pub deliveries: Vec<DialogueDelivery>, + /// Career goals the trainer offers (from the checked-in script content). + pub career_goals: Vec<(String, String)>, // (goal_id, label) + /// Purchasable teach list for the trainer's profession. + pub teachable: Vec<SkillBoxView>, + pub career_goal_id: Option<String>, +} + +// ─────────────────────────────── travel ────────────────────────────────────── + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TravelCity { + pub id: String, + pub label: String, + pub terminal_prop_id: String, + pub price: i64, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TravelPlanet { + pub id: String, + pub label: String, + pub cities: Vec<TravelCity>, +} + +/// Sim `TRAVEL_USE_RANGE_CELLS` — buy/use requires origin-terminal proximity. +pub const TRAVEL_USE_RANGE_CELLS: f32 = 10.0; + +#[derive(Clone, Debug, Default)] +pub struct TravelModel { + pub gate: Gate, + /// Origin terminal context (planet id, city id) when linked. + pub origin: Option<(String, String)>, + pub planets: Vec<TravelPlanet>, + /// Held travel tickets (inventory rows with `metadata.travelTicket`). + pub tickets: Vec<InventoryRow>, + pub wallet_credits: i64, +} + +// ──────────────────────── player association (guild) ──────────────────────── + +/// Authoritative charter price (sim re-validates; UI shows the exact figure). +pub const GUILD_CHARTER_FEE_CREDITS: i64 = 250_000; +/// Charter/management kiosk reach (shared with bank/clone terminals). +pub const KIOSK_REACH_CELLS: f32 = 1.75; + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct GuildWar { + pub opposing_guild_id: String, + pub opposing_name: String, + pub opposing_tag: String, + /// "outgoing" | "incoming" | "mutual" + pub state: String, + pub declared_tick: i64, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct GuildSummary { + pub id: String, + pub name: String, + pub tag: String, + pub leader_actor_id: String, + pub member_count: i64, + pub wars: Vec<GuildWar>, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct GuildRosterEntry { + pub actor_id: String, + pub name: String, + /// "leader" | "officer" | "member" (server-derived). + pub role: String, + /// Server-derived permission strings: invite/kick/roles/war/disband. + pub permissions: Vec<String>, + pub online: bool, + pub area_id: Option<String>, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct GuildPendingInvite { + pub invite_id: String, + pub guild_id: String, + pub guild_name: String, + pub guild_tag: String, + pub inviter_name: String, + pub expires_tick: i64, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct GuildDirectoryEntry { + pub id: String, + pub name: String, + pub tag: String, + pub member_count: i64, +} + +/// Wire `GameGuildView` (owner-scoped section). +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct GuildView { + pub guild: Option<GuildSummary>, + pub roster: Vec<GuildRosterEntry>, + pub pending_invites: Vec<GuildPendingInvite>, + pub directory: Vec<GuildDirectoryEntry>, +} + +#[derive(Clone, Debug, Default)] +pub struct PaModel { + pub gate: Gate, + pub view: GuildView, + pub my_actor_id: String, + pub wallet_credits: i64, + /// Selected player eligible for invite actions. + pub target: Option<(String, String)>, +} + +impl PaModel { + /// The local player's roster entry (server-derived role/permissions). + pub fn me(&self) -> Option<&GuildRosterEntry> { + self.view + .roster + .iter() + .find(|r| r.actor_id == self.my_actor_id) + } + pub fn has_permission(&self, p: &str) -> bool { + self.me() + .map(|m| m.role == "leader" || m.permissions.iter().any(|x| x == p)) + .unwrap_or(false) + } + pub fn is_leader(&self) -> bool { + self.me().map(|m| m.role == "leader").unwrap_or(false) + } +} + +// ───────────────────────────── groups / duels ─────────────────────────────── + +#[derive(Clone, Copy, Debug, Default, Deserialize)] +#[serde(default)] +pub struct GroupVitals { + pub health: f32, + pub action: f32, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct GroupMember { + pub actor_id: String, + pub name: String, + pub area_id: String, + pub vitals: GroupVitals, + pub max_vitals: GroupVitals, + pub life_state: String, + pub is_leader: bool, + pub link_dead: bool, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct GroupSummary { + pub group_id: i64, + pub leader_actor_id: String, + pub member_actor_ids: Vec<String>, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct GroupPendingInvite { + pub inviter_actor_id: String, + pub inviter_name: String, + pub expires_tick: i64, +} + +/// Wire `GameGroupView`. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct GroupView { + pub group: Option<GroupSummary>, + pub members: Vec<GroupMember>, + pub pending_invite: Option<GroupPendingInvite>, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DuelSummary { + pub duel_id: i64, + pub opponent_actor_id: String, + pub opponent_name: String, + pub expires_tick: i64, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DuelChallenge { + pub other_actor_id: String, + pub other_name: String, + pub expires_tick: i64, +} + +/// Wire `GameDuelView`. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DuelView { + pub active_duel: Option<DuelSummary>, + pub incoming_challenge: Option<DuelChallenge>, + pub outgoing_challenge: Option<DuelChallenge>, +} + +/// Wire `GameDuelOutcome` (targeted `duelOutcome` room message). +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DuelOutcome { + pub opponent_name: String, + /// "won" | "lost" | "dissolved" + pub result: String, + /// "yield" | "down" | "range" | "timeout" | "disconnect" + pub reason: String, + pub tick: i64, +} + +#[derive(Clone, Debug, Default)] +pub struct GroupModel { + pub my_actor_id: String, + pub group: GroupView, + pub duel: DuelView, + pub outcomes: Vec<DuelOutcome>, + /// Selected target for INVITE/CHALLENGE (actor id, label, is_player). + pub target: Option<(String, String, bool)>, + /// Downed duel opponent eligible for DEATHBLOW. + pub deathblow_target: Option<(String, String)>, +} + +impl GroupModel { + pub fn is_leader(&self) -> bool { + self.group + .group + .as_ref() + .map(|g| g.leader_actor_id == self.my_actor_id) + .unwrap_or(false) + } +} + +// ─────────────────────────── farming / parcels ────────────────────────────── + +#[derive(Clone, Copy, Debug, Default, Deserialize)] +#[serde(default)] +pub struct FarmRect { + pub x: i64, + pub y: i64, + pub w: i64, + pub h: i64, +} + +/// Wire `ParcelVM`. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Parcel { + pub parcel_id: String, + pub planet_id: String, + pub area_id: String, + pub name: String, + pub rect: FarmRect, + pub tier: String, + pub is_owner: bool, + pub upkeep_due_in_game_days: Option<f64>, + pub tilled_tiles: i64, + pub planted_tiles: i64, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct FarmCrop { + pub species: String, + pub stage: i64, + pub stage_count: i64, + pub health: String, + pub blight: String, + pub time_to_mature_game_days: Option<f64>, + pub quality_so_far_milli: i64, + pub mature: bool, +} + +/// Wire `FarmTileVM` — `legal_verbs` is blanked server-side for non-owners. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct FarmTile { + pub cell_x: i64, + pub cell_y: i64, + pub tilled: bool, + pub moisture_pct: f64, + pub crop: Option<FarmCrop>, + pub legal_verbs: Vec<String>, +} + +/// Wire `FarmPlotVM`. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct FarmPlot { + pub parcel_id: String, + pub area_id: String, + pub tiles: Vec<FarmTile>, +} + +#[derive(Clone, Debug, Default)] +pub struct FarmModel { + pub parcels: Vec<Parcel>, + pub plots: Vec<FarmPlot>, + /// Seed stacks in inventory (PlantSeed candidates). + pub seeds: Vec<InventoryRow>, + /// Fertilizer stacks in inventory (Fertilize candidates). + pub fertilizers: Vec<InventoryRow>, + /// Farm structure kits in inventory (PlaceFarmStructure candidates). + pub structures: Vec<InventoryRow>, + /// Claim context: player cell + area/planet for ClaimParcel. + pub player_cell: (i64, i64), + pub area_id: String, + pub planet_id: String, +} + +impl FarmModel { + pub fn plot_for(&self, parcel_id: &str) -> Option<&FarmPlot> { + self.plots.iter().find(|p| p.parcel_id == parcel_id) + } +} + +// ────────────────────────────── construction ──────────────────────────────── + +/// Wire `GameBuildingProjection` (`building` section envelope). +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct BuildingProjection { + pub schema: String, + pub tick: i64, + pub components: Vec<BuildComponent>, +} + +/// Wire `GameBuildComponent`. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct BuildComponent { + pub component_id: String, + pub owner_actor_id: String, + pub parcel_id: String, + pub catalog_id: String, + pub kind: String, + pub cell_x: i64, + pub cell_y: i64, + pub rotation_quarters: i64, + pub door_open: bool, +} + +/// One placeable entry from the checked-in build catalog. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct BuildCatalogItem { + pub catalog_id: String, + pub label: String, + pub category: String, + /// (material id, units) costs. + pub costs: Vec<(String, i64)>, + pub w: i64, + pub h: i64, + pub is_door: bool, +} + +/// Ghost placement preview the world layer computes (cursor cell + validity). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct BuildGhost { + pub cell_x: i64, + pub cell_y: i64, + pub rotation_quarters: u8, + pub valid: bool, + pub invalid_reason: Option<String>, +} + +#[derive(Clone, Debug, Default)] +pub struct BuildModel { + /// Owned parcel the player stands in (build gate) — label shown in strip. + pub parcel: Option<Parcel>, + pub catalog: Vec<BuildCatalogItem>, + /// Owned material units by material id (authority inventory projection). + pub materials: Vec<(String, i64)>, + pub ghost: Option<BuildGhost>, + /// Own components (BuildRemove / BuildToggleDoor targets), nearest first. + pub components: Vec<BuildComponent>, +} + +impl BuildModel { + pub fn material_units(&self, id: &str) -> i64 { + self.materials + .iter() + .find(|(m, _)| m == id) + .map(|(_, n)| *n) + .unwrap_or(0) + } + pub fn affordable(&self, item: &BuildCatalogItem) -> bool { + item.costs.iter().all(|(m, n)| self.material_units(m) >= *n) + } +} + +// ─────────────────────────── bioengineering ───────────────────────────────── + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SpliceSlot { + pub slot_index: u8, + /// "parent" | "reagent" + pub kind: String, + pub label: String, + pub filled: bool, + pub item_id: u32, + pub variant_id: u32, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SpliceLine { + pub locus: u8, + pub label: String, + pub base_milli: i64, + pub value_milli: i64, + pub cap_milli: i64, + pub can_raise: bool, +} + +/// Wire `GameSpliceSession` (targeted `spliceSession` room message). +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SpliceSession { + /// "browse" | "slots" | "assembled" + pub phase: String, + pub species_id: i64, + pub species_name: String, + pub slots: Vec<SpliceSlot>, + pub lines: Vec<SpliceLine>, + pub assembly_quality_milli: i64, + pub points_total: i64, + pub points_remaining: i64, + pub can_assemble: bool, + pub tick: i64, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct GenomeScanLocus { + pub locus: u8, + pub label: String, + pub express_milli: i64, + pub heterozygous: Option<bool>, + pub a1: Option<i64>, + pub a2: Option<i64>, +} + +/// Wire `GameGenomeScan` (targeted `genomeScan` room message). +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct GenomeScan { + pub item_id: u32, + pub variant_id: u32, + pub species_name: String, + pub cultivar_name: String, + /// "phenotype" | "hidden_presence" | "allele_values" | "full" + pub tier: String, + pub fertile: bool, + pub loci: Vec<GenomeScanLocus>, + pub generation: Option<i64>, + pub tick: i64, +} + +#[derive(Clone, Debug, Default)] +pub struct SpliceModel { + pub session: Option<SpliceSession>, + pub scans: Vec<GenomeScan>, + /// Genome-bearing stacks eligible for ScanGenome / SpliceAssignSlot. + pub samples: Vec<InventoryRow>, + /// Live creature target for GeneSample (species, actor id, in range). + pub sample_target: Option<(String, String, bool)>, +} + +// ─────────────────────────────── examine ──────────────────────────────────── + +#[derive(Clone, Debug, Default)] +pub struct ExamineActor { + pub actor_id: String, + pub name: String, + pub descriptor: String, + pub life_state: String, + pub faction_id: Option<String>, + pub pvp_status: String, + pub organization_tag: Option<String>, + pub health: f32, + pub health_max: f32, +} + +#[derive(Clone, Debug, Default)] +pub struct ExamineModel { + pub actor: Option<ExamineActor>, + pub item: Option<InventoryRow>, + pub prop: Option<(String, String)>, // (prop id, label) +} + +// ─────────────────────────── receipts / pending ───────────────────────────── + +/// A resolved command receipt joined with its sent kind (256-entry sent log, +/// port of the reference `createRejectWatcher` kind resolution). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ReceiptView { + pub command_id: u64, + pub kind: String, + pub accepted: bool, + pub reason_code: Option<String>, + pub at_ms: f64, +} + +impl ReceiptView { + /// Reference deny copy: `DENIED / REASON CODE`. + pub fn denied_copy(&self) -> String { + format!( + "DENIED / {}", + self.reason_code + .as_deref() + .unwrap_or("unspecified") + .replace('_', " ") + .to_ascii_uppercase() + ) + } +} + +/// Reference status-flash window (`STATUS_FLASH_MS` ≈ 2.6 s). +pub const STATUS_FLASH_MS: f64 = 2600.0; + +#[derive(Clone, Debug, Default)] +pub struct ReceiptsModel { + /// Kinds currently pending/in flight (queue not yet settled). + pub pending_kinds: Vec<String>, + pub last: Option<ReceiptView>, +} + +impl ReceiptsModel { + /// True while a command of one of `kinds` is pending (drives PENDING… UI). + pub fn is_pending(&self, kinds: &[&str]) -> bool { + self.pending_kinds + .iter() + .any(|k| kinds.iter().any(|w| k == w)) + } + + /// Fresh rejection flash for this window's kinds (reference RejectWatcher): + /// only rejections of watched kinds, only within the flash window. + pub fn denied(&self, kinds: &[&str], now_ms: f64) -> Option<String> { + let r = self.last.as_ref()?; + if r.accepted || now_ms - r.at_ms > STATUS_FLASH_MS { + return None; + } + if !kinds.iter().any(|k| *k == r.kind) { + return None; + } + Some(r.denied_copy()) + } +} + +// ───────────────────────── options (HUD/support slice) ────────────────────── +// Owned by the section-7 worker; retained so the options window keeps +// compiling until its live rewrite lands. + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum OptionKind { + Slider(f32), // 0..1 + Toggle(bool), +} + +#[derive(Clone, Debug)] +pub struct OptionRow { + pub label: String, + pub kind: OptionKind, +} + +#[derive(Clone, Debug, Default)] +pub struct Options { + pub rows: Vec<OptionRow>, +} + +// ────────────────────────────── aggregate ─────────────────────────────────── + +/// Aggregate the windows read from. Fields default empty; each window renders +/// its reference "unavailable" state when its section is unset. +#[derive(Clone, Debug, Default)] +pub struct WindowModel { + pub connected: bool, + pub tick: u64, + pub player: PlayerSummary, + pub inventory: InventoryModel, + pub bank: BankModel, + pub loot: Option<LootModel>, + pub trade: TradeModel, + pub craft: CraftModel, + pub survey: SurveyModel, + pub character: CharacterModel, + pub skills: SkillsModel, + pub clone: CloneModel, + pub converse: ConverseModel, + pub travel: TravelModel, + pub pa: PaModel, + pub group: GroupModel, + pub farm: FarmModel, + pub build: BuildModel, + pub splice: SpliceModel, + pub examine: ExamineModel, + pub receipts: ReceiptsModel, + pub waypoints: Vec<crate::hud::waypoints::Waypoint>, + pub macros: Vec<crate::game::macro_runtime::MacroSource>, + pub options: Options, +} + +impl WindowModel { + /// Empty model for unavailable-state developer demos and isolated UI tests. + /// Connected mode populates this from the accepted authority frame. + pub fn sample() -> Self { + Self::default() + } + + /// Explicit visual-only fixture for pointerless `--demo ui` captures. + /// + /// It intentionally carries no connection claim. Connected clients keep + /// using the authority projection, while `sample()` remains empty so tests + /// cannot accidentally inherit presentation data. + pub fn visual_sample() -> Self { + Self { + connected: false, + inventory: InventoryModel { + credits: 2_750, + weapon_label: Some("SLUGTHROWER PISTOL".into()), + rows: vec![ + InventoryRow { + container: "player:field-pack".into(), + stack_id: "demo-sidearm".into(), + item: "Slugthrower Pistol".into(), + item_id: 3101, + quantity: 1, + available: 1, + equipped: true, + item_key: Some("slugthrower_pistol".into()), + ..Default::default() + }, + InventoryRow { + container: "player:field-pack".into(), + stack_id: "demo-stim".into(), + item: "Field Stim".into(), + item_id: 1101, + quantity: 4, + available: 4, + potency: Some(45), + item_key: Some("field_stim".into()), + ..Default::default() + }, + InventoryRow { + container: "player:field-pack".into(), + stack_id: "demo-ammo".into(), + item: "Power Cell Ammo".into(), + item_id: 2201, + quantity: 180, + reserved: 20, + available: 160, + item_key: Some("power_cell_ammo".into()), + ..Default::default() + }, + InventoryRow { + container: "player:field-pack".into(), + stack_id: "demo-survey-tool".into(), + item: "Survey Tool".into(), + item_id: 4201, + quantity: 1, + available: 1, + item_key: Some("survey_tool".into()), + ..Default::default() + }, + InventoryRow { + container: "player:field-pack".into(), + stack_id: "demo-copper".into(), + item: "Copper Ore".into(), + item_id: 5101, + quantity: 64, + available: 64, + purity: Some(87), + resource_stats: Some(ResourceStats { + chemical_purity: 87, + ..Default::default() + }), + item_key: Some("resource_copper_ore".into()), + ..Default::default() + }, + InventoryRow { + container: "player:field-pack".into(), + stack_id: "demo-jacket".into(), + item: "Scout Jacket".into(), + item_id: 6101, + quantity: 1, + available: 1, + colors: vec!["desert".into()], + item_key: Some("scout_jacket".into()), + ..Default::default() + }, + InventoryRow { + container: "player:field-pack".into(), + stack_id: "demo-credit-chip".into(), + item: "Credit Chip".into(), + item_id: 7101, + quantity: 2, + available: 2, + item_key: Some("credit_chip".into()), + ..Default::default() + }, + InventoryRow { + container: "player:field-pack".into(), + stack_id: "demo-ticket".into(), + item: "Travel Ticket".into(), + item_id: 8101, + quantity: 1, + available: 1, + item_key: Some("travel_ticket".into()), + ..Default::default() + }, + InventoryRow { + container: "player:field-pack".into(), + stack_id: "demo-battery".into(), + item: "Battery Pack".into(), + item_id: 9101, + quantity: 3, + available: 3, + item_key: Some("battery_pack".into()), + ..Default::default() + }, + ], + ..Default::default() + }, + bank: BankModel { + bank: Some(BankSnapshot { + credits: 18_450, + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + } + } +} diff --git a/client-rust/source/app/src/windows/options.rs b/client-rust/source/app/src/windows/options.rs new file mode 100644 index 00000000..5c34ba7b --- /dev/null +++ b/client-rust/source/app/src/windows/options.rs @@ -0,0 +1,455 @@ +//! OPTIONS — display + input reference (exact port of +//! `ui/windows/defs/optionsWindow.ts`). +//! +//! DISPLAY: the canonical theme picker (four swatches; the dock swatch stays a +//! cycle shortcut), the live session DUST dial, and the current camera zoom +//! (display-only — the wheel owns zoom and persists it). +//! TOOLBAR: the ACTIONS browser link plus one row per slot with its bind and +//! a REBIND pending-capture button (assignment lives in the Action Browser). +//! INVENTORY: the default stack-split snap step. +//! INPUT: read-only binding rows generated from the shared gameplay registry. + +use super::{accent, dim, slot, slot_edge, text, WindowAction}; +use crate::hud::{code_glyph, Icons, THEMES, THEME_COUNT, THEME_LABELS}; +use successor_engine_render::ui::UiBuilder; + +/// Split-snap steps (`inventory/splitPrefs.ts` SPLIT_SNAP_STEPS). +pub const SPLIT_SNAP_STEPS: [u32; 6] = [1, 5, 10, 100, 1000, 10000]; + +/// Typed view of the options surface. The host projects this from +/// `RuntimeSettings` + the live toolbar doc and applies emitted actions back +/// at the same scope as the reference (theme/local, dust/session, snap/local, +/// binds/toolbar doc). +#[derive(Clone, Debug)] +pub struct OptionsModel { + pub theme_index: usize, + /// Session dust dial 0..1 (post-pass strength; resets on relaunch). + pub dust_strength: f32, + /// Window fill opacity scale (0.35..=1.0, default 0.92). + pub window_opacity: f32, + /// HUD fill opacity scale (0.35..=1.0, default 0.90). + pub hud_opacity: f32, + /// Display-only camera zoom percent (wheel-owned, 55..125). + pub zoom_percent: u16, + pub split_snap: u32, + /// One bind code per toolbar slot (12). + pub toolbar_binds: Vec<String>, + /// Slot currently capturing a new key (`PRESS KEY…`). + pub rebind_pending: Option<usize>, + /// Read-only gameplay binding reference rows: (LABEL, KEYS). + pub binding_reference: Vec<(String, String)>, +} +impl Default for OptionsModel { + fn default() -> Self { + Self::sample() + } +} + +impl OptionsModel { + pub fn sample() -> Self { + Self { + theme_index: 0, + dust_strength: 0.5, + window_opacity: 0.92, + hud_opacity: 0.90, + zoom_percent: 100, + split_snap: 100, + toolbar_binds: crate::hud::toolbar::DEFAULT_BINDS + .iter() + .map(|s| s.to_string()) + .collect(), + rebind_pending: None, + binding_reference: vec![ + ("MOVE".into(), "W A S D".into()), + ("SPRINT".into(), "SHIFT / X".into()), + ("INTERACT".into(), "F".into()), + ("TARGET CYCLE".into(), "TAB".into()), + ("RELOAD".into(), "R".into()), + ], + } + } +} + +fn section_title(ui: &mut UiBuilder, x: f32, y: f32, title: &str) -> f32 { + ui.text(title, x, y, 1.8, accent()); + y + 22.0 +} + +pub fn draw( + ui: &mut UiBuilder, + ctx: super::Ctx, + model: &OptionsModel, + _icons: &Icons, + out: &mut Vec<WindowAction>, +) { + let [x, y, w, h] = ctx.rect; + let bottom = y + h - 6.0; + let mut cy = y + 4.0; + + // ── DISPLAY ────────────────────────────────────────────────────────── + cy = section_title(ui, x, cy, "DISPLAY"); + + // THEME — four swatches, active ring, exact reference palettes. + ui.text("THEME", x, cy + 4.0, 1.6, text()); + let sw_px = 26.0; + for (i, theme) in THEMES.iter().enumerate().take(THEME_COUNT) { + let bx = x + 130.0 + i as f32 * (sw_px + 8.0); + let resp = ui.interact(bx, cy, sw_px, sw_px); + ui.rect(bx, cy, sw_px, sw_px, theme.accent); + if i == model.theme_index { + ui.rect(bx, cy + sw_px + 2.0, sw_px, 2.0, accent()); + } else if resp.hovered { + ui.rect(bx + 5.0, cy + sw_px + 2.0, sw_px - 10.0, 2.0, dim()); + } + if resp.hovered { + ui.text(THEME_LABELS[i], x + 130.0, cy + sw_px + 3.0, 1.3, dim()); + } + if resp.clicked { + out.push(WindowAction::SetTheme(i)); + } + } + cy += sw_px + 18.0; + + // DUST — live session dial (0..1, step 0.05 via slider granularity). + ui.text("DUST", x, cy + 2.0, 1.6, text()); + let mut dust = model.dust_strength.clamp(0.0, 1.0); + if ui.slider( + x + 130.0, + cy - 2.0, + w - 200.0, + 16.0, + &mut dust, + 0.0, + 1.0, + crate::hud::button_style(), + ) { + // Reference input uses step=0.05 — quantize to the same grid. + let quantized = (dust / 0.05).round() * 0.05; + out.push(WindowAction::SetDust(quantized)); + } + ui.text( + &format!("{:.2}", model.dust_strength), + x + w - 56.0, + cy + 2.0, + 1.5, + dim(), + ); + cy += 22.0; + + // WINDOW OPACITY — 0.35..=1.0 slider + ui.text("WINDOW OPACITY", x, cy + 2.0, 1.6, text()); + let mut window_op = model.window_opacity.clamp(0.35, 1.0); + if ui.slider( + x + 130.0, + cy - 2.0, + w - 200.0, + 16.0, + &mut window_op, + 0.35, + 1.0, + crate::hud::button_style(), + ) { + let quantized = (window_op / 0.01).round() * 0.01; + out.push(WindowAction::SetWindowOpacity(quantized)); + } + ui.text( + &format!("{:.2}", model.window_opacity), + x + w - 56.0, + cy + 2.0, + 1.5, + dim(), + ); + cy += 22.0; + + // HUD OPACITY — 0.35..=1.0 slider + ui.text("HUD OPACITY", x, cy + 2.0, 1.6, text()); + let mut hud_op = model.hud_opacity.clamp(0.35, 1.0); + if ui.slider( + x + 130.0, + cy - 2.0, + w - 200.0, + 16.0, + &mut hud_op, + 0.35, + 1.0, + crate::hud::button_style(), + ) { + let quantized = (hud_op / 0.01).round() * 0.01; + out.push(WindowAction::SetHudOpacity(quantized)); + } + ui.text( + &format!("{:.2}", model.hud_opacity), + x + w - 56.0, + cy + 2.0, + 1.5, + dim(), + ); + cy += 22.0; + // ZOOM — display-only; the wheel owns zoom and persists it. + ui.text("ZOOM", x, cy + 2.0, 1.6, text()); + ui.text( + &format!("{}%", model.zoom_percent), + x + 130.0, + cy + 2.0, + 1.6, + dim(), + ); + ui.text("WHEEL-OWNED", x + 190.0, cy + 3.0, 1.2, dim()); + cy += 24.0; + + // ── TOOLBAR ────────────────────────────────────────────────────────── + cy = section_title(ui, x, cy, "TOOLBAR"); + ui.text("ACTIONS", x, cy + 4.0, 1.6, text()); + if ui.button( + x + 130.0, + cy, + 118.0, + 20.0, + "OPEN BROWSER", + crate::hud::button_style(), + ) { + out.push(WindowAction::OpenWindow("actions".into())); + } + cy += 26.0; + + let slot_count = model.toolbar_binds.len().min(12); + for slot in 0..slot_count { + if cy + 18.0 > bottom - 120.0 { + // Keep the lower sections visible in shorter windows; remaining + // slots list continues in the same rhythm on taller windows. + ui.text("...RESIZE FOR ALL SLOTS", x, cy, 1.3, dim()); + cy += 16.0; + break; + } + ui.text(&format!("SLOT {:02}", slot + 1), x, cy + 3.0, 1.5, text()); + // Dotted leader. + let leader_x0 = x + 74.0; + let leader_x1 = x + 150.0; + let mut lx = leader_x0; + while lx < leader_x1 { + ui.rect(lx, cy + 9.0, 2.0, 1.0, slot_edge()); + lx += 6.0; + } + let key_label = code_glyph(&model.toolbar_binds[slot]); + ui.rect(x + 158.0, cy, 44.0, 16.0, super::slot()); + + let klw = ui.measure_text(key_label, 1.5); + ui.text( + key_label, + x + 158.0 + (44.0 - klw) * 0.5, + cy + 3.0, + 1.5, + text(), + ); + let pending = model.rebind_pending == Some(slot); + let btn_label = if pending { "PRESS KEY..." } else { "REBIND" }; + let mut style = crate::hud::button_style(); + if pending { + style.edge = accent(); + style.text = accent(); + } + if ui.button(x + 210.0, cy, 86.0, 16.0, btn_label, style) && !pending { + out.push(WindowAction::RebindToolbarSlot(slot)); + } + cy += 19.0; + } + cy += 6.0; + + // ── INVENTORY ──────────────────────────────────────────────────────── + cy = section_title(ui, x, cy, "INVENTORY"); + ui.text("DEFAULT SNAP", x, cy + 4.0, 1.6, text()); + let mut bx = x + 130.0; + for step in SPLIT_SNAP_STEPS { + let label = if step >= 1000 { + format!("{}K", step / 1000) + } else { + format!("{step}") + }; + let bw = ui.measure_text(&label, 1.5) + 14.0; + let active = model.split_snap == step; + let resp = ui.interact(bx, cy, bw, 18.0); + ui.rect( + bx, + cy, + bw, + 18.0, + if active { [70, 92, 120, 240] } else { slot() }, + ); + + ui.text(&label, bx + 7.0, cy + 4.0, 1.5, text()); + if resp.clicked && !active { + out.push(WindowAction::SetSplitSnap(step)); + } + bx += bw + 6.0; + } + cy += 26.0; + + // ── INPUT (read-only reference) ────────────────────────────────────── + cy = section_title(ui, x, cy, "INPUT"); + for (label, keys) in &model.binding_reference { + if cy + 14.0 > bottom { + break; + } + ui.text(label, x, cy, 1.5, text()); + let kw = ui.measure_text(keys, 1.5); + ui.text(keys, x + w - kw - 8.0, cy, 1.5, dim()); + cy += 15.0; + } +} + +#[cfg(test)] +mod tests { + fn test_ctx(rect: [f32; 4]) -> crate::windows::Ctx { + crate::windows::Ctx { + spec: crate::windows::spec::surface("options").expect("options surface"), + rect, + tab: 0, + } + } + + use super::*; + + fn setup() -> (UiBuilder, OptionsModel, Icons) { + let icons = Icons::load(); + let ui = UiBuilder::new(icons.meta); + (ui, OptionsModel::sample(), icons) + } + + const RECT: [f32; 4] = [100.0, 60.0, 380.0, 640.0]; + + fn click( + ui: &mut UiBuilder, + model: &OptionsModel, + icons: &Icons, + bx: f32, + by: f32, + ) -> Vec<WindowAction> { + ui.set_input(bx, by, true); + ui.begin(1280, 720); + let mut out = Vec::new(); + draw(ui, test_ctx(RECT), model, icons, &mut out); + ui.set_input(bx, by, false); + ui.begin(1280, 720); + out.clear(); + draw(ui, test_ctx(RECT), model, icons, &mut out); + out + } + + #[test] + fn theme_swatch_click_sets_theme() { + let (mut ui, model, icons) = setup(); + // Swatches start at x+130, y = 60+4 (title consumed 22 → cy=86)… + // swatch row cy after section_title = 60+4+22 = 86; third swatch at + // 130 + 2*(26+8) = x+198. + let out = click(&mut ui, &model, &icons, 100.0 + 198.0 + 13.0, 86.0 + 13.0); + assert_eq!(out, vec![WindowAction::SetTheme(2)]); + } + + // Layout walk for RECT: DISPLAY title→86, swatches 86..112, DUST row 130, + // ZOOM row 152, TOOLBAR title→198(=176+22), ACTIONS row 198..224, slot + // rows from 224 step 19, INVENTORY title after 458→480, snap segs at 480. + #[test] + fn snap_segment_emits_step() { + let (mut ui, model, icons) = setup(); + let mut button_x = 100.0 + 130.0; + for label in ["1", "5", "10", "100"] { + button_x += ui.measure_text(label, 1.5) + 14.0 + 6.0; + } + let button_w = ui.measure_text("1K", 1.5) + 14.0; + let out = click( + &mut ui, + &model, + &icons, + button_x + button_w * 0.5, + 524.0 + 9.0, + ); + assert_eq!(out, vec![WindowAction::SetSplitSnap(1000)]); + } + + #[test] + fn rebind_button_begins_capture_for_slot_one() { + let (mut ui, model, icons) = setup(); + // Slot rows start at cy=224; REBIND button at x+210, 86 wide, 16 tall. + let out = click(&mut ui, &model, &icons, 100.0 + 210.0 + 43.0, 268.0 + 8.0); + assert_eq!(out, vec![WindowAction::RebindToolbarSlot(0)]); + } + + #[test] + fn dust_slider_press_emits_quantized_value() { + let (mut ui, model, icons) = setup(); + // Slider track: x+130..x+130+(w-200) at y 128..144. Press at 75%. + let track_x = 100.0 + 130.0; + let track_w = 380.0 - 200.0; + ui.set_input(track_x + track_w * 0.75, 136.0, true); + ui.begin(1280, 720); + let mut out = Vec::new(); + draw(&mut ui, test_ctx(RECT), &model, &icons, &mut out); + let dust = out.iter().find_map(|a| match a { + WindowAction::SetDust(v) => Some(*v), + _ => None, + }); + let v = dust.expect("slider press emits SetDust"); + assert!( + (v - 0.75).abs() < 0.051, + "quantized near press point, got {v}" + ); + // Quantized to the reference 0.05 grid. + assert!(((v / 0.05).round() * 0.05 - v).abs() < 1e-6); + } + #[test] + fn window_opacity_slider_press_emits_action() { + let (mut ui, model, icons) = setup(); + let track_x = 100.0 + 130.0; + let track_w = 380.0 - 200.0; + // WINDOW OPACITY slider is row at y=152..168. Press at 50% (0.35..1.0 => 0.675). + ui.set_input(track_x + track_w * 0.5, 158.0, true); + ui.begin(1280, 720); + let mut out = Vec::new(); + draw(&mut ui, test_ctx(RECT), &model, &icons, &mut out); + let opacity = out.iter().find_map(|a| match a { + WindowAction::SetWindowOpacity(v) => Some(*v), + _ => None, + }); + let v = opacity.expect("slider press emits SetWindowOpacity"); + assert!((v - 0.675).abs() < 0.05, "got {v}"); + } + + #[test] + fn hud_opacity_slider_press_emits_action() { + let (mut ui, model, icons) = setup(); + let track_x = 100.0 + 130.0; + let track_w = 380.0 - 200.0; + // HUD OPACITY slider is row at y=174..190. Press at 50% (0.35..1.0 => 0.675). + ui.set_input(track_x + track_w * 0.5, 180.0, true); + ui.begin(1280, 720); + let mut out = Vec::new(); + draw(&mut ui, test_ctx(RECT), &model, &icons, &mut out); + let opacity = out.iter().find_map(|a| match a { + WindowAction::SetHudOpacity(v) => Some(*v), + _ => None, + }); + let v = opacity.expect("slider press emits SetHudOpacity"); + assert!((v - 0.675).abs() < 0.05, "got {v}"); + } + + #[test] + fn options_surface_quad_count() { + let (mut ui, model, icons) = setup(); + ui.begin(1280, 720); + let mut out = Vec::new(); + draw(&mut ui, test_ctx(RECT), &model, &icons, &mut out); + assert!( + ui.quads > 0, + "options surface draws quads, got {}", + ui.quads + ); + } + + #[test] + fn zoom_is_display_only_no_action_path() { + let (mut ui, model, icons) = setup(); + // Clicking the zoom readout (row at y≈152) emits nothing. + let out = click(&mut ui, &model, &icons, 100.0 + 140.0, 156.0); + assert!(out.is_empty(), "zoom row is inert, got {out:?}"); + } +} diff --git a/client-rust/source/app/src/windows/project.rs b/client-rust/source/app/src/windows/project.rs new file mode 100644 index 00000000..56177c49 --- /dev/null +++ b/client-rust/source/app/src/windows/project.rs @@ -0,0 +1,1198 @@ +//! Connected-mode projection: rebuild the live [`WindowModel`] player-scoped +//! sections from the accepted [`AuthorityStore`] after each snapshot/delta. +//! +//! Rebuilds are wholesale (fresh vectors decoded from the wire-shaped store +//! values), so a present-but-empty wire section clears the prior rows instead +//! of merging into them, and an absent player actor clears the player +//! summaries. No sample data and no inferred quantities: every field either +//! decodes from the store, comes from the explicit [`ProjectContext`] the +//! world layer resolves (terminal reach, selection, loot target), or stays at +//! its typed `Default` — a missing context keeps the matching gate closed. + +use serde::Deserialize; +use serde_json::Value; +use successor_client_proto::packets::GameActorSnapshot; + +use crate::game::authority::AuthorityStore; +use crate::hud::{clean_actor_name, sanitize_text, weapon_display_name}; + +use super::model::*; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProgressionSpec { + professions: std::collections::HashMap<String, String>, + skill_nodes: Vec<ProgressionNode>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProgressionNode { + id: String, + profession: String, + label: String, + #[serde(default)] + row: u8, + #[serde(default)] + column: u8, + #[serde(default)] + xp_cost: i64, + #[serde(default)] + skill_point_cost: i64, + #[serde(default)] + credit_cost: i64, + #[serde(default)] + title: Option<String>, + #[serde(default)] + grants: Vec<String>, + #[serde(default)] + prerequisites: Vec<String>, +} + +static PROGRESSION_SPEC: std::sync::LazyLock<ProgressionSpec> = std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!( + "../../../../../client/src/slice-core/specs/progression.v1.json" + )) + .expect("checked-in progression spec validates") +}); + +fn progression_spec() -> &'static ProgressionSpec { + &PROGRESSION_SPEC +} + +fn same_skill_id(left: &str, right: &str) -> bool { + left.replace('_', "-") == right.replace('_', "-") +} + +/// Decode every wire row in `values` as `T`. A row that does not decode is +/// skipped so one malformed value never poisons the rest of the projection. +fn decode_rows<T: serde::de::DeserializeOwned>(values: &[Value]) -> Vec<T> { + values + .iter() + .filter_map(|v| serde_json::from_value(v.clone()).ok()) + .collect() +} + +/// Decode an optional wire section; `None`/null/malformed all project `None`. +fn decode_opt<T: serde::de::DeserializeOwned>(value: Option<&Value>) -> Option<T> { + value.and_then(|v| serde_json::from_value(v.clone()).ok()) +} + +/// World-layer context the store cannot know: terminal/kiosk reach, the +/// current selection, loot target, checked-in catalog joins, and the host +/// command queue's pending envelopes. Everything defaults to "absent" — a +/// default context projects honest closed gates, never a fake open one. +#[derive(Clone, Debug, Default)] +pub struct ProjectContext { + /// Planet the active shard hosts (launch/session scope). + pub planet_id: String, + /// Selected actor id (trade proposal, group invite, duel, examine). + pub selected_actor_id: Option<String>, + /// Selected held stack `(container, stack_id)` for item examine. + pub selected_inventory: Option<(String, String)>, + /// Player corpse the world layer opened for looting. + pub loot_corpse_id: Option<String>, + /// Examined prop `(prop id, label)`. + pub examine_prop: Option<(String, String)>, + /// Terminal/kiosk gates resolved from world prop reach. + pub bank_gate: Gate, + pub clone_gate: Gate, + pub factory_gate: Gate, + pub guild_gate: Gate, + pub travel_gate: Gate, + /// Origin terminal `(planet id, city id)` when a travel terminal is linked. + pub travel_origin: Option<(String, String)>, + /// Checked-in travel chart (world/asset join, not authority state). + pub travel_planets: Vec<TravelPlanet>, + /// In-range trainer NPC (skills / converse / starter tool). + pub trainer: Option<TrainerView>, + /// Trainer-offered career goals `(goal id, label)` from checked-in scripts. + pub career_goals: Vec<(String, String)>, + /// Checked-in build catalog + world-computed ghost preview. + pub build_catalog: Vec<BuildCatalogItem>, + pub build_ghost: Option<BuildGhost>, + /// Host queue pending envelopes `(command id, kind)` for receipt joins. + pub pending: Vec<(u64, String)>, + /// Monotonic UI clock (ms) stamping newly observed receipts. + pub now_ms: f64, +} + +/// A closed gate with no note carries the shared reference copy. +fn gate_or(gate: &Gate, closed_note: &str) -> Gate { + if !gate.available && gate.note.is_empty() { + Gate::closed(closed_note) + } else { + gate.clone() + } +} + +fn actor_label(a: &GameActorSnapshot) -> String { + clean_actor_name(&a.display_name, &a.label, &a.id).to_uppercase() +} + +fn is_player_actor(a: &GameActorSnapshot) -> bool { + a.role.as_deref() == Some("player") +} + +fn upper(row: &InventoryRow) -> String { + row.item.to_ascii_uppercase() +} + +fn key_of(row: &InventoryRow) -> &str { + row.item_key.as_deref().unwrap_or("") +} + +/// Rebuild the store-derived `WindowModel` sections. Call after applying a +/// network packet (NOT per frame). `player_id` is the launch fallback used +/// when the snapshot's `player_actor_id` has not resolved yet. +pub fn project( + store: &AuthorityStore, + player_id: &str, + ctx: &ProjectContext, + model: &mut WindowModel, +) { + model.connected = true; + model.tick = store.tick; + + let player = store + .actors + .get(&store.player_actor_id) + .or_else(|| store.actors.get(player_id)); + let player_pos = player.map(|a| (a.x, a.y)).unwrap_or((0.0, 0.0)); + let mut inventory_rows: Vec<InventoryRow> = decode_rows(&store.inventory); + if let Some(weapon) = player.and_then(|actor| actor.weapon.as_ref()) { + let item_id = weapon.weapon_item_id.unwrap_or(0); + let variant_id = weapon.weapon_variant_id.unwrap_or(0); + if item_id > 0 { + for row in &mut inventory_rows { + if i64::from(row.item_id) == item_id && i64::from(row.variant_id) == variant_id { + row.equipped = true; + } + } + } + } + + // Inventory: wholesale rebuild — a present-empty wire section projects to + // zero rows, and player-scoped scalars clear when the actor is absent. + model.inventory = InventoryModel { + rows: inventory_rows, + reservations: decode_rows(&store.reservations), + credits: player.and_then(|a| a.credits).unwrap_or(0), + weapon_label: player + .and_then(|a| a.weapon.as_ref()) + .and_then(|w| w.weapon_id.as_deref()) + .map(weapon_display_name), + }; + + // Character sheet: absent player ⇒ cleared summary. + let summary = player.map(player_summary).unwrap_or_default(); + model.character = CharacterModel { + area_id: player + .map(|a| sanitize_text(&a.area_id, 32).to_uppercase()) + .unwrap_or_default(), + player: summary.clone(), + // Spec-labelled earned titles join with the checked-in progression + // spec in the skills slice; the active server title is always offered + // so SELECT/CLEAR routes to `SetProfessionTitle` without invention. + title_options: summary.active_title.iter().cloned().collect(), + career_goal_label: summary.career_goal_id.clone(), + }; + + // Bank: owner-scoped snapshot + world-resolved kiosk gate. + let bank_snapshot: Option<BankSnapshot> = decode_opt(store.bank.as_ref()); + model.bank = BankModel { + gate: gate_or(&ctx.bank_gate, "AT BANK TERMINAL ONLY"), + bank: bank_snapshot.clone(), + }; + + // Clone terminal: backup lives on the bank snapshot; life state is live. + model.clone = CloneModel { + gate: gate_or(&ctx.clone_gate, "AT CLONE TERMINAL ONLY"), + backup_present: bank_snapshot + .as_ref() + .map(|b| b.backup_present) + .unwrap_or(false), + backup_saved_tick: bank_snapshot.as_ref().and_then(|b| b.backup_saved_tick), + backup_skill_count: bank_snapshot + .as_ref() + .map(|b| b.backup_skill_count) + .unwrap_or(0), + backup_cost: bank_snapshot.as_ref().map(|b| b.backup_cost).unwrap_or(0), + vault_credits: bank_snapshot.as_ref().map(|b| b.credits).unwrap_or(0), + wallet_credits: summary.credits, + dead: matches!( + summary.life_state.as_str(), + "dead" | "downed" | "incapacitated" + ), + clone_sickness_remaining_ms: summary.clone_sickness_remaining_ms, + }; + + // Loot: only the world-opened corpse projects; rows are the inventory + // rows streamed for that corpse's container. + model.loot = ctx.loot_corpse_id.as_deref().and_then(|cid| { + if let Some(corpse) = decode_rows::<PlayerCorpse>(&store.player_corpses) + .into_iter() + .find(|c| c.id == cid) + { + let dx = corpse.x - player_pos.0; + let dy = corpse.y - player_pos.1; + let rows: Vec<InventoryRow> = model + .inventory + .rows + .iter() + .filter(|r| r.container == corpse.container) + .cloned() + .collect(); + return Some(LootModel { + kind: LootTargetKind::Corpse, + target_id: corpse.id.clone(), + container: corpse.container.clone(), + label: format!( + "CORPSE OF {}", + sanitize_text(&corpse.owner_label, 32).to_uppercase() + ), + rows, + credits_present: corpse.credits_present, + credits_count: corpse.credits_count, + in_reach: (dx * dx + dy * dy).sqrt() <= EXTRACTOR_REACH_CELLS, + rights_mine: corpse.is_owner, + harvest_actor_id: None, + }); + } + // NPC world corpse: `player_corpses` only carries player bodies, but + // the authority marks any openable corpse on its actor snapshot and + // streams its `corpse:<actor-id>` container rows to the entitled + // looter like every other container. + let actor = store.actors.get(cid)?; + if actor.lootable != Some(true) { + return None; + } + let container = format!("corpse:{cid}"); + let dx = actor.x - player_pos.0; + let dy = actor.y - player_pos.1; + let rows: Vec<InventoryRow> = model + .inventory + .rows + .iter() + .filter(|r| r.container == container) + .cloned() + .collect(); + Some(LootModel { + kind: LootTargetKind::Corpse, + target_id: cid.to_string(), + container, + label: format!( + "CORPSE OF {}", + sanitize_text(&actor_label(actor), 32).to_uppercase() + ), + rows, + credits_present: false, + credits_count: 0, + in_reach: (dx * dx + dy * dy).sqrt() <= EXTRACTOR_REACH_CELLS, + rights_mine: actor + .loot_rights_actor_id + .as_deref() + .is_none_or(|rights| rights == summary.actor_id), + harvest_actor_id: None, + }) + }); + + // Trade: streamed session + live offerable stacks + selected propose target. + let trade_session: Option<TradeSession> = decode_opt(store.trade_session.as_ref()); + let partner_label = trade_session + .as_ref() + .map(|s| { + store + .actors + .get(&s.partner_actor_id) + .map(actor_label) + .unwrap_or_else(|| sanitize_text(&s.partner_actor_id, 32).to_uppercase()) + }) + .unwrap_or_default(); + model.trade = TradeModel { + session: trade_session, + partner_label, + offerable: model + .inventory + .rows + .iter() + .filter(|r| !r.in_exchange() && r.available > 0) + .cloned() + .collect(), + propose_target: ctx + .selected_actor_id + .as_deref() + .filter(|id| *id != summary.actor_id) + .and_then(|id| store.actors.get(id)) + .filter(|a| is_player_actor(a) && a.life_state == "alive") + .map(|a| (a.id.clone(), actor_label(a))), + }; + + // Craft: streamed session + drafted schematics + world gates. + model.craft = CraftModel { + session: decode_opt(store.craft_session.as_ref()), + drafts: decode_rows(&store.drafted_schematics), + factory: gate_or(&ctx.factory_gate, "AT FACTORY TERMINAL ONLY"), + trainer_actor_id: ctx + .trainer + .as_ref() + .filter(|t| t.in_range) + .map(|t| t.actor_id.clone()), + }; + + // Survey / extraction / camps. + let spawns: Vec<ResourceSpawn> = decode_rows(&store.resource_spawns); + let mut families: Vec<SurveyFamilyOption> = Vec::new(); + for s in &spawns { + if !families.iter().any(|f| f.family == s.family) { + families.push(SurveyFamilyOption { + family: s.family.clone(), + label: sanitize_text( + if s.name.is_empty() { + &s.family + } else { + &s.name + }, + 28, + ) + .to_uppercase(), + }); + } + } + let extractors: Vec<ExtractorView> = decode_rows::<PlacedExtractor>(&store.placed_extractors) + .into_iter() + .map(|vm| { + let dx = vm.cell_x as f32 - player_pos.0; + let dy = vm.cell_y as f32 - player_pos.1; + let distance = (dx * dx + dy * dy).sqrt(); + ExtractorView { + distance, + in_reach: distance <= EXTRACTOR_REACH_CELLS, + vm, + } + }) + .collect(); + let camps: Vec<CampView> = decode_rows::<PlacedCamp>(&store.placed_camps) + .into_iter() + .map(|vm| { + let dx = vm.cell_x as f32 - player_pos.0; + let dy = vm.cell_y as f32 - player_pos.1; + let distance = (dx * dx + dy * dy).sqrt(); + CampView { + distance, + in_footprint: distance <= CAMP_FOOTPRINT_CELLS, + vm, + } + }) + .collect(); + model.survey = SurveyModel { + families, + results: decode_rows(&store.survey_results), + sample_cooldown_ticks: (summary.next_sample_tick - store.tick as i64).max(0), + spawns, + own_camp_placed: camps.iter().any(|c| c.vm.is_owner), + extractors, + camps, + batteries: model + .inventory + .rows + .iter() + .filter(|r| key_of(r).starts_with("battery") || upper(r).contains("BATTERY")) + .cloned() + .collect(), + }; + + // Skills: join the complete checked-in tree with live profession, budget, + // trained-box, wallet, and in-range trainer state. + let spec = progression_spec(); + let trainer_ready = ctx.trainer.as_ref().is_some_and(|trainer| trainer.in_range); + let mut earned_titles = Vec::new(); + let professions = summary + .professions + .iter() + .map(|profession| { + let trained = |id: &str| { + profession + .skill_boxes + .iter() + .any(|owned| same_skill_id(owned, id)) + }; + let mut boxes: Vec<SkillBoxView> = spec + .skill_nodes + .iter() + .filter(|node| node.profession == profession.id) + .map(|node| { + let is_trained = trained(&node.id); + if is_trained { + if let Some(title) = &node.title { + earned_titles.push(ProfessionTitle { + id: node.id.clone(), + label: title.clone(), + skill_box_id: node.id.clone(), + }); + } + } + let prereqs_met = node.prerequisites.iter().all(|id| trained(id)); + let enough_xp = profession.xp >= node.xp_cost; + let enough_points = summary.skill_points_used + node.skill_point_cost + <= summary.skill_points_cap; + let enough_credits = summary.credits >= node.credit_cost; + let trainer_matches = ctx.trainer.as_ref().is_some_and(|trainer| { + trainer.in_range + && (trainer.profession_id.is_empty() + || trainer.profession_id == profession.id) + }); + let available = !is_trained + && prereqs_met + && enough_xp + && enough_points + && enough_credits + && trainer_matches; + let deny_reason = if is_trained || available { + String::new() + } else if !trainer_ready { + DENY_RANGE.into() + } else if !prereqs_met { + "PREREQUISITES NOT MET".into() + } else if !enough_xp { + "INSUFFICIENT PROFESSION XP".into() + } else if !enough_points { + "SKILL POINT CAP".into() + } else if !enough_credits { + "INSUFFICIENT CREDITS".into() + } else { + "WRONG PROFESSION TRAINER".into() + }; + SkillBoxView { + id: node.id.clone(), + label: node.label.clone(), + row: node.row, + column: node.column, + xp_cost: node.xp_cost, + skill_point_cost: node.skill_point_cost, + credit_cost: node.credit_cost, + title: node.title.clone(), + grants: node.grants.clone(), + prerequisites: node.prerequisites.clone(), + trained: is_trained, + available, + deny_reason, + } + }) + .collect(); + if boxes.is_empty() { + boxes.extend( + profession + .skill_boxes + .iter() + .enumerate() + .map(|(index, id)| SkillBoxView { + id: id.clone(), + label: sanitize_text(id, 40) + .replace(['_', '-'], " ") + .to_uppercase(), + row: index as u8, + trained: true, + ..SkillBoxView::default() + }), + ); + } + ProfessionTreeView { + id: profession.id.clone(), + label: spec + .professions + .get(&profession.id) + .cloned() + .unwrap_or_else(|| profession.label.clone()), + xp: profession.xp, + boxes, + } + }) + .collect(); + if let Some(active) = &summary.active_title { + if !earned_titles.iter().any(|title| title.id == active.id) { + earned_titles.push(active.clone()); + } + } + model.character.title_options = earned_titles; + model.skills = SkillsModel { + professions, + skill_points_used: summary.skill_points_used, + skill_points_cap: summary.skill_points_cap, + credits: summary.credits, + trainer: ctx.trainer.clone(), + }; + + // Converse: streamed dialogue deliveries (bounded) + trainer context. + let mut deliveries: Vec<DialogueDelivery> = decode_rows(&store.dialogue_deliveries); + if deliveries.len() > 32 { + let cut = deliveries.len() - 32; + deliveries.drain(..cut); + } + model.converse = ConverseModel { + npc: ctx.trainer.clone(), + deliveries, + career_goals: ctx.career_goals.clone(), + teachable: model + .skills + .professions + .iter() + .flat_map(|profession| profession.boxes.iter()) + .filter(|skill| skill.available && !skill.trained) + .cloned() + .collect(), + career_goal_id: summary.career_goal_id.clone(), + }; + + // Travel: held tickets are live inventory rows; chart/gate come from the + // world layer (checked-in chart + origin terminal reach). + model.travel = TravelModel { + gate: gate_or(&ctx.travel_gate, "AT TRAVEL TERMINAL ONLY"), + origin: ctx.travel_origin.clone(), + planets: ctx.travel_planets.clone(), + tickets: model + .inventory + .rows + .iter() + .filter(|r| r.is_travel_ticket()) + .cloned() + .collect(), + wallet_credits: summary.credits, + }; + + // Player association (guild). + model.pa = PaModel { + gate: gate_or(&ctx.guild_gate, "AT PA TERMINAL ONLY"), + view: decode_opt(store.guilds.as_ref()).unwrap_or_default(), + my_actor_id: summary.actor_id.clone(), + wallet_credits: summary.credits, + target: ctx + .selected_actor_id + .as_deref() + .filter(|id| *id != summary.actor_id) + .and_then(|id| store.actors.get(id)) + .filter(|actor| is_player_actor(actor)) + .map(|actor| (actor.id.clone(), actor_label(actor))), + }; + + // Groups / duels. + let duel: DuelView = decode_opt(store.duels.as_ref()).unwrap_or_default(); + model.group = GroupModel { + my_actor_id: summary.actor_id.clone(), + group: decode_opt(store.groups.as_ref()).unwrap_or_default(), + deathblow_target: duel + .active_duel + .as_ref() + .and_then(|d| store.actors.get(&d.opponent_actor_id)) + .filter(|a| matches!(a.life_state.as_str(), "downed" | "incapacitated")) + .map(|a| (a.id.clone(), actor_label(a))), + duel, + outcomes: decode_rows(&store.duel_outcomes), + target: ctx + .selected_actor_id + .as_deref() + .filter(|id| *id != summary.actor_id) + .and_then(|id| store.actors.get(id)) + .map(|a| (a.id.clone(), actor_label(a), is_player_actor(a))), + }; + + // Farming: parcels/plots stream whole; seed/fertilizer/structure stacks + // are live inventory candidates (authority re-validates every verb). + model.farm = FarmModel { + parcels: decode_rows(&store.placed_parcels), + plots: decode_rows(&store.farm_plots), + seeds: model + .inventory + .rows + .iter() + .filter(|r| key_of(r).starts_with("seed") || upper(r).contains("SEED")) + .cloned() + .collect(), + fertilizers: model + .inventory + .rows + .iter() + .filter(|r| { + key_of(r).contains("fertilizer") + || upper(r).contains("FERTILIZER") + || upper(r).contains("COMPOST") + }) + .cloned() + .collect(), + structures: model + .inventory + .rows + .iter() + .filter(|r| { + key_of(r).starts_with("farm_") + || upper(r).contains("SPRINKLER") + || upper(r).contains("SCARECROW") + }) + .cloned() + .collect(), + player_cell: (player_pos.0.floor() as i64, player_pos.1.floor() as i64), + area_id: player.map(|a| a.area_id.clone()).unwrap_or_default(), + planet_id: ctx.planet_id.clone(), + }; + + // Construction: live components + owned-parcel gate; the catalog and the + // ghost preview are world-layer joins. + let building: BuildingProjection = decode_opt(store.building.as_ref()).unwrap_or_default(); + let player_cell = model.farm.player_cell; + let mut materials: Vec<(String, i64)> = Vec::new(); + for r in model + .inventory + .rows + .iter() + .filter(|r| r.resource_stats.is_some()) + { + let id = r + .item_key + .clone() + .unwrap_or_else(|| r.item.to_ascii_lowercase()); + match materials.iter_mut().find(|(m, _)| *m == id) { + Some((_, n)) => *n += r.available, + None => materials.push((id, r.available)), + } + } + let mut components = building.components; + components.sort_by(|a, b| { + let da = (a.cell_x - player_cell.0).pow(2) + (a.cell_y - player_cell.1).pow(2); + let db = (b.cell_x - player_cell.0).pow(2) + (b.cell_y - player_cell.1).pow(2); + da.cmp(&db) + }); + model.build = BuildModel { + parcel: model + .farm + .parcels + .iter() + .find(|p| { + p.is_owner + && player_cell.0 >= p.rect.x + && player_cell.0 < p.rect.x + p.rect.w + && player_cell.1 >= p.rect.y + && player_cell.1 < p.rect.y + p.rect.h + }) + .cloned(), + catalog: ctx.build_catalog.clone(), + materials, + ghost: ctx.build_ghost.clone(), + components, + }; + + // Bioengineering: streamed session/scans + live genome-bearing stacks. + model.splice = SpliceModel { + session: decode_opt(store.splice_session.as_ref()), + scans: decode_rows(&store.genome_scans), + samples: model + .inventory + .rows + .iter() + .filter(|r| { + r.metadata + .as_ref() + .map(|m| m.get("genome").is_some()) + .unwrap_or(false) + }) + .cloned() + .collect(), + sample_target: None, + }; + + // Examine: current actor/item/prop selection joined against live state. + model.examine = ExamineModel { + actor: ctx + .selected_actor_id + .as_deref() + .and_then(|id| store.actors.get(id)) + .map(|a| ExamineActor { + actor_id: a.id.clone(), + name: actor_label(a), + descriptor: a + .descriptor + .as_deref() + .map(|d| sanitize_text(d, 96)) + .unwrap_or_default(), + life_state: a.life_state.clone(), + faction_id: a.faction_id.clone(), + pvp_status: a.pvp_status.clone().unwrap_or_default(), + organization_tag: a.player_organization_tag.clone(), + health: a.vitals.health, + health_max: a.max_vitals.health, + }), + item: ctx + .selected_inventory + .as_ref() + .and_then(|(container, stack_id)| model.inventory.row(container, stack_id)) + .cloned(), + prop: ctx.examine_prop.clone(), + }; + + // Receipts: pending kinds from the host queue; the last receipt keeps its + // first-observed timestamp so the reference status flash can expire. + let prev = model.receipts.last.clone(); + model.receipts = ReceiptsModel { + pending_kinds: ctx.pending.iter().map(|(_, k)| k.clone()).collect(), + last: store.last_receipt.as_ref().map(|r| { + let carried = prev.as_ref().filter(|p| p.command_id == r.command_id); + ReceiptView { + command_id: r.command_id, + kind: ctx + .pending + .iter() + .find(|(id, _)| *id == r.command_id) + .map(|(_, k)| k.clone()) + .or_else(|| carried.map(|p| p.kind.clone())) + .unwrap_or_default(), + accepted: r.accepted, + reason_code: r.reason_code.clone(), + at_ms: carried.map(|p| p.at_ms).unwrap_or(ctx.now_ms), + } + }), + }; + + model.player = summary; +} + +/// Decode the typed player actor into the window-facing summary. Fields the +/// typed snapshot does not carry stay `Default` — joined in by their owning +/// slices, not guessed. +fn player_summary(a: &GameActorSnapshot) -> PlayerSummary { + PlayerSummary { + actor_id: a.id.clone(), + name: clean_actor_name(&a.display_name, &a.label, &a.id).to_uppercase(), + health: a.vitals.health, + health_max: a.max_vitals.health, + action: a.vitals.action, + action_max: a.max_vitals.action, + life_state: a.life_state.clone(), + posture: a.posture.clone().unwrap_or_default(), + credits: a.credits.unwrap_or(0), + faction_id: a.faction_id.clone(), + pvp_status: a.pvp_status.clone().unwrap_or_default(), + professions: decode_rows(&a.professions), + active_title: a + .active_title + .as_ref() + .and_then(|v| serde_json::from_value(v.clone()).ok()), + career_goal_id: a.career_goal_id.clone(), + skill_points_used: a.skill_points_used.unwrap_or(0), + skill_points_cap: a.skill_points_cap.unwrap_or(0), + weapon: a.weapon.as_ref().map(|weapon| WeaponState { + weapon_id: weapon.weapon_id.clone().unwrap_or_default(), + ammo_type: weapon.ammo_type.clone().unwrap_or_default(), + loaded_rounds: weapon.loaded_rounds.unwrap_or(0), + magazine_size: weapon.magazine_size.unwrap_or(0), + reload_remaining_ticks: weapon.reload_remaining_ticks.unwrap_or(0), + reload_total_ticks: weapon.reload_total_ticks.unwrap_or(0), + }), + shield: a + .personal_shield + .as_ref() + .and_then(|v| serde_json::from_value(v.clone()).ok()), + statuses: decode_rows(&a.statuses), + in_combat: a.in_combat.unwrap_or(false), + clone_sickness_remaining_ms: a.clone_sickness_remaining_ms.unwrap_or(0), + next_sample_tick: a.next_sample_tick.unwrap_or(0), + worn: a.worn.iter().filter_map(|w| w.item_id.clone()).collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use successor_client_proto::packets::{ + GameActorVitals, GameActorWeapon, GameShardDelta, GameShardSnapshot, + }; + + fn live_snapshot() -> GameShardSnapshot { + let mut s = GameShardSnapshot { + tick: 41, + player_actor_id: "actor-1".into(), + ..Default::default() + }; + s.actors.insert( + "actor-1".into(), + GameActorSnapshot { + id: "actor-1".into(), + display_name: "Vett Marr".into(), + area_id: "open-desert".into(), + life_state: "alive".into(), + lifecycle_seq: 1, + x: 10.0, + y: 10.0, + vitals: GameActorVitals { + health: 80.0, + action: 55.0, + spirit: 10.0, + }, + max_vitals: GameActorVitals { + health: 100.0, + action: 100.0, + spirit: 100.0, + }, + credits: Some(1250), + weapon: Some(GameActorWeapon { + weapon_id: Some("weapon_slugthrower_mk2".into()), + reload_remaining_ticks: Some(0), + ..Default::default() + }), + professions: vec![json!({ + "id": "prospector", + "label": "PROSPECTOR", + "xp": 320, + "skillBoxes": ["prospector_novice"] + })], + ..Default::default() + }, + ); + // Exact wire shape (`GameInventoryRow`: numeric stackId, camelCase). + s.inventory = vec![ + json!({ + "container": "actor-1", + "stackId": 7, + "item": "Slug Rounds", + "itemId": 12, + "variantId": 0, + "quantity": 40, + "reserved": 0, + "available": 40 + }), + json!({ + "container": "actor-1", + "stackId": 8, + "item": "Field Medkit", + "itemId": 31, + "variantId": 0, + "quantity": 2, + "reserved": 1, + "available": 1 + }), + ]; + s.reservations = vec![json!({ + "id": 3, + "actor": "actor-1", + "purpose": "craft", + "from": "actor-1", + "item": "Field Medkit", + "quantity": 1 + })]; + s + } + + fn project_default(store: &AuthorityStore, model: &mut WindowModel) { + project(store, "actor-1", &ProjectContext::default(), model); + } + + #[test] + fn snapshot_projects_live_inventory_and_character() { + let mut store = AuthorityStore::new(); + store.apply_snapshot(&live_snapshot()); + let mut model = WindowModel::default(); + project_default(&store, &mut model); + + assert!(model.connected); + assert_eq!(model.tick, 41); + assert_eq!(model.inventory.rows.len(), 2); + assert_eq!(model.inventory.rows[0].stack_id, "7"); + assert_eq!(model.inventory.rows[0].item, "Slug Rounds"); + assert_eq!(model.inventory.rows[0].quantity, 40); + assert_eq!(model.inventory.reservations.len(), 1); + assert_eq!(model.inventory.credits, 1250); + assert_eq!( + model.inventory.weapon_label.as_deref(), + Some("SLUGTHROWER MK2") + ); + + assert_eq!(model.character.player.name, "VETT MARR"); + assert_eq!(model.character.player.credits, 1250); + assert_eq!(model.character.player.health, 80.0); + assert_eq!(model.character.player.health_max, 100.0); + assert_eq!(model.character.area_id, "OPEN-DESERT"); + assert_eq!(model.character.player.professions.len(), 1); + assert_eq!(model.character.player.professions[0].label, "PROSPECTOR"); + assert_eq!(model.player.actor_id, "actor-1"); + // Trained skill boxes surface without a spec join. + assert_eq!(model.skills.professions.len(), 1); + assert!(model.skills.professions[0].boxes[0].trained); + } + #[test] + fn equipped_weapon_projects_exact_row_and_magazine_state() { + let mut snapshot = live_snapshot(); + snapshot.actors.get_mut("actor-1").unwrap().weapon = Some(GameActorWeapon { + weapon_id: Some("wpn-launcher".into()), + weapon_item_id: Some(3127), + weapon_variant_id: Some(9), + ammo_type: Some("slug_iron".into()), + loaded_rounds: Some(6), + magazine_size: Some(30), + reload_remaining_ticks: Some(15), + reload_total_ticks: Some(60), + ..Default::default() + }); + snapshot.inventory.push(json!({ + "container": "actor-1", + "stackId": 9, + "item": "Flare Net Launcher", + "itemId": 3127, + "variantId": 9, + "quantity": 1, + "reserved": 0, + "available": 1, + "equipped": false + })); + + let mut store = AuthorityStore::new(); + store.apply_snapshot(&snapshot); + let mut model = WindowModel::default(); + project_default(&store, &mut model); + + let row = model + .inventory + .rows + .iter() + .find(|row| row.item_id == 3127) + .expect("launcher inventory row"); + assert!(row.equipped); + assert_eq!( + model.inventory.weapon_label.as_deref(), + Some("WPN LAUNCHER") + ); + let weapon = model.player.weapon.as_ref().expect("player weapon"); + assert_eq!(weapon.ammo_type, "slug_iron"); + assert_eq!(weapon.loaded_rounds, 6); + assert_eq!(weapon.magazine_size, 30); + assert_eq!(weapon.reload_remaining_ticks, 15); + assert_eq!(weapon.reload_total_ticks, 60); + } + + #[test] + fn present_empty_inventory_clears_rows() { + let mut store = AuthorityStore::new(); + store.apply_snapshot(&live_snapshot()); + let mut model = WindowModel::default(); + project_default(&store, &mut model); + assert_eq!(model.inventory.rows.len(), 2); + + // Present-but-empty delta sections mean "clear", not "unchanged". + let d = GameShardDelta { + tick: 42, + inventory: Some(Vec::new()), + reservations: Some(Vec::new()), + ..Default::default() + }; + store.apply_delta(&d); + project_default(&store, &mut model); + assert!(model.inventory.rows.is_empty()); + assert!(model.inventory.reservations.is_empty()); + // Player still live: scalars persist. + assert_eq!(model.inventory.credits, 1250); + } + + #[test] + fn absent_player_clears_summaries() { + let mut store = AuthorityStore::new(); + store.apply_snapshot(&live_snapshot()); + let mut model = WindowModel::default(); + project_default(&store, &mut model); + + let mut d = GameShardDelta { + tick: 43, + ..Default::default() + }; + d.actor_removals.push("actor-1".into()); + store.apply_delta(&d); + project_default(&store, &mut model); + + assert_eq!(model.inventory.credits, 0); + assert!(model.inventory.weapon_label.is_none()); + assert!(model.character.player.name.is_empty()); + assert!(model.character.area_id.is_empty()); + assert!(model.skills.professions.is_empty()); + } + + #[test] + fn malformed_row_is_skipped_not_poisoning() { + let mut store = AuthorityStore::new(); + let mut s = live_snapshot(); + s.inventory.push(json!("not-a-row")); + store.apply_snapshot(&s); + let mut model = WindowModel::default(); + project_default(&store, &mut model); + assert_eq!(model.inventory.rows.len(), 2); + } + + #[test] + fn sections_project_and_clear_from_live_store() { + let mut store = AuthorityStore::new(); + let mut s = live_snapshot(); + s.bank = Some(json!({ + "credits": 5000, + "items": [], + "backupPresent": true, + "backupSavedTick": 12, + "backupSkillCount": 4, + "backupCost": 250 + })); + // Trade sessions arrive as targeted room messages, not snapshot fields. + let trade_payload = json!({ + "proposalId": 9, + "partnerActorId": "actor-2", + "mine": { "actorId": "actor-1", "items": [], "coin": 0, "locked": false, "confirmed": false }, + "theirs": { "actorId": "actor-2", "items": [], "coin": 50, "locked": false, "confirmed": false }, + "bothLocked": false, + "stage": "negotiating", + "tick": 41 + }); + s.guilds = Some(json!({ + "guild": { "id": "g1", "name": "Dust Legion", "tag": "DL", "leaderActorId": "actor-1", "memberCount": 2, "wars": [] }, + "roster": [ + { "actorId": "actor-1", "name": "Vett", "role": "leader", "permissions": [], "online": true } + ], + "pendingInvites": [], + "directory": [] + })); + s.groups = Some(json!({ + "group": { "groupId": 3, "leaderActorId": "actor-1", "memberActorIds": ["actor-1"] }, + "members": [], + "pendingInvite": null + })); + s.duels = Some( + json!({ "activeDuel": null, "incomingChallenge": null, "outgoingChallenge": null }), + ); + s.placed_parcels = vec![json!({ + "parcelId": "parcel:1", + "planetId": "korvath", + "areaId": "open-desert", + "name": "HOMESTEAD", + "rect": { "x": 8, "y": 8, "w": 8, "h": 8 }, + "tier": "basic", + "isOwner": true, + "tilledTiles": 1, + "plantedTiles": 0 + })]; + s.building = Some(json!({ + "schema": "successor.authority-building.v1", + "tick": 41, + "components": [{ + "componentId": "b1", + "ownerActorId": "actor-1", + "parcelId": "parcel:1", + "catalogId": "wall_basic", + "kind": "wall", + "cellX": 9, + "cellY": 9, + "rotationQuarters": 0, + "doorOpen": false + }] + })); + let splice_payload = json!({ + "phase": "slots", + "speciesId": 2, + "speciesName": "Dune Creeper", + "slots": [], + "lines": [], + "assemblyQualityMilli": 0, + "pointsTotal": 4, + "pointsRemaining": 4, + "canAssemble": false, + "tick": 41 + }); + s.placed_extractors = vec![json!({ + "extractorId": "ex1", + "areaId": "open-desert", + "cellX": 10, + "cellY": 11, + "mode": "manual", + "biome": "desert", + "hopperPct": 40.0, + "collectableUnits": 4, + "batteryPct": 0.0, + "isOwner": true, + "familyLabel": "FERROUS" + })]; + store.apply_snapshot(&s); + store.apply_room_message("tradeSession", &trade_payload); + store.apply_room_message("spliceSession", &splice_payload); + + let mut model = WindowModel::default(); + project_default(&store, &mut model); + + let bank = model.bank.bank.as_ref().expect("bank snapshot"); + assert_eq!(bank.credits, 5000); + assert!(bank.backup_present); + // A default context keeps world gates honestly closed with a reason. + assert!(!model.bank.gate.available); + assert!(!model.bank.gate.note.is_empty()); + assert!(model.clone.backup_present); + assert_eq!(model.clone.vault_credits, 5000); + + let trade = model.trade.session.as_ref().expect("trade session"); + assert_eq!(trade.proposal_id, 9); + assert_eq!(trade.theirs.coin, 50); + assert_eq!(model.trade.offerable.len(), 2); + + assert_eq!(model.pa.view.guild.as_ref().unwrap().tag, "DL"); + assert_eq!(model.pa.my_actor_id, "actor-1"); + assert!(model.group.group.group.is_some()); + assert!(model.group.is_leader()); + + assert_eq!(model.farm.parcels.len(), 1); + assert_eq!(model.build.components.len(), 1); + assert!( + model.build.parcel.is_some(), + "player stands in owned parcel" + ); + + assert_eq!( + model.splice.session.as_ref().unwrap().species_name, + "Dune Creeper" + ); + assert_eq!(model.survey.extractors.len(), 1); + assert!( + model.survey.extractors[0].in_reach, + "extractor one cell away is in point-blank reach" + ); + + // A null targeted session payload clears the projected session. + store.apply_room_message("tradeSession", &Value::Null); + project_default(&store, &mut model); + assert!(model.trade.session.is_none(), "present-null clears session"); + } + + #[test] + fn receipts_join_pending_kinds_and_keep_first_timestamp() { + let mut store = AuthorityStore::new(); + store.apply_snapshot(&live_snapshot()); + store.last_receipt = Some(successor_client_proto::packets::GameCommandReceipt { + command_id: 77, + accepted: false, + tick: 41, + reason_code: Some("insufficient_funds".into()), + }); + + let mut model = WindowModel::default(); + let ctx = ProjectContext { + pending: vec![(77, "BankWithdrawCredits".into())], + now_ms: 1000.0, + ..Default::default() + }; + project(&store, "actor-1", &ctx, &mut model); + let last = model.receipts.last.clone().expect("receipt view"); + assert_eq!(last.kind, "BankWithdrawCredits"); + assert_eq!(last.at_ms, 1000.0); + assert_eq!(last.denied_copy(), "DENIED / INSUFFICIENT FUNDS"); + + // Re-projection keeps the first-observed stamp and resolved kind even + // after the envelope left the queue. + let ctx2 = ProjectContext { + now_ms: 2000.0, + ..Default::default() + }; + project(&store, "actor-1", &ctx2, &mut model); + let last = model.receipts.last.expect("receipt view"); + assert_eq!(last.at_ms, 1000.0); + assert_eq!(last.kind, "BankWithdrawCredits"); + } +} diff --git a/client-rust/source/app/src/windows/skills.rs b/client-rust/source/app/src/windows/skills.rs new file mode 100644 index 00000000..4ee1ceac --- /dev/null +++ b/client-rust/source/app/src/windows/skills.rs @@ -0,0 +1,271 @@ +//! SKILLS — profession skill-box trees with trained/available/denied states. +//! +//! Reads `WindowModel::skills` (live `SkillsModel` projection: profession +//! trees joined from the checked-in progression spec + actor state, skill +//! point budget, wallet credits, and the in-range trainer gate). Clicking a +//! purchasable box emits `WindowAction::Button("skill:buy:<box id>")` — the +//! host routes it onto `ClientCommand::PurchaseSkillBox`. Trained and denied +//! boxes never emit; the deny reason is the authoritative copy from the +//! projection. + +use super::{accent, dim, slot, text, WindowAction, WindowModel}; +use crate::hud::Icons; +use successor_engine_render::ui::UiBuilder; + +pub fn draw( + ui: &mut UiBuilder, + ctx: super::Ctx, + model: &WindowModel, + icons: &Icons, + out: &mut Vec<WindowAction>, +) { + let [x, y, w, h] = ctx.rect; + let s = &model.skills; + + // ── Header: point budget, wallet, trainer gate ─────────────────────── + ui.text( + &format!("SP {}/{}", s.skill_points_used, s.skill_points_cap), + x, + y, + 2.0, + text(), + ); + let cr = format!("CR {}", s.credits); + let cr_w = ui.measure_text(&cr, 2.0); + ui.text(&cr, x + w - cr_w, y, 2.0, accent()); + let trainer = match &s.trainer { + Some(t) if t.in_range => format!("TRAINER {}", t.name), + Some(t) => format!("TRAINER {} - {}", t.name, super::DENY_RANGE), + None => "NO TRAINER IN RANGE".to_string(), + }; + ui.text( + &trainer, + x, + y + 22.0, + 1.8, + if s.trainer.as_ref().is_some_and(|t| t.in_range) { + text() + } else { + dim() + }, + ); + + if s.professions.is_empty() { + ui.text("NO PROFESSION DATA", x, y + 48.0, 2.0, dim()); + return; + } + + // ── Profession trees ───────────────────────────────────────────────── + let mut cy = y + 48.0; + for tree in &s.professions { + if cy + 26.0 > y + h { + return; + } + ui.text( + &format!("{} - XP {}", tree.label, tree.xp), + x, + cy, + 2.2, + accent(), + ); + cy += 26.0; + for b in &tree.boxes { + if cy + 34.0 > y + h { + return; + } + let resp = ui.interact(x, cy, w, 34.0); + let clickable = s.trainer.as_ref().is_some_and(|trainer| trainer.in_range) + && (b.available || b.trained); + let fill = if clickable && resp.hovered { + [36, 48, 64, 230] + } else { + slot() + }; + ui.rect(x, cy, w, 34.0, fill); + + let text_col = if b.trained { + accent() + } else if b.available { + text() + } else { + dim() + }; + let mut lx = x + 8.0; + if !b.trained && !b.available { + if let Some((c, r)) = icons.cell("lock") { + ui.icon(c, r, lx, cy + 8.0, 18.0, 18.0, dim()); + } + lx += 22.0; + } + ui.text(&b.label, lx, cy + 4.0, 2.0, text_col); + // Right column: trained mark, cost line, or the deny reason. + let right = if b.trained { + "TRAINED".to_string() + } else if b.available { + let mut cost = format!("SP {} / XP {}", b.skill_point_cost, b.xp_cost); + if b.credit_cost > 0 { + cost.push_str(&format!(" / CR {}", b.credit_cost)); + } + cost + } else { + b.deny_reason.clone() + }; + let right_w = ui.measure_text(&right, 1.6); + ui.text(&right, x + w - right_w - 8.0, cy + 20.0, 1.6, text_col); + if resp.clicked && clickable { + let trainer_actor_id = s + .trainer + .as_ref() + .expect("clickable skills have an in-range trainer") + .actor_id + .clone(); + let command = if b.trained { + successor_net::ClientCommand::UnlearnSkillBox { + skill_box_id: b.id.clone(), + trainer_actor_id, + } + } else { + successor_net::ClientCommand::PurchaseSkillBox { + skill_box_id: b.id.clone(), + trainer_actor_id, + } + }; + out.push(WindowAction::Command(command)); + } + cy += 40.0; + } + cy += 10.0; + } +} + +#[cfg(test)] +mod tests { + fn test_ctx(rect: [f32; 4]) -> crate::windows::Ctx { + crate::windows::Ctx { + spec: crate::windows::spec::surface("skills").expect("skills surface"), + rect, + tab: 0, + } + } + + use super::*; + use crate::windows::model::{ProfessionTreeView, SkillBoxView, TrainerView}; + + /// Explicit test fixture — `WindowModel::sample()` is intentionally empty + /// so demo/test state can never masquerade as a live projection. + fn fixture() -> WindowModel { + let mut m = WindowModel::sample(); + m.skills.skill_points_used = 10; + m.skills.skill_points_cap = 250; + m.skills.credits = 500; + m.skills.trainer = Some(TrainerView { + actor_id: "trainer-1".into(), + name: "SGT HALE".into(), + profession_id: "marksman".into(), + in_range: true, + }); + m.skills.professions = vec![ProfessionTreeView { + id: "marksman".into(), + label: "MARKSMAN".into(), + xp: 1200, + boxes: vec![ + SkillBoxView { + id: "marksman_novice".into(), + label: "NOVICE MARKSMAN".into(), + trained: true, + ..Default::default() + }, + SkillBoxView { + id: "marksman_rifles_1".into(), + label: "RIFLES I".into(), + xp_cost: 400, + skill_point_cost: 2, + available: true, + ..Default::default() + }, + SkillBoxView { + id: "marksman_pistols_1".into(), + label: "PISTOLS I".into(), + xp_cost: 400, + skill_point_cost: 2, + available: false, + deny_reason: "REQUIRES 1000 MARKSMAN XP".into(), + ..Default::default() + }, + ], + }]; + m + } + + /// Row geometry for rect [100,100,500,400]: tree header at y=148, boxes + /// start at 174 and advance 40 ⇒ box0 174, box1 214, box2 254. + const RECT: [f32; 4] = [100.0, 100.0, 500.0, 400.0]; + + fn click( + ui: &mut UiBuilder, + model: &WindowModel, + icons: &Icons, + cx: f32, + cy: f32, + ) -> Vec<WindowAction> { + ui.set_input(cx, cy, true); + ui.begin(1280, 720); + let mut out = Vec::new(); + draw(ui, test_ctx(RECT), model, icons, &mut out); + ui.set_input(cx, cy, false); + ui.begin(1280, 720); + out.clear(); + draw(ui, test_ctx(RECT), model, icons, &mut out); + out + } + + #[test] + fn clicking_available_box_emits_purchase_intent() { + let icons = Icons::load(); + let model = fixture(); + let mut ui = UiBuilder::new(icons.meta); + let out = click(&mut ui, &model, &icons, 150.0, 230.0); + assert_eq!( + out, + vec![WindowAction::Command( + successor_net::ClientCommand::PurchaseSkillBox { + skill_box_id: "marksman_rifles_1".into(), + trainer_actor_id: "trainer-1".into(), + } + )], + "available untrained box emits the typed purchase command" + ); + } + + #[test] + fn trained_box_unlearns_and_denied_box_ignores_click() { + let icons = Icons::load(); + let model = fixture(); + let mut ui = UiBuilder::new(icons.meta); + let out = click(&mut ui, &model, &icons, 150.0, 190.0); + assert_eq!( + out, + vec![WindowAction::Command( + successor_net::ClientCommand::UnlearnSkillBox { + skill_box_id: "marksman_novice".into(), + trainer_actor_id: "trainer-1".into(), + } + )], + "trained box emits the typed unlearn command" + ); + let out = click(&mut ui, &model, &icons, 150.0, 270.0); + assert!(out.is_empty(), "denied box emits nothing, got {out:?}"); + } + + #[test] + fn empty_model_renders_without_actions() { + let icons = Icons::load(); + let model = WindowModel::sample(); + let mut ui = UiBuilder::new(icons.meta); + let out = click(&mut ui, &model, &icons, 150.0, 230.0); + assert!( + out.is_empty(), + "empty projection emits nothing, got {out:?}" + ); + } +} diff --git a/client-rust/source/app/src/windows/spec.rs b/client-rust/source/app/src/windows/spec.rs new file mode 100644 index 00000000..e35f4ca6 --- /dev/null +++ b/client-rust/source/app/src/windows/spec.rs @@ -0,0 +1,928 @@ +//! Surface specs: the in-code mapping from every registered window id to its +//! closest legacy mediator family, plus the geometry and chrome metrics +//! that mapping implies. +//! +//! One table drives three things that used to drift apart: +//! * **geometry** — [`Surface::bounds`] is the registration default and +//! [`Surface::min_size`] the resize floor (`game::connected_scene` +//! registers from here, so a spec edit moves the real window), +//! * **content routing** — [`route`] is total over the registry and +//! `windows::content` matches [`Route`] exhaustively, so a new id cannot +//! silently fall through to a stub, and +//! * **chrome density** — [`Metrics`] fixes row pitch, type sizes, and the +//! action column, so a list in BANK reads like a list in LOOT. +//! +//! Families name the original mediator they answer to (legacy client UI class +//! names). Nothing here imports legacy art or data: the mediator name records +//! *behavioral* intent — what the surface is for, how dense it is, whether it +//! owns tabs — and every pixel is Successor-drawn. + +/// Original-client mediator family a Successor surface answers to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Family { + /// `SwgCuiInventory` + `SwgCuiInventoryEquipment` — volume-page item field + /// over a live paperdoll, one selected-item action footer. + InventoryEquipment, + /// `SwgCuiCharacterSheet` — read-mostly identity/vitals sheet. + CharacterSheet, + /// `SwgCuiDataStorage` (datapad) — tabbed pane: waypoints, schematics, data. + Datapad, + /// `SwgCuiSkills` — profession tracks with per-box prerequisites. + SkillTree, + /// `SwgCuiCommandBrowser` — the bindable command list assigned to the bar. + CommandBrowser, + /// `SwgCuiMacroEditor` — name + body editor over a saved-macro list. + MacroBench, + /// `SwgCuiOptions` — categorized settings rows. + Options, + /// `SwgCuiPlayerAssociation` — guild charter, roster, wars, directory. + PlayerAssociation, + /// `SwgCuiGroup` — group roster plus duel challenges. + GroupRoster, + /// `SwgCuiCraftAssembly` / `SwgCuiCraftExperiment` — staged bench workflow. + CraftBench, + /// Bioengineering bench; behaves as a `SwgCuiCraft*` staged workflow. + SpliceBench, + /// `SwgCuiConversation` — NPC portrait, one prose panel, numbered replies. + Converse, + /// `SwgCuiTrade` — mirrored two-party panes with a commit rail. + Trade, + /// `SwgCuiBugReport` — category + body form with a submit receipt. + Report, + /// `SwgCuiExamine` — object viewer over a fixed attribute list. + Examine, + /// `SwgCuiSurvey` — resource families, concentration readout, tool verbs. + Survey, + /// `SwgCuiTicketPurchase` — destination list priced per row. + Travel, + /// `SwgCuiLootBox` — container rows with take / take-all. + Loot, + /// `SwgCuiBank` — terminal-gated wallet/vault transfer lists. + Bank, + /// `SwgCuiCloneSelect` — clone terminal bind/respawn confirmation. + Clone, + /// `SwgCuiPlayerStructure` — deed/land management and placement catalog. + Structure, +} + +impl Family { + /// Original mediator class this surface answers to (behavioral intent; + /// surfaced as the header caption's provenance). + pub const fn mediator(self) -> &'static str { + match self { + Self::InventoryEquipment => "SwgCuiInventory", + Self::CharacterSheet => "SwgCuiCharacterSheet", + Self::Datapad => "SwgCuiDataStorage", + Self::SkillTree => "SwgCuiSkills", + Self::CommandBrowser => "SwgCuiCommandBrowser", + Self::MacroBench => "SwgCuiMacroEditor", + Self::Options => "SwgCuiOptions", + Self::PlayerAssociation => "SwgCuiPlayerAssociation", + Self::GroupRoster => "SwgCuiGroup", + Self::CraftBench => "SwgCuiCraftAssembly", + Self::SpliceBench => "SwgCuiCraftAssembly", + Self::Converse => "SwgCuiConversation", + Self::Trade => "SwgCuiTrade", + Self::Report => "SwgCuiBugReport", + Self::Examine => "SwgCuiExamine", + Self::Survey => "SwgCuiSurvey", + Self::Travel => "SwgCuiTicketPurchase", + Self::Loot => "SwgCuiLootBox", + Self::Bank => "SwgCuiBank", + Self::Clone => "SwgCuiCloneSelect", + Self::Structure => "SwgCuiPlayerStructure", + } + } + + /// Header caption: what the surface is, in the client's own words. + pub const fn caption(self) -> &'static str { + match self { + Self::InventoryEquipment => "FIELD KIT", + Self::CharacterSheet => "PERSONNEL RECORD", + Self::Datapad => "FIELD DATAPAD", + Self::SkillTree => "PROFESSION TRACKS", + Self::CommandBrowser => "BINDABLE COMMANDS", + Self::MacroBench => "COMMAND MACROS", + Self::Options => "CLIENT SETTINGS", + Self::PlayerAssociation => "GUILD CHARTER", + Self::GroupRoster => "GROUP / DUEL", + Self::CraftBench => "FABRICATION", + Self::SpliceBench => "GENE SEQUENCING", + Self::Converse => "DIALOGUE", + Self::Trade => "SECURE TRADE", + Self::Report => "SUPPORT REPORT", + Self::Examine => "OBJECT DETAIL", + Self::Survey => "RESOURCE SURVEY", + Self::Travel => "TRANSIT", + Self::Loot => "CONTAINER", + Self::Bank => "VAULT TERMINAL", + Self::Clone => "CLONE TERMINAL", + Self::Structure => "LAND MANAGEMENT", + } + } + + /// Chrome density this family carries. + pub const fn density(self) -> Density { + match self { + Self::InventoryEquipment => Density::Grid, + Self::CharacterSheet | Self::Examine => Density::Sheet, + Self::Datapad + | Self::SkillTree + | Self::CommandBrowser + | Self::PlayerAssociation + | Self::GroupRoster + | Self::Travel + | Self::Loot + | Self::Bank + | Self::Structure + | Self::Survey => Density::List, + Self::CraftBench | Self::SpliceBench | Self::Trade => Density::Bench, + Self::MacroBench | Self::Options | Self::Report => Density::Form, + Self::Converse | Self::Clone => Density::Dialogue, + } + } +} + +/// How tightly a surface packs rows, and therefore its type scale. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Density { + /// Item field of square cards (inventory). + Grid, + /// Scannable rows with a right-hand action column. + List, + /// Labelled read-mostly blocks (character sheet, examine). + Sheet, + /// Staged workflow: slot rows plus a commit rail (craft, splice, trade). + Bench, + /// Editors and settings: field rows with generous hit targets. + Form, + /// Prose panel plus numbered replies. + Dialogue, +} + +/// Resolved chrome metrics. One place decides row pitch and type scale, so +/// every family reads as the same client at the same distance. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Metrics { + /// Row pitch, including the row's own gap. + pub row_h: f32, + /// Primary row label size. + pub label_px: f32, + /// Secondary/qualifier text size. + pub caption_px: f32, + /// Section heading size. + pub heading_px: f32, + /// Action control height, always centered in its row. + pub action_h: f32, + /// Nominal action control width before narrow-width collapse. + pub action_w: f32, + /// Space between the content edge and the first glyph. + pub gutter: f32, +} + +impl Density { + /// Measured original density: generic rows, buttons, and tabs are 19 px, + /// primary text fields 32 px (`ui_options.inc`, `ui_auction.inc`). Only the + /// item grid and the prose dialogue depart from the 19 px row, and only + /// because their content is not a row. + /// + /// Cap heights run one pixel over the original's 12/13/10. The original was + /// measured on a 1024x768 CRT; the same literal caps on a modern panel are + /// small enough to be a legibility complaint, and the 19 px row absorbs the + /// extra pixel without reflowing anything. Row height is NOT scaled with + /// them - that is what would move every pane's floor. + pub const fn metrics(self) -> Metrics { + // 5x7 glyph scale for a measured cap height. + const LABEL: f32 = 13.0 / 7.0; + const VALUE: f32 = 14.0 / 7.0; + const CAPTION: f32 = 11.0 / 7.0; + const HEADING: f32 = 14.0 / 7.0; + match self { + // Item field: cards, not rows. The label strip under a cell keeps + // the 12 px label; the cell itself is sized by the grid. + Self::Grid => Metrics { + row_h: 19.0, + label_px: LABEL, + caption_px: CAPTION, + heading_px: HEADING, + action_h: 17.0, + action_w: 72.0, + gutter: 4.0, + }, + Self::List => Metrics { + row_h: 19.0, + label_px: LABEL, + caption_px: CAPTION, + heading_px: HEADING, + action_h: 17.0, + action_w: 76.0, + gutter: 6.0, + }, + Self::Sheet => Metrics { + row_h: 19.0, + label_px: VALUE, + caption_px: CAPTION, + heading_px: HEADING, + action_h: 17.0, + action_w: 92.0, + gutter: 6.0, + }, + Self::Bench => Metrics { + row_h: 19.0, + label_px: LABEL, + caption_px: CAPTION, + heading_px: HEADING, + action_h: 17.0, + action_w: 70.0, + gutter: 6.0, + }, + // Forms carry 32 px primary fields; their rows leave room for one. + Self::Form => Metrics { + row_h: 22.0, + label_px: LABEL, + caption_px: CAPTION, + heading_px: HEADING, + action_h: 19.0, + action_w: 100.0, + gutter: 6.0, + }, + // Numbered dialogue replies are click targets, not dense rows. + Self::Dialogue => Metrics { + row_h: 22.0, + label_px: VALUE, + caption_px: CAPTION, + heading_px: HEADING, + action_h: 19.0, + action_w: 110.0, + gutter: 6.0, + }, + } + } +} + +/// Content route for a registered id. `windows::content` matches this +/// exhaustively, so the compiler — not a catch-all arm — proves every mapped +/// surface draws real content. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Route { + Inventory, + Character, + Skills, + Datapad, + CommandBrowser, + Macros, + Options, + Association, + Group, + Craft, + Splice, + Converse, + Trade, + Report, + Examine, + Survey, + Travel, + Loot, + Bank, + Clone, + Structure, +} + +/// Fractional size rule, mirroring the original client's viewport-relative +/// frame defaults: take the fraction, then clamp into the family's readable +/// band. Never larger than the workspace inset. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SizeRule { + pub frac_w: f32, + pub min_w: f32, + pub max_w: f32, + pub frac_h: f32, + pub min_h: f32, + pub max_h: f32, +} + +/// Workspace inset kept clear on every edge so a default frame never touches +/// the screen border (the dock rail owns the right gutter). +pub const MARGIN: f32 = 16.0; + +/// One registered surface. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Surface { + pub id: &'static str, + /// Frame title (title bar, dock tooltip). + pub title: &'static str, + /// Atlas icon id for the title bar / dock button. + pub icon: &'static str, + pub family: Family, + /// Tab labels the surface owns; empty when it is a single pane. + pub tabs: &'static [&'static str], + /// Whether the body draws a caption header. False for frames whose body + /// starts with a live 3D viewport anchored at the content origin — those + /// carry their name in the title bar only. + pub header: bool, + /// Dock-visible (a permanent window with a rail button and hotkey badge). + pub dock: bool, + /// `KeyboardEvent.code` the dock advertises for this window; empty when the + /// surface opens from a terminal/target/item route instead. + pub hotkey: &'static str, + pub size: SizeRule, + /// Resize floor `(w, h)`: the narrowest geometry the content stays + /// readable at. + pub min: (f32, f32), + /// Workspace alignment in `[0,1]` per axis (0 = left/top, 0.5 = centered). + pub anchor: (f32, f32), +} + +impl Surface { + pub const fn metrics(&self) -> Metrics { + self.family.density().metrics() + } + + /// Default frame rect for a viewport, in workspace pixels. + pub fn bounds(&self, viewport_w: f32, viewport_h: f32) -> [f32; 4] { + let avail_w = (viewport_w - MARGIN * 2.0).max(120.0); + let avail_h = (viewport_h - MARGIN * 2.0).max(120.0); + let w = (viewport_w * self.size.frac_w) + .round() + .clamp(self.size.min_w, self.size.max_w) + .min(avail_w); + let h = (viewport_h * self.size.frac_h) + .round() + .clamp(self.size.min_h, self.size.max_h) + .min(avail_h); + [ + MARGIN + (avail_w - w) * self.anchor.0, + MARGIN + (avail_h - h) * self.anchor.1, + w, + h, + ] + } + + /// Resize floor. Held at or under the smallest supported framebuffer + /// (800×600 less the workspace inset) so a min-size frame always fits. + pub const fn min_size(&self) -> (f32, f32) { + self.min + } +} + +const fn size( + frac_w: f32, + min_w: f32, + max_w: f32, + frac_h: f32, + min_h: f32, + max_h: f32, +) -> SizeRule { + SizeRule { + frac_w, + min_w, + max_w, + frac_h, + min_h, + max_h, + } +} + +/// Every registered surface: dock-visible frames first (in rail order), then +/// context frames. `hud::PERMANENT_WINDOWS` / `hud::CONTEXT_WINDOWS` are +/// generated from this table, so registry and spec cannot drift. +pub const SURFACES: [Surface; 21] = [ + // ── Permanent (dock-visible) ──────────────────────────────────────────── + Surface { + id: "character", + title: "CHARACTER", + icon: "character", + family: Family::CharacterSheet, + tabs: &[], + header: true, + dock: true, + hotkey: "KeyC", + size: size(0.30, 340.0, 420.0, 0.60, 360.0, 500.0), + min: (320.0, 300.0), + anchor: (0.12, 0.16), + }, + Surface { + id: "inventory", + title: "INVENTORY", + icon: "inventory", + family: Family::InventoryEquipment, + tabs: &[], + // The item field and the live player doll are anchored at the content + // origin by `inventory::layout`; the frame title carries the caption. + header: false, + dock: true, + hotkey: "KeyI", + // Original-client oracle: the shipped inventory frame is a FIXED + // 660x521 at every framebuffer (measured x=176 y=123 w=660 h=521 at + // 1024x768) and `SwgCuiHudWindowManager::createInventory` centers it + // with `page.Center()`. It does not scale with the viewport; at 800x600 + // it only clamps. Reflow happens on user resize, never on default. + size: size(0.0, 660.0, 660.0, 0.0, 521.0, 521.0), + // Legacy UI layout contract: the original minimum is 250x244. + min: (250.0, 244.0), + anchor: (0.5, 0.5), + }, + Surface { + id: "datapad", + title: "DATAPAD", + icon: "datapad", + family: Family::Datapad, + tabs: &["MAP", "SCHEMATICS", "DATA"], + header: true, + dock: true, + hotkey: "KeyP", + size: size(0.40, 420.0, 560.0, 0.62, 400.0, 620.0), + min: (400.0, 340.0), + anchor: (0.86, 0.14), + }, + Surface { + id: "skills", + title: "SKILLS", + icon: "skills", + family: Family::SkillTree, + tabs: &[], + header: true, + dock: true, + hotkey: "KeyK", + size: size(0.52, 520.0, 720.0, 0.62, 400.0, 640.0), + min: (460.0, 340.0), + anchor: (0.72, 0.18), + }, + Surface { + id: "actions", + title: "ACTIONS", + icon: "actions", + family: Family::CommandBrowser, + tabs: &[], + header: true, + dock: true, + hotkey: "KeyB", + size: size(0.30, 320.0, 440.0, 0.66, 320.0, 640.0), + min: (300.0, 280.0), + anchor: (0.95, 0.5), + }, + Surface { + id: "macros", + title: "MACROS", + icon: "macro", + family: Family::MacroBench, + tabs: &[], + header: true, + dock: true, + hotkey: "KeyM", + size: size(0.42, 460.0, 600.0, 0.60, 400.0, 600.0), + min: (400.0, 360.0), + anchor: (0.08, 0.22), + }, + Surface { + id: "options", + title: "OPTIONS", + icon: "options", + family: Family::Options, + tabs: &[], + header: true, + dock: true, + hotkey: "KeyO", + size: size(0.30, 360.0, 420.0, 0.78, 380.0, 720.0), + min: (340.0, 340.0), + anchor: (0.78, 0.06), + }, + Surface { + id: "pa", + title: "ASSOCIATION", + icon: "association", + family: Family::PlayerAssociation, + tabs: &[], + header: true, + dock: true, + hotkey: "KeyG", + size: size(0.34, 440.0, 560.0, 0.58, 400.0, 600.0), + min: (400.0, 340.0), + anchor: (0.4, 0.34), + }, + // ── Context (opened from a terminal, target, roster, or item route) ───── + Surface { + id: "craft", + title: "CRAFT", + icon: "craft", + family: Family::CraftBench, + tabs: &[], + header: true, + dock: false, + hotkey: "", + size: size(0.46, 520.0, 700.0, 0.64, 420.0, 640.0), + min: (460.0, 360.0), + anchor: (0.36, 0.2), + }, + Surface { + id: "splice", + title: "GENE BENCH", + icon: "splice", + family: Family::SpliceBench, + tabs: &[], + header: true, + dock: false, + hotkey: "", + size: size(0.48, 540.0, 700.0, 0.66, 440.0, 660.0), + min: (460.0, 380.0), + anchor: (0.5, 0.36), + }, + Surface { + id: "converse", + title: "CONVERSE", + icon: "converse", + family: Family::Converse, + tabs: &[], + // The NPC viewer is anchored at the content origin. + header: false, + dock: false, + hotkey: "", + size: size(0.34, 440.0, 600.0, 0.50, 340.0, 520.0), + min: (400.0, 300.0), + anchor: (0.14, 0.2), + }, + Surface { + id: "trade", + title: "TRADE", + icon: "trade", + family: Family::Trade, + tabs: &[], + header: true, + dock: false, + hotkey: "", + size: size(0.42, 500.0, 640.0, 0.62, 420.0, 620.0), + min: (460.0, 360.0), + anchor: (0.1, 0.5), + }, + Surface { + id: "bug-report", + title: "REPORT", + icon: "bug-report", + family: Family::Report, + tabs: &[], + header: true, + dock: false, + hotkey: "", + size: size(0.42, 420.0, 540.0, 0.58, 390.0, 560.0), + min: (380.0, 340.0), + anchor: (0.5, 0.5), + }, + Surface { + id: "examine", + title: "EXAMINE", + icon: "examine", + family: Family::Examine, + tabs: &[], + // The object viewer is anchored at the content origin. + header: false, + dock: false, + hotkey: "", + size: size(0.26, 320.0, 380.0, 0.60, 340.0, 520.0), + min: (300.0, 320.0), + anchor: (0.9, 0.42), + }, + Surface { + id: "survey", + title: "SURVEY", + icon: "survey", + family: Family::Survey, + tabs: &["RESOURCES", "EXTRACTORS"], + header: true, + dock: false, + hotkey: "", + // A resource row carries a name, a class caption, a percentage, and + // three actions. At 360 the actions and readout take everything and the + // name clips to `VEXHOLL...`, so the default opens at the wide end. + size: size(0.38, 420.0, 470.0, 0.60, 400.0, 600.0), + min: (380.0, 340.0), + anchor: (0.0, 0.18), + }, + Surface { + id: "travel", + title: "TRAVEL", + icon: "travel", + family: Family::Travel, + tabs: &["DESTINATIONS", "TICKETS"], + header: true, + dock: false, + hotkey: "", + size: size(0.40, 460.0, 600.0, 0.50, 360.0, 520.0), + min: (400.0, 320.0), + anchor: (0.5, 0.44), + }, + Surface { + id: "loot", + title: "LOOT", + icon: "loot", + family: Family::Loot, + tabs: &[], + header: true, + dock: false, + hotkey: "", + size: size(0.30, 380.0, 520.0, 0.52, 330.0, 520.0), + min: (340.0, 300.0), + anchor: (0.66, 0.5), + }, + Surface { + id: "bank", + title: "BANK", + icon: "bank", + family: Family::Bank, + tabs: &["HELD", "VAULT"], + header: true, + dock: false, + hotkey: "", + size: size(0.34, 420.0, 560.0, 0.56, 360.0, 560.0), + min: (380.0, 320.0), + anchor: (0.5, 0.42), + }, + Surface { + id: "clone", + title: "CLONING", + icon: "clone-facility", + family: Family::Clone, + tabs: &[], + header: true, + dock: false, + hotkey: "", + size: size(0.30, 400.0, 520.0, 0.44, 320.0, 460.0), + min: (360.0, 280.0), + anchor: (0.5, 0.42), + }, + Surface { + id: "build", + title: "LAND / BUILD", + icon: "options", + family: Family::Structure, + tabs: &["PARCEL", "CATALOG"], + header: true, + dock: false, + hotkey: "", + size: size(0.44, 480.0, 640.0, 0.62, 420.0, 620.0), + min: (440.0, 360.0), + anchor: (0.28, 0.28), + }, + Surface { + id: "group", + title: "GROUP", + // No dedicated group glyph exists in the Successor atlas; the + // association mark is the closest owned symbol and this frame is + // context-only, so it never sits beside ASSOCIATION in the dock. + icon: "association", + family: Family::GroupRoster, + tabs: &[], + header: true, + dock: false, + hotkey: "", + size: size(0.30, 380.0, 500.0, 0.50, 340.0, 520.0), + min: (360.0, 300.0), + anchor: (0.22, 0.5), + }, +]; + +/// Spec for a registered id. +pub fn surface(id: &str) -> Option<&'static Surface> { + SURFACES.iter().find(|surface| surface.id == id) +} + +/// Content route for a registered id. `None` means the id is not a mapped +/// surface — `windows::content` renders that as a named diagnostic rather than +/// a blank `NO SIGNAL`, and `every_registry_id_routes` fails on it. +pub fn route(id: &str) -> Option<Route> { + Some(match id { + "inventory" => Route::Inventory, + "character" => Route::Character, + "skills" => Route::Skills, + "datapad" => Route::Datapad, + "actions" => Route::CommandBrowser, + "macros" => Route::Macros, + "options" => Route::Options, + "pa" => Route::Association, + "group" => Route::Group, + "craft" => Route::Craft, + "splice" => Route::Splice, + "converse" => Route::Converse, + "trade" => Route::Trade, + "bug-report" => Route::Report, + "examine" => Route::Examine, + "survey" => Route::Survey, + "travel" => Route::Travel, + "loot" => Route::Loot, + "bank" => Route::Bank, + "clone" => Route::Clone, + "build" => Route::Structure, + _ => return None, + }) +} + +/// Registration geometry for `id`: default bounds at the given viewport plus +/// the resize floor. Unknown ids get a readable centered default instead of a +/// zero-size frame. +pub fn geometry(id: &str, viewport_w: f32, viewport_h: f32) -> ([f32; 4], f32, f32) { + match surface(id) { + Some(spec) => { + let (min_w, min_h) = spec.min_size(); + (spec.bounds(viewport_w, viewport_h), min_w, min_h) + } + None => { + let w = 480.0_f32.min(viewport_w - MARGIN * 2.0).max(120.0); + let h = 360.0_f32.min(viewport_h - MARGIN * 2.0).max(120.0); + ( + [(viewport_w - w) * 0.5, (viewport_h - h) * 0.5, w, h], + w.min(320.0), + h.min(240.0), + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every framebuffer the original-client UI matrix covers. + const MATRIX: [(f32, f32); 4] = [ + (800.0, 600.0), + (1024.0, 768.0), + (1280.0, 1024.0), + (1600.0, 1200.0), + ]; + + #[test] + fn every_registry_id_routes() { + for (id, _, _, _) in crate::hud::PERMANENT_WINDOWS { + assert!(route(id).is_some(), "permanent window {id} has no route"); + assert!(surface(id).is_some(), "permanent window {id} has no spec"); + } + for (id, _, _) in crate::hud::CONTEXT_WINDOWS { + assert!(route(id).is_some(), "context window {id} has no route"); + assert!(surface(id).is_some(), "context window {id} has no spec"); + } + assert_eq!( + SURFACES.len(), + crate::hud::PERMANENT_WINDOWS.len() + crate::hud::CONTEXT_WINDOWS.len(), + "registry and spec table disagree on surface count" + ); + } + + #[test] + fn ids_and_routes_are_unique() { + for (index, spec) in SURFACES.iter().enumerate() { + assert!( + !SURFACES[..index].iter().any(|other| other.id == spec.id), + "duplicate surface id {}", + spec.id + ); + let taken = route(spec.id).expect("mapped surface"); + assert!( + !SURFACES[..index] + .iter() + .any(|other| route(other.id) == Some(taken)), + "two surfaces share route {taken:?}" + ); + } + } + + #[test] + fn surface_families_match_their_mediator_intent() { + assert_eq!( + surface("actions").expect("actions").family, + Family::CommandBrowser, + "ACTIONS is the command browser, not the group roster" + ); + assert_eq!( + surface("group").expect("group").family.mediator(), + "SwgCuiGroup" + ); + assert_eq!( + surface("pa").expect("pa").family.mediator(), + "SwgCuiPlayerAssociation" + ); + assert_eq!( + surface("inventory").expect("inventory").family, + Family::InventoryEquipment + ); + assert_eq!(surface("build").expect("build").family, Family::Structure); + // Frames whose body starts with a live 3D viewport draw no body header. + for id in ["inventory", "examine", "converse"] { + assert!( + !surface(id).expect("spec").header, + "{id} anchors a 3D viewport at the content origin" + ); + } + } + + #[test] + fn defaults_fit_and_respect_the_resize_floor_at_every_matrix_size() { + for (vw, vh) in MATRIX { + for spec in &SURFACES { + let [x, y, w, h] = spec.bounds(vw, vh); + let (min_w, min_h) = spec.min_size(); + assert!( + x >= 0.0 && y >= 0.0 && x + w <= vw && y + h <= vh, + "{} escapes {vw}x{vh}: {:?}", + spec.id, + [x, y, w, h] + ); + assert!( + min_w <= vw - MARGIN * 2.0 && min_h <= vh - MARGIN * 2.0, + "{} floor {min_w}x{min_h} cannot fit {vw}x{vh}", + spec.id + ); + assert!( + w >= min_w.min(vw - MARGIN * 2.0), + "{} default {w} under floor {min_w} at {vw}x{vh}", + spec.id + ); + assert!( + h >= min_h.min(vh - MARGIN * 2.0), + "{} default {h} under floor {min_h} at {vw}x{vh}", + spec.id + ); + } + } + } + + #[test] + fn inventory_is_the_measured_fixed_rect_centered_at_every_matrix_size() { + let inventory = surface("inventory").expect("inventory spec"); + // Matrix measured the shipped frame at exactly 660x521, x=176 y=123 at + // 1024x768, and it does not scale with the viewport: only clamping. + let [x, y, w, h] = inventory.bounds(1024.0, 768.0); + assert_eq!((w, h), (660.0, 521.0)); + assert!( + (x - 176.0).abs() <= 8.0, + "x={x} should sit at the measured 176 within workspace-inset slack" + ); + assert!( + (y - 123.0).abs() <= 8.0, + "y={y} should sit at the measured 123 within workspace-inset slack" + ); + for (vw, vh) in MATRIX { + let [x, y, w, h] = inventory.bounds(vw, vh); + assert_eq!( + (w, h), + (660.0, 521.0), + "inventory must stay fixed at {vw}x{vh}, clamping only" + ); + // `SwgCuiHudWindowManager::createInventory` calls page.Center(). + assert!( + ((x + w * 0.5) - vw * 0.5).abs() <= 0.5, + "inventory off-center at {vw}x{vh}: x={x}" + ); + assert!( + ((y + h * 0.5) - vh * 0.5).abs() <= 0.5, + "inventory off-center at {vw}x{vh}: y={y}" + ); + } + } + + #[test] + fn density_metrics_stay_readable() { + for density in [ + Density::Grid, + Density::List, + Density::Sheet, + Density::Bench, + Density::Form, + Density::Dialogue, + ] { + let metrics = density.metrics(); + // A row must clear its own action control, and glyphs must not + // exceed the row they sit in (5×7 font: cap height = 7·px). + assert!( + metrics.row_h >= metrics.action_h + 2.0, + "{density:?} row {} cannot hold a {} action", + metrics.row_h, + metrics.action_h + ); + assert!( + metrics.label_px * 7.0 < metrics.row_h, + "{density:?} label clips its row" + ); + assert!( + metrics.caption_px < metrics.label_px, + "{density:?} caption must stay subordinate" + ); + } + } + + #[test] + fn dock_surfaces_lead_the_table_and_declare_hotkeys() { + let dock_count = SURFACES.iter().filter(|spec| spec.dock).count(); + for (index, spec) in SURFACES.iter().enumerate() { + assert_eq!( + spec.dock, + index < dock_count, + "{} breaks the dock-first table order", + spec.id + ); + assert_eq!( + spec.dock, + !spec.hotkey.is_empty(), + "{} dock/hotkey disagreement", + spec.id + ); + } + } +} diff --git a/client-rust/source/app/src/world/area.rs b/client-rust/source/app/src/world/area.rs new file mode 100644 index 00000000..24659be7 --- /dev/null +++ b/client-rust/source/app/src/world/area.rs @@ -0,0 +1,145 @@ +//! Active-area resolution — exact port of the seed/biome derivation in +//! `client-3d/src/render/terrain/TerrainStreamer.ts` (`worldSeedFromSlice`, +//! `mixWorldSeedWithArea`, `biomeIdFromSliceArea`). The connected scene must +//! render the *streamed* area, so terrain seed and biome are always derived +//! from the accepted snapshot's active area id + the checked-in map bundle — +//! never hard-coded. + +use successor_engine_core::json::Json; + +use super::terrain::Biome; + +/// `SUCCESSOR_3D_CONFIG.terrain.fallbackWorldSeed`. +pub const FALLBACK_WORLD_SEED: u32 = 0x0d3d_071e; + +/// FNV-1a over UTF-16 code units (area ids are ASCII, so bytes == charCodes). +/// Mirrors the TS `fnv1a32` including `Math.imul` wrapping semantics. +pub fn fnv1a32(value: &str) -> u32 { + let mut hash: u32 = 0x811c_9dc5; + for unit in value.encode_utf16() { + hash ^= unit as u32; + hash = hash.wrapping_mul(0x0100_0193); + } + hash +} + +/// TS `avalanche32` (two multiply-xorshift rounds). +pub fn avalanche32(value: u32) -> u32 { + let mut hash = value; + hash = (hash ^ (hash >> 16)).wrapping_mul(0x7feb_352d); + hash = (hash ^ (hash >> 15)).wrapping_mul(0x846c_a68b); + hash ^ (hash >> 16) +} + +/// The anchor hash the TS streamer salts every area id against. +fn ashvat_area_seed_hash() -> u32 { + fnv1a32("open-desert-overworld") +} + +/// `worldSeedFromSlice`: the slice's finite `worldSeed`, else the fallback. +pub fn world_seed_from_slice(slice: &Json) -> u32 { + match slice.get("worldSeed").and_then(Json::as_f64) { + Some(seed) if seed.is_finite() => seed.trunc() as i64 as u32, + _ => FALLBACK_WORLD_SEED, + } +} + +/// `mixWorldSeedWithArea`: XOR the slice seed with the avalanche of the area +/// hash relative to the anchor area. The anchor area itself keeps the raw +/// slice seed (salt is zero), preserving the shipped desert look. +pub fn mix_world_seed_with_area(slice_world_seed: u32, area_id: &str) -> u32 { + let area_salt = avalanche32(fnv1a32(area_id) ^ ashvat_area_seed_hash()); + slice_world_seed ^ area_salt +} + +/// `effectiveWorldSeedFromSliceArea`. +pub fn effective_world_seed(slice: &Json, area_id: &str) -> u32 { + mix_world_seed_with_area(world_seed_from_slice(slice), area_id) +} + +/// `biomeIdFromSliceArea`: the area's authored biome from `slice.areas`, else +/// a case-insensitive `forest` substring fallback, else desert. +pub fn biome_for_area(slice: &Json, area_id: &str) -> Biome { + if let Some(areas) = slice.get("areas").and_then(Json::as_array) { + for area in areas { + if area.get("id").and_then(Json::as_str) != Some(area_id) { + continue; + } + return match area.get("biome").and_then(Json::as_str) { + Some("forest") => Biome::Forest, + Some("desert") => Biome::Desert, + _ => fallback_biome(area_id), + }; + } + } + fallback_biome(area_id) +} + +fn fallback_biome(area_id: &str) -> Biome { + if area_id.to_ascii_lowercase().contains("forest") { + Biome::Forest + } else { + Biome::Desert + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Reference values computed from the TS implementation (Math.imul + // semantics) for the shipped slice (`worldSeed` 424242) and both areas. + #[test] + fn fnv_matches_ts_reference() { + assert_eq!(fnv1a32("open-desert-overworld"), 0xe703_dab6); + assert_eq!(fnv1a32("verdance-forest-overworld"), 0x5019_74ca); + } + + #[test] + fn anchor_area_keeps_slice_seed() { + // avalanche(0) == 0, so the anchor area's salt vanishes. + assert_eq!(avalanche32(0), 0); + assert_eq!( + mix_world_seed_with_area(424_242, "open-desert-overworld"), + 424_242 + ); + } + + #[test] + fn forest_area_seed_matches_ts_reference() { + assert_eq!( + mix_world_seed_with_area(424_242, "verdance-forest-overworld"), + 0xb0d2_cb4f + ); + } + + #[test] + fn seed_from_slice_and_fallback() { + let slice = Json::parse(r#"{ "worldSeed": 424242 }"#).unwrap(); + assert_eq!(world_seed_from_slice(&slice), 424_242); + let empty = Json::parse("{}").unwrap(); + assert_eq!(world_seed_from_slice(&empty), FALLBACK_WORLD_SEED); + } + + #[test] + fn biome_lookup_and_substring_fallback() { + let slice = Json::parse( + r#"{ "areas": [ + { "id": "open-desert-overworld", "biome": "desert" }, + { "id": "verdance-forest-overworld", "biome": "forest" } + ] }"#, + ) + .unwrap(); + assert_eq!( + biome_for_area(&slice, "open-desert-overworld"), + Biome::Desert + ); + assert_eq!( + biome_for_area(&slice, "verdance-forest-overworld"), + Biome::Forest + ); + // Unknown area: substring fallback. + assert_eq!(biome_for_area(&slice, "deep-FOREST-test"), Biome::Forest); + assert_eq!(biome_for_area(&slice, "salt-flats"), Biome::Desert); + } +} diff --git a/client-rust/source/app/src/world/camera.rs b/client-rust/source/app/src/world/camera.rs new file mode 100644 index 00000000..7535eccb --- /dev/null +++ b/client-rust/source/app/src/world/camera.rs @@ -0,0 +1,194 @@ +//! Orthographic isometric camera — port of `client-3d/src/render/camera.ts` +//! (`IsometricCameraController`) using `config.camera` constants. Produces the +//! engine `Camera` component's eye/look-at/ortho each frame with smoothed +//! follow, and exposes zoom clamping. Ground-ray unprojection for picking lives +//! in `picking.rs`. + +use super::WORLD_UNITS_PER_CELL; +use successor_engine_core::math::{vec3, Vec3}; +use successor_engine_render::components::{Camera, Projection}; + +// config.camera +const YAW_DEG: f32 = 0.0; +const PITCH_DEG: f32 = 60.0; +const DISTANCE_CELLS: f32 = 96.0; +const BASE_FRUSTUM_HEIGHT_CELLS: f32 = 12.5; +const MIN_ZOOM_PERCENT: f32 = 55.0; +const MAX_ZOOM_PERCENT: f32 = 140.0; +const FOLLOW_LERP_PER_SECOND: f32 = 12.0; +const FOLLOW_HALF_LIFE_SECONDS: f32 = core::f32::consts::LN_2 / FOLLOW_LERP_PER_SECOND; + +pub const NEAR: f32 = 0.1; +pub const FAR: f32 = 320.0; + +pub fn clamp_zoom_percent(pct: f32) -> f32 { + pct.clamp(MIN_ZOOM_PERCENT, MAX_ZOOM_PERCENT) +} + +/// Fixed camera offset from the focus point (yaw 0, pitch 60°, 96 metres). +pub fn camera_offset() -> Vec3 { + let yaw = YAW_DEG.to_radians(); + let pitch = PITCH_DEG.to_radians(); + let distance = DISTANCE_CELLS * WORLD_UNITS_PER_CELL; + let horizontal = pitch.cos() * distance; + let height = pitch.sin() * distance; + vec3(yaw.sin() * horizontal, height, yaw.cos() * horizontal) +} +/// Move a camera anchor toward its target with a frame-rate-independent +/// exponential response. +pub(crate) fn smooth_follow(current: Vec3, desired: Vec3, dt_seconds: f32) -> Vec3 { + let alpha = + 1.0 - (-core::f32::consts::LN_2 * dt_seconds.max(0.0) / FOLLOW_HALF_LIFE_SECONDS).exp(); + current.add(desired.sub(current).scale(alpha)) +} + +pub struct IsoCamera { + center: Vec3, + zoom_percent: f32, + initialized: bool, +} + +impl Default for IsoCamera { + fn default() -> Self { + IsoCamera { + center: Vec3::ZERO, + zoom_percent: MIN_ZOOM_PERCENT, + initialized: false, + } + } +} + +impl IsoCamera { + pub fn set_zoom(&mut self, pct: f32) { + self.zoom_percent = clamp_zoom_percent(pct); + } + + pub fn zoom_percent(&self) -> f32 { + self.zoom_percent + } + + pub fn center(&self) -> Vec3 { + self.center + } + + /// Ortho half-height in world units at the current zoom. + pub fn half_height(&self) -> f32 { + (BASE_FRUSTUM_HEIGHT_CELLS * WORLD_UNITS_PER_CELL / (self.zoom_percent / 100.0)) * 0.5 + } + + /// Smoothly follow `(x, z)` on the ground plane (exponential lerp; snaps on + /// the first call). + pub fn update_focus(&mut self, x: f32, z: f32, dt_seconds: f32) { + let desired = vec3(x, 0.0, z); + if !self.initialized { + self.center = desired; + self.initialized = true; + } else { + self.center = smooth_follow(self.center, desired, dt_seconds); + } + } + + /// Write eye/look-at/projection into a `Camera` component (ortho iso). + pub fn apply(&self, cam: &mut Camera) { + cam.eye = self.center.add(camera_offset()); + cam.look_at = self.center; + cam.up = Vec3::Y; + cam.projection = Projection::Ortho { + half_height: self.half_height(), + near: NEAR, + far: FAR, + }; + } + + /// The combined view-projection matrix at a given aspect ratio. + pub fn view_proj(&self, aspect: f32) -> successor_engine_core::math::Mat4 { + use successor_engine_core::math::Mat4; + let eye = self.center.add(camera_offset()); + let view = Mat4::look_at(eye, self.center, Vec3::Y); + let hh = self.half_height(); + let hw = hh * aspect; + let proj = Mat4::ortho(-hw, hw, -hh, hh, NEAR, FAR); + proj.mul(view) + } + + /// Unproject a normalized device coord (`-1..1`, y up) to the ground plane + /// (y = 0). Returns `None` if the ray is parallel or points away. + pub fn ground_pick(&self, aspect: f32, ndc_x: f32, ndc_y: f32) -> Option<Vec3> { + let inv = self.view_proj(aspect).inverse(); + let near = inv.project_point(vec3(ndc_x, ndc_y, -1.0)); + let far = inv.project_point(vec3(ndc_x, ndc_y, 1.0)); + let dir = far.sub(near); + if dir.y.abs() < 1e-6 { + return None; + } + let t = -near.y / dir.y; + if t < 0.0 || !t.is_finite() { + return None; + } + let hit = near.add(dir.scale(t)); + if hit.x.is_finite() && hit.z.is_finite() { + Some(hit) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn offset_pitch_60() { + let o = camera_offset(); + assert!(o.x.abs() < 1e-4, "yaw 0 → no x offset"); + // pitch 60°: height = sin60*96 ≈ 83.14, horizontal = cos60*96 = 48. + assert!((o.y - 83.138).abs() < 0.01); + assert!((o.z - 48.0).abs() < 0.01); + } + + #[test] + fn zoom_clamps() { + assert_eq!(clamp_zoom_percent(10.0), 55.0); + assert_eq!(clamp_zoom_percent(999.0), 140.0); + assert_eq!(clamp_zoom_percent(100.0), 100.0); + } + + #[test] + fn half_height_scales_inverse_zoom() { + let mut c = IsoCamera::default(); + c.set_zoom(55.0); + assert!((c.half_height() - (12.5 / 0.55) * 0.5).abs() < 1e-3); + c.set_zoom(140.0); + assert!((c.half_height() - (12.5 / 1.4) * 0.5).abs() < 1e-3); + } + + #[test] + fn follow_snaps_then_converges_without_overshoot() { + let mut c = IsoCamera::default(); + c.update_focus(10.0, 20.0, 0.016); + assert_eq!(c.center(), vec3(10.0, 0.0, 20.0)); + c.update_focus(30.0, 20.0, 0.016); + assert!(c.center().x > 10.0); + assert!(c.center().x < 30.0); + } + + #[test] + fn exponential_follow_halves_error_each_half_life() { + let current = Vec3::ZERO; + let desired = vec3(10.0, -4.0, 2.0); + let next = smooth_follow(current, desired, FOLLOW_HALF_LIFE_SECONDS); + let expected = desired.scale(0.5); + assert!(next.sub(expected).length() < 1.0e-5); + } + + #[test] + fn exponential_follow_is_frame_rate_independent() { + let current = vec3(-3.0, 2.0, 7.0); + let desired = vec3(12.0, -4.0, -5.0); + let one_step = smooth_follow(current, desired, 0.1); + let half_step = smooth_follow(current, desired, 0.05); + let two_steps = smooth_follow(half_step, desired, 0.05); + assert!(one_step.sub(two_steps).length() < 1.0e-5); + } +} diff --git a/client-rust/source/app/src/world/chunks.rs b/client-rust/source/app/src/world/chunks.rs new file mode 100644 index 00000000..43d6cb49 --- /dev/null +++ b/client-rust/source/app/src/world/chunks.rs @@ -0,0 +1,785 @@ +//! Terrain chunk streamer: updates continuous material/height controls around a +//! center and renders pooled tessellated patches with one shared PBR tile +//! library. It ports the `client-3d` ring-prefetch and eviction policy without +//! baking final color into per-chunk textures. + +use std::collections::HashMap; + +use successor_engine_core::ecs::{Entity, WorldOps}; +use successor_engine_core::math::{vec3, Vec3}; +use successor_engine_render::components::{MaterialId, MeshId, MeshRenderer, SkinRef, Transform}; +use successor_engine_render::gpu::{ + Filter, Gpu, MinFilter, TextureArrayDesc, TextureDesc, TextureFormat, TextureId, Wrap, +}; +use successor_engine_render::renderer::{ + InstanceBatchId, MaterialDesc, Renderer, TerrainMaterialDesc, +}; + +use super::flora::{ + biome_density, detail_instance_matrix, rock_mesh, scatter_into, shrub_mesh, tuft_mesh, + DetailKind, FloraInstance, +}; +use super::terrain::{sample_terrain, terrain_height, Biome}; +use super::terrain_material::{generate_terrain_tiles, TILE_LAYERS, TILE_SIZE}; +use super::{TERRAIN_MATERIAL_METERS_PER_TILE, WORLD_UNITS_PER_CELL}; +use crate::GameWorld; + +const CONTROL_INTERIOR_PX: u32 = 128; +const CONTROL_PX: u32 = CONTROL_INTERIOR_PX + 2; +const GRID_SEGMENTS: u32 = 32; +const HEIGHT_RANGE: f32 = 4.0; +const DETAIL_CAPACITY_PER_KIND: u32 = 128; +const DETAIL_MAX_DISTANCE: f32 = 260.0; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TerrainExclusion { + pub min: [f32; 2], + pub max: [f32; 2], + pub feather: f32, +} + +/// Flat mean ground albedo per biome, fed to the GI volume as the bounce color +/// of the y=0 plane. +fn biome_ground_albedo(biome: Biome) -> [f32; 3] { + match biome { + Biome::Forest => [0.30, 0.34, 0.20], + _ => [0.79, 0.68, 0.51], + } +} + +struct TerrainSlot { + control: TextureId, + material: MaterialId, + detail_matrices: [Vec<[f32; 16]>; 3], + entity: Option<Entity>, +} + +pub struct TerrainStreamer { + seed: i32, + biome: Biome, + chunk_cells: f64, + radius: i32, + loaded: HashMap<(i32, i32), usize>, + mask: u32, + shared_mesh: Option<MeshId>, + albedo_tiles: Option<TextureId>, + nrma_tiles: Option<TextureId>, + slots: Vec<TerrainSlot>, + control_scratch: Vec<u8>, + evict_scratch: Vec<(i32, i32)>, + exclusions: Vec<TerrainExclusion>, + detail_scratch: Vec<FloraInstance>, + detail_matrices: [Vec<[f32; 16]>; 3], + detail_batches: Option<[InstanceBatchId; 3]>, + merged_detail_matrices: [Vec<[f32; 16]>; 3], +} + +impl TerrainStreamer { + pub fn new(seed: i32, biome: Biome, chunk_cells: f64, radius: i32, viewport_mask: u32) -> Self { + let slot_count = ((radius * 2 + 1) * (radius * 2 + 1)) as usize; + Self { + seed, + biome, + chunk_cells, + radius, + loaded: HashMap::with_capacity(slot_count), + mask: viewport_mask, + shared_mesh: None, + albedo_tiles: None, + nrma_tiles: None, + slots: Vec::with_capacity(slot_count), + control_scratch: vec![0; (CONTROL_PX * CONTROL_PX * 4) as usize], + evict_scratch: Vec::with_capacity(slot_count), + exclusions: Vec::new(), + detail_scratch: Vec::with_capacity(256), + detail_matrices: core::array::from_fn(|_| { + Vec::with_capacity(DETAIL_CAPACITY_PER_KIND as usize) + }), + detail_batches: None, + merged_detail_matrices: core::array::from_fn(|_| { + Vec::with_capacity(slot_count * DETAIL_CAPACITY_PER_KIND as usize) + }), + } + } + /// Replace the visual-ground flattening regions. Structure footprints use + /// these before chunks are baked so props remain seated and detail scatter + /// cannot invade buildable space. + pub fn set_exclusions(&mut self, exclusions: &[TerrainExclusion]) { + self.exclusions.clear(); + self.exclusions.extend_from_slice(exclusions); + } + + fn chunk_of(&self, world_x: f64, world_z: f64) -> (i32, i32) { + ( + (world_x / self.chunk_cells).floor() as i32, + (world_z / self.chunk_cells).floor() as i32, + ) + } + + fn ensure_shared<G: Gpu>(&mut self, renderer: &mut Renderer, gpu: &mut G) { + if self.shared_mesh.is_some() { + return; + } + let tiles = generate_terrain_tiles(self.biome); + let albedo_tiles = gpu.create_texture_array( + &TextureArrayDesc { + width: TILE_SIZE, + height: TILE_SIZE, + layers: TILE_LAYERS, + format: TextureFormat::Srgba8, + mipmaps: true, + }, + Some(&tiles.albedo), + ); + let nrma_tiles = gpu.create_texture_array( + &TextureArrayDesc { + width: TILE_SIZE, + height: TILE_SIZE, + layers: TILE_LAYERS, + format: TextureFormat::Rgba8, + mipmaps: true, + }, + Some(&tiles.nrma), + ); + let size = self.chunk_cells as f32; + let (verts, indices) = chunk_grid(size, GRID_SEGMENTS); + let mesh = renderer.upload_mesh(gpu, &verts, &indices); + let control_desc = control_texture_desc(); + let zeros = vec![0; self.control_scratch.len()]; + let slot_count = self.slots.capacity(); + let (rock_vertices, rock_indices) = rock_mesh(); + let (cover_vertices, cover_indices) = tuft_mesh(); + let (shrub_vertices, shrub_indices) = shrub_mesh(); + let detail_meshes = [ + renderer.upload_mesh(gpu, &rock_vertices, &rock_indices), + renderer.upload_mesh(gpu, &cover_vertices, &cover_indices), + renderer.upload_mesh(gpu, &shrub_vertices, &shrub_indices), + ]; + let detail_materials = detail_materials(renderer, self.biome); + for _ in 0..slot_count { + let control = gpu.create_texture(&control_desc, Some(&zeros)); + let material = renderer.add_material_desc(MaterialDesc { + metallic: 0.0, + roughness: 1.0, + terrain: Some(TerrainMaterialDesc { + control_texture: control, + albedo_tiles, + nrma_tiles, + world_origin: [0.0, 0.0], + world_size: size, + tile_scale: TERRAIN_MATERIAL_METERS_PER_TILE, + normal_strength: 1.2, + biome: biome_id(self.biome), + }), + ..MaterialDesc::default() + }); + self.slots.push(TerrainSlot { + control, + material, + detail_matrices: core::array::from_fn(|_| { + Vec::with_capacity(DETAIL_CAPACITY_PER_KIND as usize) + }), + entity: None, + }); + } + self.detail_batches = Some(core::array::from_fn(|kind| { + renderer.add_instance_batch( + gpu, + detail_meshes[kind], + detail_materials[kind], + slot_count as u32 * DETAIL_CAPACITY_PER_KIND, + self.mask, + DETAIL_MAX_DISTANCE, + ) + })); + self.shared_mesh = Some(mesh); + self.albedo_tiles = Some(albedo_tiles); + self.nrma_tiles = Some(nrma_tiles); + } + + /// Ensure every chunk within `radius` of the world center is loaded. Slots, + /// textures, materials, and the quad mesh are fixed after first warmup. + pub fn ensure_around<G: Gpu>( + &mut self, + world: &mut GameWorld, + renderer: &mut Renderer, + gpu: &mut G, + center_x: f64, + center_z: f64, + ) { + renderer.gi_set_ground_albedo(biome_ground_albedo(self.biome)); + self.ensure_shared(renderer, gpu); + let (ccx, ccz) = self.chunk_of(center_x, center_z); + + self.evict_scratch.clear(); + self.evict_scratch.extend( + self.loaded.keys().copied().filter(|(cx, cz)| { + (cx - ccx).abs() > self.radius || (cz - ccz).abs() > self.radius + }), + ); + for key in self.evict_scratch.drain(..) { + if let Some(slot_index) = self.loaded.remove(&key) { + if let Some(entity) = self.slots[slot_index].entity.take() { + world.destroy(entity); + } + for matrices in &mut self.slots[slot_index].detail_matrices { + matrices.clear(); + } + } + } + world.flush(); + + for dz in -self.radius..=self.radius { + for dx in -self.radius..=self.radius { + let key = (ccx + dx, ccz + dz); + if !self.loaded.contains_key(&key) { + self.load_chunk(world, renderer, gpu, key); + } + } + } + world.flush(); + self.upload_detail_batches(renderer, gpu, center_x as f32, center_z as f32); + } + + /// Release every area-scoped chunk: despawn chunk entities, forget the + /// residency map, and zero the flora instance batches so a dropped + /// streamer leaves nothing rendering. Used on area transition before a + /// new streamer (new seed/biome) is built. + pub fn clear<G: Gpu>(&mut self, world: &mut GameWorld, renderer: &mut Renderer, gpu: &mut G) { + for slot in &mut self.slots { + if let Some(entity) = slot.entity.take() { + world.destroy(entity); + } + for matrices in &mut slot.detail_matrices { + matrices.clear(); + } + } + self.loaded.clear(); + for matrices in &mut self.merged_detail_matrices { + matrices.clear(); + } + if let Some(batches) = self.detail_batches { + for batch in batches { + let _ = renderer.update_instance_batch(gpu, batch, &[], [0.0, 0.0, 0.0]); + } + } + world.flush(); + } + + fn load_chunk<G: Gpu>( + &mut self, + world: &mut GameWorld, + renderer: &mut Renderer, + gpu: &mut G, + (cx, cz): (i32, i32), + ) { + let slot_index = self + .slots + .iter() + .position(|slot| slot.entity.is_none()) + .expect("terrain slot pool exhausted"); + let origin_x = cx as f64 * self.chunk_cells; + let origin_z = cz as f64 * self.chunk_cells; + self.bake_control(origin_x, origin_z); + self.scatter_details(origin_x as f32, origin_z as f32); + let slot = &mut self.slots[slot_index]; + gpu.update_texture(slot.control, &control_texture_desc(), &self.control_scratch); + renderer.update_material_desc( + slot.material, + MaterialDesc { + metallic: 0.0, + roughness: 1.0, + terrain: Some(TerrainMaterialDesc { + control_texture: slot.control, + albedo_tiles: self.albedo_tiles.expect("terrain albedo tiles"), + nrma_tiles: self.nrma_tiles.expect("terrain NRMA tiles"), + world_origin: [origin_x as f32, origin_z as f32], + world_size: self.chunk_cells as f32, + tile_scale: TERRAIN_MATERIAL_METERS_PER_TILE, + normal_strength: 1.2, + biome: biome_id(self.biome), + }), + ..MaterialDesc::default() + }, + ); + for kind in 0..3 { + slot.detail_matrices[kind].clear(); + core::mem::swap( + &mut slot.detail_matrices[kind], + &mut self.detail_matrices[kind], + ); + } + let entity = world.spawn(); + world.set_component( + entity, + Transform { + pos: vec3(origin_x as f32, 0.0, origin_z as f32), + ..Default::default() + }, + ); + world.set_component( + entity, + MeshRenderer { + mesh: self.shared_mesh.expect("terrain mesh"), + material: slot.material, + viewport_mask: self.mask, + skin: SkinRef::NONE, + }, + ); + slot.entity = Some(entity); + self.loaded.insert((cx, cz), slot_index); + } + + fn upload_detail_batches<G: Gpu>( + &mut self, + renderer: &mut Renderer, + gpu: &mut G, + center_x: f32, + center_z: f32, + ) { + for matrices in &mut self.merged_detail_matrices { + matrices.clear(); + } + for slot in &self.slots { + if slot.entity.is_none() { + continue; + } + for (merged, chunk) in self + .merged_detail_matrices + .iter_mut() + .zip(&slot.detail_matrices) + { + merged.extend_from_slice(chunk); + } + } + let batches = self.detail_batches.expect("terrain detail batches"); + for (kind, batch) in batches.into_iter().enumerate() { + let updated = renderer.update_instance_batch( + gpu, + batch, + &self.merged_detail_matrices[kind], + [center_x, 0.0, center_z], + ); + debug_assert!(updated, "terrain detail pool exceeded fixed capacity"); + } + } + + fn scatter_details(&mut self, origin_x: f32, origin_z: f32) { + let size = self.chunk_cells as f32; + let exclusions = &self.exclusions; + scatter_into( + &mut self.detail_scratch, + self.seed ^ 0x51a7_3e2d, + [origin_x, origin_z], + [origin_x + size, origin_z + size], + biome_density(self.biome) * (64.0 / size).powi(2), + |point| point_blocked(exclusions, point), + ); + for matrices in &mut self.detail_matrices { + matrices.clear(); + } + for instance in &mut self.detail_scratch { + instance.pos[1] = flattened_height( + self.seed, + self.biome, + exclusions, + instance.pos[0], + instance.pos[2], + ); + let kind = DetailKind::from_hash(instance.kind); + self.detail_matrices[kind as usize] + .push(detail_instance_matrix(instance, kind, self.biome)); + } + } + + fn bake_control(&mut self, origin_x: f64, origin_z: f64) { + let step = self.chunk_cells / (CONTROL_INTERIOR_PX - 1) as f64; + for y in 0..CONTROL_PX { + let world_z = origin_z + (y as f64 - 1.0) * step; + for x in 0..CONTROL_PX { + let world_x = origin_x + (x as f64 - 1.0) * step; + let sample = sample_terrain(self.seed, world_x, world_z, self.biome); + let height = self.height_at(world_x as f32, world_z as f32); + let offset = ((y * CONTROL_PX + x) * 4) as usize; + self.control_scratch[offset] = unit_byte(sample.weights[0]); + self.control_scratch[offset + 1] = unit_byte(sample.weights[1]); + self.control_scratch[offset + 2] = unit_byte(sample.weights[2]); + self.control_scratch[offset + 3] = + unit_byte((height + HEIGHT_RANGE) / (HEIGHT_RANGE * 2.0)); + } + } + } + + pub fn height_at(&self, world_x: f32, world_z: f32) -> f32 { + flattened_height(self.seed, self.biome, &self.exclusions, world_x, world_z) + } + + #[cfg(test)] + fn resident_resources(&self) -> (usize, usize, usize) { + ( + self.slots.len(), + self.loaded.len(), + usize::from(self.shared_mesh.is_some()), + ) + } +} + +fn control_texture_desc() -> TextureDesc { + TextureDesc { + width: CONTROL_PX, + height: CONTROL_PX, + format: TextureFormat::Rgba8, + mag_filter: Filter::Linear, + min_filter: MinFilter::Linear, + wrap_s: Wrap::ClampToEdge, + wrap_t: Wrap::ClampToEdge, + mipmaps: false, + } +} + +fn unit_byte(value: f32) -> u8 { + (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8 +} + +fn biome_id(biome: Biome) -> i32 { + match biome { + Biome::Desert => 0, + Biome::Forest => 1, + } +} + +fn detail_materials(renderer: &mut Renderer, biome: Biome) -> [MaterialId; 3] { + let colors = match biome { + Biome::Desert => [ + [0.30, 0.23, 0.17, 1.0], + [0.23, 0.27, 0.10, 1.0], + [0.25, 0.18, 0.095, 1.0], + ], + Biome::Forest => [ + [0.22, 0.25, 0.20, 1.0], + [0.10, 0.27, 0.055, 1.0], + [0.065, 0.19, 0.04, 1.0], + ], + }; + core::array::from_fn(|kind| { + renderer.add_material_desc(MaterialDesc { + base_color: colors[kind], + metallic: 0.0, + roughness: if kind == DetailKind::Rock as usize { + 0.76 + } else { + 0.91 + }, + double_sided: kind == DetailKind::GroundCover as usize, + ..MaterialDesc::default() + }) + }) +} + +fn point_blocked(exclusions: &[TerrainExclusion], point: [f32; 2]) -> bool { + exclusions.iter().any(|exclusion| { + point[0] >= exclusion.min[0] + && point[0] <= exclusion.max[0] + && point[1] >= exclusion.min[1] + && point[1] <= exclusion.max[1] + }) +} + +fn flattened_height( + seed: i32, + biome: Biome, + exclusions: &[TerrainExclusion], + world_x: f32, + world_z: f32, +) -> f32 { + let base = terrain_height(seed, world_x as f64, world_z as f64, biome); + let mut keep = 1.0f32; + for exclusion in exclusions { + let dx = if world_x < exclusion.min[0] { + exclusion.min[0] - world_x + } else if world_x > exclusion.max[0] { + world_x - exclusion.max[0] + } else { + 0.0 + }; + let dz = if world_z < exclusion.min[1] { + exclusion.min[1] - world_z + } else if world_z > exclusion.max[1] { + world_z - exclusion.max[1] + } else { + 0.0 + }; + let outside = (dx * dx + dz * dz).sqrt(); + keep = keep.min(smoothstep01(outside / exclusion.feather.max(0.001))); + } + base * keep +} + +fn smoothstep01(value: f32) -> f32 { + let value = value.clamp(0.0, 1.0); + value * value * (3.0 - 2.0 * value) +} + +/// Shared tessellated patch. Displacement is sampled from each slot's control +/// texture in the terrain vertex shader. +fn chunk_grid(size: f32, segments: u32) -> (Vec<f32>, Vec<u32>) { + let segments = segments.max(1); + let side = segments + 1; + let mut vertices = Vec::with_capacity((side * side * 8) as usize); + let mut indices = Vec::with_capacity((segments * segments * 6) as usize); + for z in 0..=segments { + for x in 0..=segments { + let u = x as f32 / segments as f32; + let v = z as f32 / segments as f32; + vertices.extend_from_slice(&[u * size, 0.0, v * size, 0.0, 1.0, 0.0, u, v]); + } + } + for z in 0..segments { + for x in 0..segments { + let a = z * side + x; + let b = a + 1; + let c = a + side; + let d = c + 1; + indices.extend_from_slice(&[a, d, b, a, c, d]); + } + } + (vertices, indices) +} + +/// A ready-to-render terrain scene for `--demo terrain`. +pub struct TerrainScene { + pub world: GameWorld, + pub renderer: Renderer, + streamer: TerrainStreamer, + camera: Entity, + center: Vec3, + orbit: f32, + fixed_camera: bool, +} + +impl TerrainScene { + pub fn build<G: Gpu>(gpu: &mut G, biome: Biome) -> TerrainScene { + use successor_engine_render::components::{ + CamTarget, Camera, DirectionalLight, Projection, RectNorm, + }; + use successor_engine_render::gpu::ClearSpec; + + let mut renderer = crate::configured_renderer(gpu).expect("renderer initialization failed"); + renderer.set_ambient(0.55); + let fog = match biome { + Biome::Forest => [0.615, 0.658, 0.408], + Biome::Desert => [0.788, 0.678, 0.510], + }; + renderer.set_fog(fog, 120.0, 260.0); + let mut world = GameWorld::new(); + + // Demo-scale streaming; production supplies the authoritative chunk size. + let mut streamer = TerrainStreamer::new( + 0x0d3d_071e, + biome, + 64.0 * WORLD_UNITS_PER_CELL as f64, + 2, + 0b1, + ); + let center = vec3(0.0, 0.0, 0.0); + renderer.gi_set_focus([center.x, center.y, center.z]); + streamer.ensure_around( + &mut world, + &mut renderer, + gpu, + center.x as f64, + center.z as f64, + ); + + let sun = world.spawn(); + world.set_component( + sun, + DirectionalLight { + dir: vec3(-0.4, -1.0, -0.3).normalize(), + color: [1.0, 0.98, 0.92], + cast_shadows: false, + }, + ); + + let orbit = 150.0f32; + let camera = world.spawn(); + world.set_component( + camera, + Camera { + viewport_id: 0, + order: 0, + projection: Projection::Perspective { + fovy: 50.0_f32.to_radians(), + near: 0.5, + far: 2000.0, + }, + target: CamTarget::Screen(RectNorm::FULL), + clear: ClearSpec { + color: Some([fog[0], fog[1], fog[2], 1.0]), + depth: Some(1.0), + }, + eye: center.add(vec3(orbit, orbit * 0.9, orbit)), + look_at: center, + up: Vec3::Y, + }, + ); + + TerrainScene { + world, + renderer, + streamer, + camera, + center, + orbit, + fixed_camera: false, + } + } + + pub fn use_material_detail_view(&mut self) { + self.fixed_camera = true; + let (eye, look) = match self.streamer.biome { + Biome::Desert => (vec3(40.0, 15.0, 33.0), vec3(0.0, 0.0, -10.0)), + Biome::Forest => (vec3(36.0, 13.0, 30.0), vec3(0.0, 0.0, -8.0)), + }; + if let Some(camera) = self + .world + .get_component::<successor_engine_render::components::Camera>(self.camera) + { + camera.eye = self.center.add(eye); + camera.look_at = self.center.add(look); + } + } + + pub fn animate(&mut self, frame: u64) { + if self.fixed_camera { + return; + } + use successor_engine_render::components::Camera; + let angle = frame as f32 * 0.008; + let eye = self.center.add(vec3( + angle.cos() * self.orbit, + self.orbit * 0.9, + angle.sin() * self.orbit, + )); + if let Some(cam) = self.world.get_component::<Camera>(self.camera) { + cam.eye = eye; + } + let _ = &self.streamer; // streaming re-runs only when the center moves (static demo). + } +} + +#[cfg(test)] +mod tests { + use super::*; + use successor_engine_render::gpu::{MockCall, MockGpu}; + use successor_engine_render::renderer::{RenderQuality, RendererLimits}; + + #[test] + fn traversal_reuses_fixed_terrain_resources() { + let mut gpu = MockGpu::default(); + let mut renderer = Renderer::new( + &mut gpu, + RendererLimits { + quality: RenderQuality::Low, + ..RendererLimits::default() + }, + ) + .expect("renderer"); + let mut world = GameWorld::new(); + let mut streamer = TerrainStreamer::new(7, Biome::Desert, 256.0, 1, 1); + streamer.ensure_around(&mut world, &mut renderer, &mut gpu, 0.0, 0.0); + let first = streamer.resident_resources(); + let first_detail_batches = renderer.instance_batch_count(); + streamer.ensure_around(&mut world, &mut renderer, &mut gpu, 2560.0, -2560.0); + let second = streamer.resident_resources(); + let second_detail_batches = renderer.instance_batch_count(); + assert_eq!(first, (9, 9, 1)); + assert_eq!(second, first); + assert_eq!(first_detail_batches, 3); + assert_eq!(second_detail_batches, first_detail_batches); + assert_eq!( + gpu.log + .iter() + .filter(|call| matches!(call, MockCall::CreateTextureArray)) + .count(), + 2 + ); + } + #[test] + fn tessellated_patch_has_shared_edges_and_expected_topology() { + let (vertices, indices) = chunk_grid(64.0, 32); + assert_eq!(vertices.len(), 33 * 33 * 8); + assert_eq!(indices.len(), 32 * 32 * 6); + let first = &vertices[0..8]; + let last = &vertices[(33 * 33 - 1) * 8..33 * 33 * 8]; + assert_eq!(&first[0..3], &[0.0, 0.0, 0.0]); + assert_eq!(&last[0..3], &[64.0, 0.0, 64.0]); + assert!(indices.iter().all(|index| *index < 33 * 33)); + } + + #[test] + fn structure_exclusions_flatten_only_the_padded_footprint() { + let mut streamer = TerrainStreamer::new(7, Biome::Forest, 64.0, 1, 1); + streamer.set_exclusions(&[TerrainExclusion { + min: [10.0, 20.0], + max: [18.0, 28.0], + feather: 4.0, + }]); + assert_eq!(streamer.height_at(14.0, 24.0), 0.0); + assert_eq!(streamer.height_at(10.0, 20.0), 0.0); + let feathered = streamer.height_at(20.0, 24.0); + let base = terrain_height(7, 20.0, 24.0, Biome::Forest); + assert!((feathered - base * 0.5).abs() < 1.0e-5); + assert_eq!( + streamer.height_at(30.0, 24.0), + terrain_height(7, 30.0, 24.0, Biome::Forest) + ); + } + + #[test] + fn detail_scatter_respects_structure_exclusions_and_capacity() { + let mut streamer = TerrainStreamer::new(17, Biome::Forest, 64.0, 1, 1); + let exclusion = TerrainExclusion { + min: [8.0, 8.0], + max: [56.0, 56.0], + feather: 3.0, + }; + streamer.set_exclusions(&[exclusion]); + streamer.scatter_details(0.0, 0.0); + let mut count = 0; + for matrices in &streamer.detail_matrices { + assert!(matrices.len() <= DETAIL_CAPACITY_PER_KIND as usize); + for matrix in matrices { + count += 1; + assert!(!point_blocked(&[exclusion], [matrix[12], matrix[14]])); + } + } + assert!(count > 0); + } + + #[test] + fn terrain_scene_reaches_deferred_draws() { + let mut gpu = MockGpu::default(); + let mut scene = TerrainScene::build(&mut gpu, Biome::Desert); + gpu.log.clear(); + scene + .renderer + .render(&mut gpu, &mut scene.world, 1280, 720) + .expect("terrain render"); + assert!( + gpu.draw_calls() >= 25, + "all resident terrain chunks must draw" + ); + assert!(gpu.log.iter().any(|call| matches!( + call, + MockCall::UniformFloat { + name: "u_terrainWorldSize", + .. + } + ))); + assert!(gpu + .log + .iter() + .any(|call| matches!(call, MockCall::DrawInstanced { instances } if *instances > 0))); + } +} diff --git a/client-rust/source/app/src/world/collision_debug.rs b/client-rust/source/app/src/world/collision_debug.rs new file mode 100644 index 00000000..3e5ca2e1 --- /dev/null +++ b/client-rust/source/app/src/world/collision_debug.rs @@ -0,0 +1,1013 @@ +//! Developer-only visualization of the movement collision inputs visible to the client. +//! +//! Geometry mirrors the authority's milli-cell AABBs. It never participates in +//! movement, picking, shadows, GI, or any other gameplay path. + +use serde_json::Value; +use successor_engine_core::ecs::{Entity, WorldOps}; +use successor_engine_core::json::Json; +use successor_engine_core::math::{vec3, Quat}; +use successor_engine_render::components::{MaterialId, MeshId, MeshRenderer, SkinRef, Transform}; +use successor_engine_render::gpu::Gpu; +use successor_engine_render::renderer::{MaterialDesc, Renderer}; + +use crate::world::chunks::TerrainStreamer; +use crate::world::{ADULT_PAWN_HEIGHT_METERS, WORLD_UNITS_PER_CELL}; +use crate::GameWorld; + +const ACTIVE_MASK: u32 = 0b1; +const HIDDEN_MASK: u32 = 0; +const EDGE_THICKNESS: f32 = 0.025; +const PLAYER_RADIUS_CELLS: f32 = 0.3; +const CLEARANCE_RADIUS_CELLS: f32 = 0.3; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CollisionKind { + BlockedCell, + StaticProp, + Door, + Building, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CollisionAabb { + pub left_milli: i32, + pub top_milli: i32, + pub right_milli: i32, + pub bottom_milli: i32, + pub kind: CollisionKind, +} + +#[derive(Clone, Copy)] +struct DebugInstance { + entity: Entity, + clearance_entity: Option<Entity>, + active: bool, + kind: CollisionKind, + bound: Option<CollisionAabb>, +} + +struct DoorInstance { + prop_id: String, + debug: DebugInstance, +} + +pub struct CollisionDebugOverlay { + enabled: bool, + box_mesh: MeshId, + player_mesh: MeshId, + active_static: MaterialId, + active_dynamic: MaterialId, + inactive: MaterialId, + clearance: MaterialId, + authority: MaterialId, + predicted: MaterialId, + player: MaterialId, + static_instances: Vec<DebugInstance>, + doors: Vec<DoorInstance>, + buildings: Vec<DebugInstance>, + player_entity: Option<Entity>, + authority_entity: Option<Entity>, + predicted_entity: Option<Entity>, + building_hash: u64, +} + +impl CollisionDebugOverlay { + pub fn new<G: Gpu>(renderer: &mut Renderer, gpu: &mut G) -> Self { + let (box_vertices, box_indices) = wire_box_mesh(); + let box_mesh = renderer.upload_mesh(gpu, &box_vertices, &box_indices); + let (player_vertices, player_indices) = wire_cylinder_mesh(20); + let player_mesh = renderer.upload_mesh(gpu, &player_vertices, &player_indices); + let active_static = debug_material(renderer, [1.0, 0.08, 0.04]); + let active_dynamic = debug_material(renderer, [1.0, 0.48, 0.03]); + let inactive = debug_material(renderer, [0.08, 0.65, 0.72]); + let clearance = debug_material(renderer, [0.18, 1.0, 0.12]); + let authority = debug_material(renderer, [0.05, 0.75, 1.0]); + let predicted = debug_material(renderer, [1.0, 0.92, 0.05]); + let player = debug_material(renderer, [1.0, 0.05, 0.85]); + Self { + enabled: false, + box_mesh, + player_mesh, + active_static, + active_dynamic, + inactive, + clearance, + authority, + predicted, + player, + static_instances: Vec::new(), + doors: Vec::new(), + buildings: Vec::new(), + player_entity: None, + authority_entity: None, + predicted_entity: None, + building_hash: 0, + } + } + + pub fn enabled(&self) -> bool { + self.enabled + } + + pub fn set_enabled(&mut self, world: &mut GameWorld, enabled: bool) { + self.enabled = enabled; + for instance in self + .static_instances + .iter() + .chain(self.doors.iter().map(|door| &door.debug)) + .chain(self.buildings.iter()) + { + apply_instance( + world, + *instance, + enabled, + self.active_static, + self.active_dynamic, + self.inactive, + self.clearance, + ); + } + ensure_marker( + world, + &mut self.player_entity, + self.player_mesh, + self.player, + PLAYER_RADIUS_CELLS, + enabled, + ); + ensure_marker( + world, + &mut self.authority_entity, + self.player_mesh, + self.authority, + PLAYER_RADIUS_CELLS * 0.72, + enabled, + ); + ensure_marker( + world, + &mut self.predicted_entity, + self.player_mesh, + self.predicted, + PLAYER_RADIUS_CELLS * 0.86, + enabled, + ); + } + + pub fn toggle(&mut self, world: &mut GameWorld) { + self.set_enabled(world, !self.enabled); + } + + pub fn clear_area(&mut self, world: &mut GameWorld) { + for instance in self.static_instances.drain(..) { + world.destroy(instance.entity); + if let Some(entity) = instance.clearance_entity { + world.destroy(entity); + } + } + for door in self.doors.drain(..) { + world.destroy(door.debug.entity); + if let Some(entity) = door.debug.clearance_entity { + world.destroy(entity); + } + } + for instance in self.buildings.drain(..) { + world.destroy(instance.entity); + if let Some(entity) = instance.clearance_entity { + world.destroy(entity); + } + } + self.building_hash = 0; + world.flush(); + } + + pub fn load_area<G: Gpu>( + &mut self, + world: &mut GameWorld, + renderer: &mut Renderer, + gpu: &mut G, + slice: &Json, + area_id: &str, + terrain: &TerrainStreamer, + prop_states: &std::collections::HashMap<String, Value>, + ) { + self.clear_area(world); + let static_bounds = authored_collision_bounds(slice, area_id); + if let Some(instance) = + self.spawn_static_batch(world, renderer, gpu, &static_bounds, terrain) + { + self.static_instances.push(instance); + } + if let Some(props) = slice.get("props").and_then(Json::as_array) { + for prop in props { + if prop.get("areaId").and_then(Json::as_str) != Some(area_id) { + continue; + } + let Some(blocker) = prop.get("door").and_then(|door| door.get("blocker")) else { + continue; + }; + let Some(bound) = placed_bound(prop, blocker, CollisionKind::Door, slice, area_id) + else { + continue; + }; + let prop_id = prop.get("id").and_then(Json::as_str).unwrap_or(""); + let active = door_blocker_active(prop_states.get(prop_id)); + let debug = self.spawn_bound(world, bound, terrain, active); + self.doors.push(DoorInstance { + prop_id: prop_id.to_owned(), + debug, + }); + } + } + self.set_enabled(world, self.enabled); + } + + pub fn sync_dynamic( + &mut self, + world: &mut GameWorld, + area_id: &str, + terrain: &TerrainStreamer, + prop_states: &std::collections::HashMap<String, Value>, + building: Option<&Value>, + ) -> bool { + let mut changed = false; + for door in &mut self.doors { + let active = door_blocker_active(prop_states.get(&door.prop_id)); + if active != door.debug.active { + changed = true; + door.debug.active = active; + apply_instance( + world, + door.debug, + self.enabled, + self.active_static, + self.active_dynamic, + self.inactive, + self.clearance, + ); + } + } + + // Only `components` feed collision bounds; sibling metadata (`tick`, + // `schema`, …) churns every authority snapshot and must not trigger a + // destroy/respawn + movement-collision rebuild every frame. + let hash = building + .and_then(|value| value.get("components")) + .map(stable_json_hash) + .unwrap_or(0); + if hash != self.building_hash { + changed = true; + for instance in self.buildings.drain(..) { + world.destroy(instance.entity); + if let Some(entity) = instance.clearance_entity { + world.destroy(entity); + } + } + self.building_hash = hash; + if let Some(components) = building + .and_then(|value| value.get("components")) + .and_then(Value::as_array) + { + for component in components { + if component.get("areaId").and_then(Value::as_str) != Some(area_id) { + continue; + } + let kind = component.get("kind").and_then(Value::as_str).unwrap_or(""); + if !matches!(kind, "wall" | "window" | "door") { + continue; + } + let active = kind != "door" + || !component + .get("doorOpen") + .and_then(Value::as_bool) + .unwrap_or(false); + if let Some(bound) = building_bound(component) { + let instance = self.spawn_bound(world, bound, terrain, active); + self.buildings.push(instance); + } + } + } + self.set_enabled(world, self.enabled); + } + changed + } + pub fn append_active_dynamic_bounds(&self, out: &mut Vec<successor_movement::CircleAabb>) { + out.extend( + self.doors + .iter() + .map(|door| &door.debug) + .chain(self.buildings.iter()) + .filter(|instance| instance.active) + .filter_map(|instance| instance.bound) + .map(|bound| { + successor_movement::CircleAabb::new( + bound.left_milli, + bound.top_milli, + bound.right_milli, + bound.bottom_milli, + ) + }), + ); + } + + pub fn update_player( + &mut self, + world: &mut GameWorld, + rendered: (f32, f32), + authoritative: (f32, f32), + predicted: (f32, f32), + ground_y: f32, + ) { + update_marker(world, self.player_entity, rendered, ground_y); + update_marker(world, self.authority_entity, authoritative, ground_y); + update_marker(world, self.predicted_entity, predicted, ground_y); + } + + fn spawn_bound( + &self, + world: &mut GameWorld, + bound: CollisionAabb, + terrain: &TerrainStreamer, + active: bool, + ) -> DebugInstance { + let left = bound.left_milli as f32 / 1_000.0 * WORLD_UNITS_PER_CELL; + + let top = bound.top_milli as f32 / 1_000.0 * WORLD_UNITS_PER_CELL; + let right = bound.right_milli as f32 / 1_000.0 * WORLD_UNITS_PER_CELL; + let bottom = bound.bottom_milli as f32 / 1_000.0 * WORLD_UNITS_PER_CELL; + let center_x = (left + right) * 0.5; + let center_z = (top + bottom) * 0.5; + let ground = terrain.height_at(center_x, center_z); + let entity = spawn_box_entity( + world, + self.box_mesh, + material_for_kind( + bound.kind, + active, + self.active_static, + self.active_dynamic, + self.inactive, + ), + self.enabled, + center_x, + center_z, + ground, + (right - left).max(0.001), + (bottom - top).max(0.001), + ); + let clearance_entity = spawn_box_entity( + world, + self.box_mesh, + self.clearance, + self.enabled, + center_x, + center_z, + ground + EDGE_THICKNESS, + (right - left).max(0.001) + CLEARANCE_RADIUS_CELLS * 2.0 * WORLD_UNITS_PER_CELL, + (bottom - top).max(0.001) + CLEARANCE_RADIUS_CELLS * 2.0 * WORLD_UNITS_PER_CELL, + ); + let instance = DebugInstance { + entity, + active, + kind: bound.kind, + bound: Some(bound), + clearance_entity: Some(clearance_entity), + }; + + instance + } + fn spawn_static_batch<G: Gpu>( + &self, + world: &mut GameWorld, + renderer: &mut Renderer, + gpu: &mut G, + bounds: &[CollisionAabb], + terrain: &TerrainStreamer, + ) -> Option<DebugInstance> { + if bounds.is_empty() { + return None; + } + let (vertices, indices) = wire_bounds_mesh(bounds, terrain, 0.0); + let mesh = renderer.upload_mesh(gpu, &vertices, &indices); + let entity = world.spawn(); + world.set_component(entity, Transform::default()); + world.set_component( + entity, + MeshRenderer { + mesh, + material: self.active_static, + viewport_mask: if self.enabled { + ACTIVE_MASK + } else { + HIDDEN_MASK + }, + skin: SkinRef::NONE, + }, + ); + let (clearance_vertices, clearance_indices) = + wire_bounds_mesh(bounds, terrain, CLEARANCE_RADIUS_CELLS); + let clearance_mesh = renderer.upload_mesh(gpu, &clearance_vertices, &clearance_indices); + let clearance_entity = world.spawn(); + world.set_component(clearance_entity, Transform::default()); + world.set_component( + clearance_entity, + MeshRenderer { + mesh: clearance_mesh, + material: self.clearance, + viewport_mask: if self.enabled { + ACTIVE_MASK + } else { + HIDDEN_MASK + }, + skin: SkinRef::NONE, + }, + ); + Some(DebugInstance { + entity, + active: true, + kind: CollisionKind::StaticProp, + bound: None, + clearance_entity: Some(clearance_entity), + }) + } +} + +fn spawn_box_entity( + world: &mut GameWorld, + mesh: MeshId, + material: MaterialId, + enabled: bool, + center_x: f32, + center_z: f32, + ground: f32, + width: f32, + depth: f32, +) -> Entity { + let entity = world.spawn(); + world.set_component( + entity, + Transform { + pos: vec3(center_x, ground + ADULT_PAWN_HEIGHT_METERS * 0.5, center_z), + rot: Quat::IDENTITY, + scale: vec3(width, ADULT_PAWN_HEIGHT_METERS, depth), + }, + ); + world.set_component( + entity, + MeshRenderer { + mesh, + material, + viewport_mask: if enabled { ACTIVE_MASK } else { HIDDEN_MASK }, + skin: SkinRef::NONE, + }, + ); + entity +} + +fn ensure_marker( + world: &mut GameWorld, + slot: &mut Option<Entity>, + mesh: MeshId, + material: MaterialId, + radius_cells: f32, + enabled: bool, +) { + if slot.is_none() { + let entity = world.spawn(); + world.set_component( + entity, + Transform { + pos: vec3(0.0, ADULT_PAWN_HEIGHT_METERS * 0.5, 0.0), + rot: Quat::IDENTITY, + scale: vec3( + radius_cells * 2.0, + ADULT_PAWN_HEIGHT_METERS, + radius_cells * 2.0, + ), + }, + ); + world.set_component( + entity, + MeshRenderer { + mesh, + material, + viewport_mask: if enabled { ACTIVE_MASK } else { HIDDEN_MASK }, + skin: SkinRef::NONE, + }, + ); + *slot = Some(entity); + } else if let Some(render) = + (*slot).and_then(|entity| world.get_component::<MeshRenderer>(entity)) + { + render.viewport_mask = if enabled { ACTIVE_MASK } else { HIDDEN_MASK }; + } +} + +fn update_marker( + world: &mut GameWorld, + entity: Option<Entity>, + position_cells: (f32, f32), + ground_y: f32, +) { + if let Some(transform) = entity.and_then(|entity| world.get_component::<Transform>(entity)) { + transform.pos = vec3( + (position_cells.0 + 0.5) * WORLD_UNITS_PER_CELL, + ground_y + ADULT_PAWN_HEIGHT_METERS * 0.5, + (position_cells.1 + 0.5) * WORLD_UNITS_PER_CELL, + ); + } +} + +fn material_for_kind( + kind: CollisionKind, + active: bool, + static_mat: MaterialId, + dynamic_mat: MaterialId, + inactive: MaterialId, +) -> MaterialId { + if !active { + inactive + } else if matches!(kind, CollisionKind::Door | CollisionKind::Building) { + dynamic_mat + } else { + static_mat + } +} + +fn debug_material(renderer: &mut Renderer, color: [f32; 3]) -> MaterialId { + renderer.add_material_desc(MaterialDesc { + base_color: [color[0], color[1], color[2], 1.0], + metallic: 0.0, + roughness: 0.25, + emissive_factor: color, + emissive_strength: 2.0, + blend: false, + double_sided: true, + ..MaterialDesc::default() + }) +} + +fn material_for( + instance: DebugInstance, + static_mat: MaterialId, + dynamic_mat: MaterialId, + inactive: MaterialId, +) -> MaterialId { + material_for_kind( + instance.kind, + instance.active, + static_mat, + dynamic_mat, + inactive, + ) +} + +fn apply_instance( + world: &mut GameWorld, + instance: DebugInstance, + enabled: bool, + static_mat: MaterialId, + dynamic_mat: MaterialId, + inactive: MaterialId, + clearance: MaterialId, +) { + if let Some(render) = world.get_component::<MeshRenderer>(instance.entity) { + render.viewport_mask = if enabled { ACTIVE_MASK } else { HIDDEN_MASK }; + render.material = material_for(instance, static_mat, dynamic_mat, inactive); + } + if let Some(render) = instance + .clearance_entity + .and_then(|entity| world.get_component::<MeshRenderer>(entity)) + { + render.viewport_mask = if enabled && instance.active { + ACTIVE_MASK + } else { + HIDDEN_MASK + }; + render.material = clearance; + } +} + +pub fn authored_collision_bounds(slice: &Json, area_id: &str) -> Vec<CollisionAabb> { + let mut out = Vec::new(); + if let Some(cells) = slice.get("blockedCells").and_then(Json::as_array) { + for cell in cells { + if cell.get("areaId").and_then(Json::as_str) != Some(area_id) { + continue; + } + let (Some(x), Some(y)) = ( + cell.get("x") + .and_then(Json::as_i64) + .and_then(|v| i32::try_from(v).ok()), + cell.get("y") + .and_then(Json::as_i64) + .and_then(|v| i32::try_from(v).ok()), + ) else { + continue; + }; + out.push(CollisionAabb { + left_milli: x.saturating_mul(1_000), + top_milli: y.saturating_mul(1_000), + right_milli: x.saturating_add(1).saturating_mul(1_000), + bottom_milli: y.saturating_add(1).saturating_mul(1_000), + kind: CollisionKind::BlockedCell, + }); + } + } + if let Some(props) = slice.get("props").and_then(Json::as_array) { + for prop in props { + if prop.get("areaId").and_then(Json::as_str) != Some(area_id) { + continue; + } + if let Some(bounds) = prop.get("collisionBounds").and_then(Json::as_array) { + for bound in bounds { + if let Some(bound) = + placed_bound(prop, bound, CollisionKind::StaticProp, slice, area_id) + { + out.push(bound); + } + } + } + } + } + out +} + +fn placed_bound( + prop: &Json, + bound: &Json, + kind: CollisionKind, + slice: &Json, + area_id: &str, +) -> Option<CollisionAabb> { + let cell = prop.get("cell")?; + let size = prop.get("size")?; + let cell_x = i32::try_from(cell.get("x")?.as_i64()?).ok()?; + let cell_y = i32::try_from(cell.get("y")?.as_i64()?).ok()?; + let width = i32::try_from(size.get("w")?.as_i64()?) + .ok()? + .max(1) + .saturating_mul(1_000); + let height = i32::try_from(size.get("h")?.as_i64()?) + .ok()? + .max(1) + .saturating_mul(1_000); + let x = i32::try_from(bound.get("xMilli")?.as_i64()?) + .ok()? + .clamp(0, width); + let y = i32::try_from(bound.get("yMilli")?.as_i64()?) + .ok()? + .clamp(0, height); + let w = i32::try_from(bound.get("wMilli")?.as_i64()?).ok()?.max(0); + let h = i32::try_from(bound.get("hMilli")?.as_i64()?).ok()?.max(0); + let mut right = x.saturating_add(w).clamp(0, width); + let mut bottom = y.saturating_add(h).clamp(0, height); + if right <= x || bottom <= y { + return None; + } + let (area_w, area_h) = area_size_milli(slice, area_id); + let left = cell_x + .saturating_mul(1_000) + .saturating_add(x) + .clamp(0, area_w); + let top = cell_y + .saturating_mul(1_000) + .saturating_add(y) + .clamp(0, area_h); + right = cell_x + .saturating_mul(1_000) + .saturating_add(right) + .clamp(0, area_w); + bottom = cell_y + .saturating_mul(1_000) + .saturating_add(bottom) + .clamp(0, area_h); + (right > left && bottom > top).then_some(CollisionAabb { + left_milli: left, + top_milli: top, + right_milli: right, + bottom_milli: bottom, + kind, + }) +} + +fn area_size_milli(slice: &Json, area_id: &str) -> (i32, i32) { + slice + .get("areas") + .and_then(Json::as_array) + .and_then(|areas| { + areas + .iter() + .find(|area| area.get("id").and_then(Json::as_str) == Some(area_id)) + }) + .map(|area| { + let w = area + .get("width") + .and_then(Json::as_i64) + .and_then(|v| i32::try_from(v).ok()) + .unwrap_or(i32::MAX / 1_000) + .saturating_mul(1_000); + let h = area + .get("height") + .and_then(Json::as_i64) + .and_then(|v| i32::try_from(v).ok()) + .unwrap_or(i32::MAX / 1_000) + .saturating_mul(1_000); + (w, h) + }) + .unwrap_or((i32::MAX, i32::MAX)) +} + +fn door_blocker_active(state: Option<&Value>) -> bool { + !state + .and_then(|value| value.get("doorOpen")) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn building_bound(component: &Value) -> Option<CollisionAabb> { + let x = i32::try_from(component.get("cellX")?.as_i64()?) + .ok()? + .saturating_mul(1_000); + let y = i32::try_from(component.get("cellY")?.as_i64()?) + .ok()? + .saturating_mul(1_000); + let q = component + .get("rotationQuarters") + .and_then(Value::as_i64) + .unwrap_or(0) + .rem_euclid(4); + let (left, top, right, bottom) = match q { + 0 => (x, y - 50, x + 1_000, y + 50), + 1 => (x + 950, y, x + 1_050, y + 1_000), + 2 => (x, y + 950, x + 1_000, y + 1_050), + _ => (x - 50, y, x + 50, y + 1_000), + }; + Some(CollisionAabb { + left_milli: left, + top_milli: top, + right_milli: right, + bottom_milli: bottom, + kind: CollisionKind::Building, + }) +} + +fn stable_json_hash(value: &Value) -> u64 { + fn add(hash: &mut u64, bytes: &[u8]) { + for byte in bytes { + *hash ^= u64::from(*byte); + *hash = hash.wrapping_mul(0x100_0000_01b3); + } + } + fn visit(hash: &mut u64, value: &Value) { + match value { + Value::Null => add(hash, b"n"), + Value::Bool(v) => add(hash, if *v { b"t" } else { b"f" }), + Value::Number(v) => { + if let Some(number) = v.as_i64() { + add(hash, &number.to_le_bytes()); + } else if let Some(number) = v.as_u64() { + add(hash, &number.to_le_bytes()); + } else if let Some(number) = v.as_f64() { + add(hash, &number.to_bits().to_le_bytes()); + } + } + Value::String(v) => add(hash, v.as_bytes()), + Value::Array(values) => { + for value in values { + visit(hash, value); + } + } + Value::Object(values) => { + for (key, value) in values { + add(hash, key.as_bytes()); + visit(hash, value); + } + } + } + } + let mut hash = 0xcbf2_9ce4_8422_2325; + visit(&mut hash, value); + hash +} + +fn wire_box_mesh() -> (Vec<f32>, Vec<u32>) { + let mut vertices = Vec::with_capacity(12 * 24 * 8); + let mut indices = Vec::with_capacity(12 * 36); + let edges = [ + ([0.0, -0.5, -0.5], [1.0, EDGE_THICKNESS, EDGE_THICKNESS]), + ([0.0, -0.5, 0.5], [1.0, EDGE_THICKNESS, EDGE_THICKNESS]), + ([0.0, 0.5, -0.5], [1.0, EDGE_THICKNESS, EDGE_THICKNESS]), + ([0.0, 0.5, 0.5], [1.0, EDGE_THICKNESS, EDGE_THICKNESS]), + ([-0.5, -0.5, 0.0], [EDGE_THICKNESS, EDGE_THICKNESS, 1.0]), + ([0.5, -0.5, 0.0], [EDGE_THICKNESS, EDGE_THICKNESS, 1.0]), + ([-0.5, 0.5, 0.0], [EDGE_THICKNESS, EDGE_THICKNESS, 1.0]), + ([0.5, 0.5, 0.0], [EDGE_THICKNESS, EDGE_THICKNESS, 1.0]), + ([-0.5, 0.0, -0.5], [EDGE_THICKNESS, 1.0, EDGE_THICKNESS]), + ([0.5, 0.0, -0.5], [EDGE_THICKNESS, 1.0, EDGE_THICKNESS]), + ([-0.5, 0.0, 0.5], [EDGE_THICKNESS, 1.0, EDGE_THICKNESS]), + ([0.5, 0.0, 0.5], [EDGE_THICKNESS, 1.0, EDGE_THICKNESS]), + ]; + + for (center, scale) in edges { + append_cube(&mut vertices, &mut indices, center, scale); + } + (vertices, indices) +} +fn wire_bounds_mesh( + bounds: &[CollisionAabb], + terrain: &TerrainStreamer, + expand_cells: f32, +) -> (Vec<f32>, Vec<u32>) { + let (unit_vertices, unit_indices) = wire_box_mesh(); + let mut vertices = Vec::with_capacity(unit_vertices.len() * bounds.len()); + let mut indices = Vec::with_capacity(unit_indices.len() * bounds.len()); + let expand = expand_cells * WORLD_UNITS_PER_CELL; + for bound in bounds { + let left = bound.left_milli as f32 / 1_000.0 * WORLD_UNITS_PER_CELL; + let top = bound.top_milli as f32 / 1_000.0 * WORLD_UNITS_PER_CELL; + let right = bound.right_milli as f32 / 1_000.0 * WORLD_UNITS_PER_CELL; + let bottom = bound.bottom_milli as f32 / 1_000.0 * WORLD_UNITS_PER_CELL; + let center_x = (left + right) * 0.5; + let center_z = (top + bottom) * 0.5; + let ground = terrain.height_at(center_x, center_z) + expand_cells.signum() * EDGE_THICKNESS; + let width = (right - left).max(0.001) + expand * 2.0; + let depth = (bottom - top).max(0.001) + expand * 2.0; + let base = (vertices.len() / 8) as u32; + for vertex in unit_vertices.chunks_exact(8) { + vertices.extend_from_slice(&[ + center_x + vertex[0] * width, + ground + ADULT_PAWN_HEIGHT_METERS * 0.5 + vertex[1] * ADULT_PAWN_HEIGHT_METERS, + center_z + vertex[2] * depth, + vertex[3], + vertex[4], + vertex[5], + vertex[6], + vertex[7], + ]); + } + indices.extend(unit_indices.iter().map(|index| base + *index)); + } + (vertices, indices) +} + +fn wire_cylinder_mesh(segments: usize) -> (Vec<f32>, Vec<u32>) { + let mut vertices = Vec::with_capacity(segments * 3 * 24 * 8); + let mut indices = Vec::with_capacity(segments * 3 * 36); + let tau = core::f32::consts::TAU; + for i in 0..segments { + let a = i as f32 / segments as f32 * tau; + let b = (i + 1) as f32 / segments as f32 * tau; + let (sa, ca) = a.sin_cos(); + let (sb, cb) = b.sin_cos(); + let dx = cb - ca; + let dz = sb - sa; + let length = (dx * dx + dz * dz).sqrt(); + let yaw = dz.atan2(dx); + append_rotated_cube( + &mut vertices, + &mut indices, + [(ca + cb) * 0.5, -0.5, (sa + sb) * 0.5], + [length, EDGE_THICKNESS, EDGE_THICKNESS], + yaw, + ); + append_rotated_cube( + &mut vertices, + &mut indices, + [(ca + cb) * 0.5, 0.5, (sa + sb) * 0.5], + [length, EDGE_THICKNESS, EDGE_THICKNESS], + yaw, + ); + append_cube( + &mut vertices, + &mut indices, + [ca, 0.0, sa], + [EDGE_THICKNESS, 1.0, EDGE_THICKNESS], + ); + } + (vertices, indices) +} + +fn append_cube(vertices: &mut Vec<f32>, indices: &mut Vec<u32>, center: [f32; 3], scale: [f32; 3]) { + append_rotated_cube(vertices, indices, center, scale, 0.0); +} + +fn append_rotated_cube( + vertices: &mut Vec<f32>, + indices: &mut Vec<u32>, + center: [f32; 3], + scale: [f32; 3], + yaw: f32, +) { + let (source, source_indices) = successor_engine_render::primitives::cube(); + let base = (vertices.len() / 8) as u32; + let (s, c) = yaw.sin_cos(); + for vertex in source.chunks_exact(8) { + let x = vertex[0] * scale[0]; + let y = vertex[1] * scale[1]; + let z = vertex[2] * scale[2]; + vertices.extend_from_slice(&[ + center[0] + x * c - z * s, + center[1] + y, + center[2] + x * s + z * c, + vertex[3], + vertex[4], + vertex[5], + vertex[6], + vertex[7], + ]); + } + indices.extend(source_indices.into_iter().map(|index| base + index)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn authored_bounds_match_authority_clamping_without_rotation() { + let slice = Json::parse(r#"{"areas":[{"id":"a","width":8,"height":8}],"blockedCells":[{"areaId":"a","x":1,"y":2}],"props":[{"id":"p","areaId":"a","cell":{"x":3,"y":4},"size":{"w":2,"h":2},"rotation":90,"collisionBounds":[{"xMilli":-50,"yMilli":100,"wMilli":3000,"hMilli":400}]}]}"#).unwrap(); + let bounds = authored_collision_bounds(&slice, "a"); + assert_eq!( + bounds[0], + CollisionAabb { + left_milli: 1000, + top_milli: 2000, + right_milli: 2000, + bottom_milli: 3000, + kind: CollisionKind::BlockedCell + } + ); + assert_eq!( + bounds[1], + CollisionAabb { + left_milli: 3000, + top_milli: 4100, + right_milli: 5000, + bottom_milli: 4500, + kind: CollisionKind::StaticProp + } + ); + } + + #[test] + fn building_bounds_match_authority_quarters() { + for (q, expected) in [ + (0, (2000, 2950, 3000, 3050)), + (1, (2950, 3000, 3050, 4000)), + (2, (2000, 3950, 3000, 4050)), + (3, (1950, 3000, 2050, 4000)), + ] { + let value = serde_json::json!({"cellX":2,"cellY":3,"rotationQuarters":q}); + let bound = building_bound(&value).unwrap(); + assert_eq!( + ( + bound.left_milli, + bound.top_milli, + bound.right_milli, + bound.bottom_milli + ), + expected + ); + } + } + + #[test] + fn dynamic_state_changes_door_activity_and_building_identity() { + let closed = serde_json::json!({"doorOpen":false}); + let open = serde_json::json!({"doorOpen":true}); + assert!(door_blocker_active(None)); + assert!(door_blocker_active(Some(&closed))); + assert!(!door_blocker_active(Some(&open))); + + let building = serde_json::json!({"components":[{"kind":"door","doorOpen":false}]}); + let unchanged = building.clone(); + let changed = serde_json::json!({"components":[{"kind":"door","doorOpen":true}]}); + assert_eq!(stable_json_hash(&building), stable_json_hash(&unchanged)); + assert_ne!(stable_json_hash(&building), stable_json_hash(&changed)); + } + + #[test] + fn authored_bounds_rebuild_for_only_the_requested_area() { + let slice = Json::parse( + r#"{"areas":[{"id":"a","width":4,"height":4},{"id":"b","width":4,"height":4}],"blockedCells":[{"areaId":"a","x":1,"y":1},{"areaId":"b","x":2,"y":2}],"props":[]}"#, + ) + .unwrap(); + let a = authored_collision_bounds(&slice, "a"); + let b = authored_collision_bounds(&slice, "b"); + assert_eq!(a.len(), 1); + assert_eq!(b.len(), 1); + assert_eq!(a[0].left_milli, 1_000); + assert_eq!(b[0].left_milli, 2_000); + } + + #[test] + fn wire_meshes_are_triangle_complete() { + let (box_vertices, box_indices) = wire_box_mesh(); + let (circle_vertices, circle_indices) = wire_cylinder_mesh(20); + assert_eq!(box_vertices.len(), 12 * 24 * 8); + assert_eq!(box_indices.len(), 12 * 36); + assert_eq!(circle_vertices.len(), 20 * 3 * 24 * 8); + assert_eq!(circle_indices.len(), 20 * 3 * 36); + } +} diff --git a/client-rust/source/app/src/world/cutaway.rs b/client-rust/source/app/src/world/cutaway.rs new file mode 100644 index 00000000..de821512 --- /dev/null +++ b/client-rust/source/app/src/world/cutaway.rs @@ -0,0 +1,220 @@ +//! Enterable-prop cutaway state machine — a verbatim port of the pure machine +//! in `client-3d/src/render/props.ts` (lines 228-334). Advances only on new +//! authority snapshot ticks, with inner/outer hysteresis and a two-snapshot +//! dwell before any enter/exit flip. All coordinates are milli-cells. + +/// Entering requires the point at least this far INSIDE a region. +pub const INNER_INSET_MILLI: f64 = 250.0; +/// Exiting requires the point at least this far OUTSIDE every region. +pub const OUTER_EXPAND_MILLI: f64 = 250.0; +/// Consecutive agreeing snapshots before an enter/exit flip commits. +pub const DWELL_SNAPSHOTS: u32 = 2; + +/// An axis-aligned interior region in milli-cells (min corner + size). +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct RegionMilli { + pub x_milli: f64, + pub y_milli: f64, + pub w_milli: f64, + pub h_milli: f64, +} + +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct CutawayState { + pub inside: bool, + pub dwell: u32, + pub last_sampled_tick: f64, + /// Fade progress 0 (exterior) .. 1 (interior). + pub t: f64, +} + +impl Default for CutawayState { + fn default() -> Self { + CutawayState { + inside: false, + dwell: 0, + last_sampled_tick: f64::NEG_INFINITY, + t: 0.0, + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum CutawayPhase { + Exterior, + Entering, + Interior, + Exiting, +} + +/// Positive margin expands the region; negative insets it (clamped so a narrow +/// region keeps a core). +fn region_contains(region: &RegionMilli, x: f64, z: f64, margin: f64) -> bool { + let mx = margin.max(1.0 - region.w_milli / 2.0); + let mz = margin.max(1.0 - region.h_milli / 2.0); + x >= region.x_milli - mx + && x <= region.x_milli + region.w_milli + mx + && z >= region.y_milli - mz + && z <= region.y_milli + region.h_milli + mz +} + +pub fn inside_inner(regions: &[RegionMilli], x: f64, z: f64) -> bool { + regions + .iter() + .any(|r| region_contains(r, x, z, -INNER_INSET_MILLI)) +} + +pub fn inside_outer(regions: &[RegionMilli], x: f64, z: f64) -> bool { + regions + .iter() + .any(|r| region_contains(r, x, z, OUTER_EXPAND_MILLI)) +} + +/// Advance the enter/exit decision — ONLY on a new snapshot tick. +pub fn sample( + state: &mut CutawayState, + snapshot_tick: f64, + regions: &[RegionMilli], + x: f64, + z: f64, +) { + if snapshot_tick == state.last_sampled_tick { + return; + } + state.last_sampled_tick = snapshot_tick; + let wants_flip = if state.inside { + !inside_outer(regions, x, z) + } else { + inside_inner(regions, x, z) + }; + if !wants_flip { + state.dwell = 0; + return; + } + state.dwell += 1; + if state.dwell >= DWELL_SNAPSHOTS { + state.inside = !state.inside; + state.dwell = 0; + } +} + +pub fn phase(state: &CutawayState) -> CutawayPhase { + if state.inside { + if state.t >= 1.0 { + CutawayPhase::Interior + } else { + CutawayPhase::Entering + } + } else if state.t <= 0.0 { + CutawayPhase::Exterior + } else { + CutawayPhase::Exiting + } +} + +/// Advance the fade toward the current decision; returns the eased hide amount +/// (0 = walls visible, 1 = hidden). Reduced motion snaps after the same decision. +pub fn advance_fade( + state: &mut CutawayState, + dt_seconds: f64, + fade_seconds: f64, + reduced_motion: bool, +) -> f64 { + let target = if state.inside { 1.0 } else { 0.0 }; + if reduced_motion { + state.t = target; + } else { + let step_seconds = if dt_seconds.is_finite() { + dt_seconds.clamp(0.0, 0.1) + } else { + 0.0 + }; + let step = step_seconds / fade_seconds.max(0.01); + state.t = if state.t < target { + (state.t + step).min(target) + } else { + (state.t - step).max(target) + }; + } + state.t * state.t * (3.0 - 2.0 * state.t) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn region() -> Vec<RegionMilli> { + // A 4000×4000 milli-cell (4×4 cell) room at origin. + vec![RegionMilli { + x_milli: 0.0, + y_milli: 0.0, + w_milli: 4000.0, + h_milli: 4000.0, + }] + } + + #[test] + fn enters_after_dwell_when_inside_inner() { + let regs = region(); + let mut s = CutawayState::default(); + // Deep inside (well past inner inset). First tick arms dwell, second flips. + sample(&mut s, 1.0, ®s, 2000.0, 2000.0); + assert!(!s.inside); + assert_eq!(s.dwell, 1); + sample(&mut s, 2.0, ®s, 2000.0, 2000.0); + assert!(s.inside); + } + + #[test] + fn same_tick_is_ignored() { + let regs = region(); + let mut s = CutawayState::default(); + sample(&mut s, 5.0, ®s, 2000.0, 2000.0); + let after = s; + sample(&mut s, 5.0, ®s, 2000.0, 2000.0); // same tick → no change + assert_eq!(s, after); + } + + #[test] + fn holds_between_thresholds() { + let regs = region(); + let mut s = CutawayState { + inside: true, + ..Default::default() + }; + // Point just outside the room but within the outer-expand band: inside + // actor should NOT want to flip out. + sample(&mut s, 1.0, ®s, 4100.0, 2000.0); + assert!(s.inside, "still inside outer band → holds"); + } + + #[test] + fn exits_after_leaving_outer() { + let regs = region(); + let mut s = CutawayState { + inside: true, + ..Default::default() + }; + // Far outside the outer band. + sample(&mut s, 1.0, ®s, 100000.0, 100000.0); + sample(&mut s, 2.0, ®s, 100000.0, 100000.0); + assert!(!s.inside); + } + + #[test] + fn fade_tween_and_snap() { + let mut s = CutawayState { + inside: true, + ..Default::default() + }; + let a = advance_fade(&mut s, 0.05, 0.2, false); + assert!(a > 0.0 && s.t > 0.0 && s.t < 1.0); + // Reduced motion snaps to the target. + let mut s2 = CutawayState { + inside: true, + ..Default::default() + }; + advance_fade(&mut s2, 0.0, 0.2, true); + assert_eq!(s2.t, 1.0); + } +} diff --git a/client-rust/source/app/src/world/environs.rs b/client-rust/source/app/src/world/environs.rs new file mode 100644 index 00000000..3a525150 --- /dev/null +++ b/client-rust/source/app/src/world/environs.rs @@ -0,0 +1,354 @@ +//! Streamed environment driver: `worldClock` + `weather` sections → sun, +//! fog, color grade, and precipitation. Ports the time-of-day pipeline from +//! `client-3d` (`environment/index.ts` grade anchors via +//! `engine_render::environment`, `post.ts` zoom-relative fog window) and the +//! area-weather selection from `render/weather`. Nothing here is hard-coded +//! to a fixture: before the first clock/weather packet the scene renders the +//! documented pre-stream default (noon, clear air), and every later frame is +//! derived from the accepted sections. + +use serde_json::Value; +use successor_engine_render::environment::{self, EnvSample}; +use successor_engine_render::weather::WeatherKind; + +/// `config.camera.pitchDegrees` (fog depth solves against the iso pitch). +const PITCH_DEG: f32 = 60.0; +/// `config.camera` distance in world units. +const CAMERA_DISTANCE: f32 = 96.0; +/// `config.renderer.fog.nearT` / `farT` — the fog window starts past the +/// visible top edge so in-frame air stays uniform. +const FOG_NEAR_T: f32 = 1.05; +const FOG_FAR_T: f32 = 1.45; + +/// Pre-stream default: noon. Documented, deterministic, replaced by the first +/// accepted `worldClock`. +const DEFAULT_MINUTE: f32 = 720.0; + +/// Weather phase → presentation intensity scale (server phases: +/// idle/warning/active/decay). +fn phase_scale(phase: &str) -> f32 { + match phase { + "active" => 1.0, + "warning" => 0.4, + "decay" => 0.5, + _ => 0.0, + } +} + +/// Case-insensitive substring check against a lowercase ASCII literal. +/// Allocation-free: runs in the per-frame weather selection path. +fn contains_ci(haystack: &str, needle: &str) -> bool { + debug_assert!(needle.bytes().all(|b| !b.is_ascii_uppercase())); + if needle.len() > haystack.len() { + return false; + } + haystack + .as_bytes() + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle.as_bytes())) +} + +/// Map a streamed `eventType` to the renderer's precipitation kind. +fn kind_for_event(event_type: &str) -> WeatherKind { + let has = |needle: &str| contains_ci(event_type, needle); + if has("rain") || has("storm") && has("thunder") { + WeatherKind::Rain + } else if has("dust") || has("sand") || has("storm") { + WeatherKind::DustStorm + } else { + WeatherKind::Clear + } +} + +/// The active weather selection for the player's area/position. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ActiveWeather { + pub kind: WeatherKind, + /// Presentation strength 0..1 (streamed intensity × phase scale × + /// center-distance falloff). + pub strength: f32, +} + +impl Default for ActiveWeather { + fn default() -> Self { + Self { + kind: WeatherKind::Clear, + strength: 0.0, + } + } +} + +/// Streamed clock + weather state, smoothed for presentation. +pub struct Environs { + minute: f32, + have_clock: bool, + active: ActiveWeather, + /// Smoothed grade blend (avoids pops on sparse clock packets). + smoothed: Option<EnvSample>, +} + +impl Default for Environs { + fn default() -> Self { + Self::new() + } +} + +impl Environs { + pub fn new() -> Self { + Self { + minute: DEFAULT_MINUTE, + have_clock: false, + active: ActiveWeather::default(), + smoothed: None, + } + } + + pub fn minute_of_day(&self) -> f32 { + self.minute + } + + pub fn have_clock(&self) -> bool { + self.have_clock + } + + pub fn active_weather(&self) -> ActiveWeather { + self.active + } + + /// Adopt the streamed `worldClock` section (`minuteOfDay`). Malformed or + /// non-finite values leave the prior accepted minute untouched. + pub fn apply_clock(&mut self, world_clock: Option<&Value>) { + let Some(clock) = world_clock else { return }; + let Some(minute) = clock.get("minuteOfDay").and_then(Value::as_f64) else { + return; + }; + let minute = minute as f32; + if !minute.is_finite() || !(0.0..environment::DAY_MINUTES).contains(&minute) { + return; + } + self.minute = minute; + self.have_clock = true; + } + + /// Select the strongest weather event covering the player in the active + /// area. Events outside the area or with a zero phase scale are ignored; + /// inside `radiusCells` the strength falls off toward the rim. + pub fn apply_weather(&mut self, weather: &[Value], area_id: &str, player: (f32, f32)) { + let mut best = ActiveWeather::default(); + for event in weather { + if event.get("areaId").and_then(Value::as_str) != Some(area_id) { + continue; + } + let phase = event.get("phase").and_then(Value::as_str).unwrap_or("idle"); + let scale = phase_scale(phase); + if scale <= 0.0 { + continue; + } + let event_type = event.get("eventType").and_then(Value::as_str).unwrap_or(""); + let kind = kind_for_event(event_type); + if kind == WeatherKind::Clear { + continue; + } + let intensity = event + .get("intensity") + .and_then(Value::as_f64) + .unwrap_or(0.0) as f32; + if !(0.0..=1.0).contains(&intensity) { + continue; // malformed → skip without adopting + } + let cx = event.get("centerX").and_then(Value::as_f64).unwrap_or(0.0) as f32; + let cy = event.get("centerY").and_then(Value::as_f64).unwrap_or(0.0) as f32; + let radius = event + .get("radiusCells") + .and_then(Value::as_f64) + .unwrap_or(0.0) as f32; + let falloff = if radius > 0.0 { + let d = ((player.0 - cx).powi(2) + (player.1 - cy).powi(2)).sqrt(); + if d >= radius * 1.25 { + 0.0 + } else { + // Full strength inside the core, easing off past the rim. + (1.0 - ((d / radius) - 0.6).max(0.0) / 0.65).clamp(0.0, 1.0) + } + } else { + 1.0 // area-wide event + }; + let strength = intensity * scale * falloff; + if strength > best.strength { + best = ActiveWeather { kind, strength }; + } + } + self.active = best; + } + + /// The environment sample for this frame, exponentially smoothed so + /// sparse clock packets never pop the grade. Weather darkens/desaturates + /// on top of the time-of-day grade (storm reading from the reference). + pub fn sample(&mut self, dt: f32) -> EnvSample { + let mut target = environment::sample(self.minute); + let w = self.active.strength; + if w > 0.0 { + match self.active.kind { + WeatherKind::DustStorm => { + // Warm dust: pull fog + grade toward sand, flatten sun. + let dust = [0.79, 0.65, 0.46]; + target.fog = lerp3(target.fog, dust, 0.55 * w); + target.desaturate = (target.desaturate + 0.18 * w).min(1.0); + target.scene_darken = (target.scene_darken + 0.10 * w).min(1.0); + target.sun_color = lerp3(target.sun_color, dust, 0.4 * w); + } + WeatherKind::Rain => { + let slate = [0.45, 0.5, 0.56]; + target.fog = lerp3(target.fog, slate, 0.5 * w); + target.desaturate = (target.desaturate + 0.25 * w).min(1.0); + target.scene_darken = (target.scene_darken + 0.18 * w).min(1.0); + target.sun_color = lerp3(target.sun_color, slate, 0.5 * w); + } + WeatherKind::Clear => {} + } + } + let alpha = 1.0 - (-dt.max(0.0) * 2.5).exp(); + match &mut self.smoothed { + None => { + self.smoothed = Some(target); + target + } + Some(current) => { + blend_env(current, &target, alpha); + *current + } + } + } + + /// Fog near/far for the current zoom, from the reference contract: + /// `depth = cameraDistance + t · (halfFrustumHeight / tan(pitch))` with + /// the config nearT/farT window (far clamped past near). + pub fn fog_range(&self, half_frustum_height: f32) -> (f32, f32) { + let rise = half_frustum_height / PITCH_DEG.to_radians().tan(); + // Dust/rain pulls the melt closer so heavy weather reads as air. + let squeeze = 1.0 - 0.35 * self.active.strength; + let near = CAMERA_DISTANCE + FOG_NEAR_T * rise * squeeze; + let far = (CAMERA_DISTANCE + FOG_FAR_T * rise * squeeze).max(near + 1.0); + (near, far) + } +} + +fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { + [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + ] +} + +fn blend_env(current: &mut EnvSample, target: &EnvSample, alpha: f32) { + current.sun_dir = lerp3(current.sun_dir, target.sun_dir, alpha); + current.sun_color = lerp3(current.sun_color, target.sun_color, alpha); + current.sun_elevation01 += (target.sun_elevation01 - current.sun_elevation01) * alpha; + current.is_day = target.is_day; + current.fog = lerp3(current.fog, target.fog, alpha); + current.bone_tint = lerp3(current.bone_tint, target.bone_tint, alpha); + current.desaturate += (target.desaturate - current.desaturate) * alpha; + current.scene_darken += (target.scene_darken - current.scene_darken) * alpha; + current.black_lift += (target.black_lift - current.black_lift) * alpha; + current.bloom += (target.bloom - current.bloom) * alpha; +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn defaults_to_noon_until_a_clock_arrives() { + let mut env = Environs::new(); + assert!(!env.have_clock()); + assert_eq!(env.minute_of_day(), 720.0); + env.apply_clock(Some(&json!({ "minuteOfDay": 380.0 }))); + assert!(env.have_clock()); + assert_eq!(env.minute_of_day(), 380.0); + } + + #[test] + fn malformed_clock_keeps_prior_accepted_minute() { + let mut env = Environs::new(); + env.apply_clock(Some(&json!({ "minuteOfDay": 100.0 }))); + env.apply_clock(Some(&json!({ "minuteOfDay": "noon" }))); + env.apply_clock(Some(&json!({ "minuteOfDay": 999999.0 }))); + env.apply_clock(None); + assert_eq!(env.minute_of_day(), 100.0); + } + + #[test] + fn weather_selection_is_area_scoped_and_phase_gated() { + let mut env = Environs::new(); + let events = vec![ + json!({ + "areaId": "elsewhere", "eventType": "duststorm", "phase": "active", + "centerX": 0.0, "centerY": 0.0, "radiusCells": 0.0, "intensity": 1.0, + }), + json!({ + "areaId": "here", "eventType": "duststorm", "phase": "idle", + "centerX": 0.0, "centerY": 0.0, "radiusCells": 0.0, "intensity": 1.0, + }), + ]; + env.apply_weather(&events, "here", (0.0, 0.0)); + assert_eq!( + env.active_weather().strength, + 0.0, + "wrong area / idle ignored" + ); + + let active = vec![json!({ + "areaId": "here", "eventType": "duststorm", "phase": "active", + "centerX": 0.0, "centerY": 0.0, "radiusCells": 0.0, "intensity": 0.8, + })]; + env.apply_weather(&active, "here", (0.0, 0.0)); + let w = env.active_weather(); + assert_eq!(w.kind, WeatherKind::DustStorm); + assert!((w.strength - 0.8).abs() < 1e-5); + } + + #[test] + fn radius_falloff_zeroes_far_outside_the_cell() { + let mut env = Environs::new(); + let events = vec![json!({ + "areaId": "a", "eventType": "rain", "phase": "active", + "centerX": 100.0, "centerY": 100.0, "radiusCells": 20.0, "intensity": 1.0, + })]; + env.apply_weather(&events, "a", (100.0, 100.0)); + assert!(env.active_weather().strength > 0.99, "core full strength"); + env.apply_weather(&events, "a", (100.0, 160.0)); + assert_eq!(env.active_weather().strength, 0.0, "outside 1.25r"); + } + + #[test] + fn dust_pulls_grade_and_fog_toward_sand() { + let mut env = Environs::new(); + let clear = env.sample(10.0); // effectively snapped + let mut storm = Environs::new(); + storm.apply_weather( + &[json!({ + "areaId": "a", "eventType": "duststorm", "phase": "active", + "centerX": 0.0, "centerY": 0.0, "radiusCells": 0.0, "intensity": 1.0, + })], + "a", + (0.0, 0.0), + ); + let dusty = storm.sample(10.0); + assert!(dusty.desaturate > clear.desaturate); + assert!(dusty.fog != clear.fog); + } + + #[test] + fn fog_window_sits_past_the_visible_frame() { + let env = Environs::new(); + let half_h = 12.5 / 2.0; // 100% zoom + let (near, far) = env.fog_range(half_h); + // Visible top edge depth is distance + halfH/tan(pitch); the window + // must start past it (nearT > 1) and keep far beyond near. + let top_edge = 96.0 + half_h / 60.0f32.to_radians().tan(); + assert!(near > top_edge); + assert!(far > near); + } +} diff --git a/client-rust/source/app/src/world/flora.rs b/client-rust/source/app/src/world/flora.rs new file mode 100644 index 00000000..477871a8 --- /dev/null +++ b/client-rust/source/app/src/world/flora.rs @@ -0,0 +1,364 @@ +//! Deterministic flora and small world-object scatter over terrain. +//! Produces instance transforms for the renderer's instanced mesh path. + +use successor_engine_core::math::{vec3, Mat4, Quat}; +use successor_engine_render::primitives; + +use super::terrain::Biome; + +/// A single placed flora instance. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FloraInstance { + pub pos: [f32; 3], + pub yaw: f32, + pub scale: f32, + pub kind: u8, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum DetailKind { + Rock = 0, + GroundCover = 1, + Shrub = 2, +} + +impl DetailKind { + pub fn from_hash(value: u8) -> Self { + if value < 64 { + Self::Rock + } else if value < 204 { + Self::GroundCover + } else { + Self::Shrub + } + } +} + +/// Computes the 32-bit FNV-1a hash of a byte slice. +fn fnv1a_32(data: &[u8]) -> u32 { + let mut hash = 0x811c9dc5u32; + for &byte in data { + hash ^= byte as u32; + hash = hash.wrapping_mul(0x01000193u32); + } + hash +} + +/// Generates a deterministic float in `[0.0, 1.0]` using FNV-1a. +fn hash_to_float(seed: i32, cx: i32, cz: i32, index: i32, salt: u32) -> f32 { + let mut bytes = [0u8; 20]; + bytes[0..4].copy_from_slice(&seed.to_le_bytes()); + bytes[4..8].copy_from_slice(&cx.to_le_bytes()); + bytes[8..12].copy_from_slice(&cz.to_le_bytes()); + bytes[12..16].copy_from_slice(&index.to_le_bytes()); + bytes[16..20].copy_from_slice(&salt.to_le_bytes()); + let h = fnv1a_32(&bytes); + h as f32 / 4294967295.0 +} + +/// Deterministically scatters flora instances over an area. +/// +/// * `seed` - World seed. +/// * `area_min` - Minimum coordinate boundary `[x, z]`. +/// * `area_max` - Maximum coordinate boundary `[x, z]`. +/// * `density` - Controls average flora instances per cell. +/// * `is_blocked` - Closure to skip placements on blocked regions. +pub fn scatter( + seed: i32, + area_min: [f32; 2], + area_max: [f32; 2], + density: f32, + is_blocked: impl Fn([f32; 2]) -> bool, +) -> Vec<FloraInstance> { + let mut instances = Vec::new(); + scatter_into( + &mut instances, + seed, + area_min, + area_max, + density, + is_blocked, + ); + instances +} + +/// Allocation-stable scatter variant used by pooled terrain chunks. +pub fn scatter_into( + instances: &mut Vec<FloraInstance>, + seed: i32, + area_min: [f32; 2], + area_max: [f32; 2], + density: f32, + is_blocked: impl Fn([f32; 2]) -> bool, +) { + instances.clear(); + if density <= 0.0 { + return; + } + + let x_min = area_min[0].floor() as i32; + let x_max = area_max[0].floor() as i32; + let z_min = area_min[1].floor() as i32; + let z_max = area_max[1].floor() as i32; + if x_min > x_max || z_min > z_max { + return; + } + + for cx in x_min..=x_max { + for cz in z_min..=z_max { + let count_roll = hash_to_float(seed, cx, cz, -1, 0); + let base_count = density.floor() as i32; + let fract = density - base_count as f32; + let count = if count_roll < fract { + base_count + 1 + } else { + base_count + }; + for i in 0..count { + let px = cx as f32 + hash_to_float(seed, cx, cz, i, 1); + let pz = cz as f32 + hash_to_float(seed, cx, cz, i, 2); + if px < area_min[0] + || px > area_max[0] + || pz < area_min[1] + || pz > area_max[1] + || is_blocked([px, pz]) + { + continue; + } + instances.push(FloraInstance { + pos: [px, 0.0, pz], + yaw: hash_to_float(seed, cx, cz, i, 3) * 2.0 * std::f32::consts::PI, + scale: 0.5 + hash_to_float(seed, cx, cz, i, 4), + kind: (hash_to_float(seed, cx, cz, i, 5) * 255.0) as u8, + }); + } + } + } +} + +pub fn biome_density(biome: Biome) -> f32 { + match biome { + Biome::Desert => 0.021, + Biome::Forest => 0.043, + } +} + +/// Converts a flora instance's TRS components into a column-major 4x4 matrix array. +pub fn instance_matrix(f: &FloraInstance) -> [f32; 16] { + let t = vec3(f.pos[0], f.pos[1], f.pos[2]); + let r = Quat::from_yaw(f.yaw); + let s = vec3(f.scale, f.scale, f.scale); + Mat4::from_trs(t, r, s).to_cols_array() +} + +/// Per-kind nonuniform transforms keep one tiny shared mesh from reading as +/// stamped clones. Desert ground cover is scrub; forest ground cover is grass. +pub fn detail_instance_matrix(f: &FloraInstance, kind: DetailKind, biome: Biome) -> [f32; 16] { + let t = vec3(f.pos[0], f.pos[1], f.pos[2]); + let r = Quat::from_yaw(f.yaw); + let s = match (kind, biome) { + (DetailKind::Rock, _) => vec3(0.55 * f.scale, 0.34 * f.scale, 0.48 * f.scale), + (DetailKind::GroundCover, Biome::Desert) => { + vec3(0.42 * f.scale, 0.48 * f.scale, 0.42 * f.scale) + } + (DetailKind::GroundCover, Biome::Forest) => { + vec3(0.22 * f.scale, 0.72 * f.scale, 0.22 * f.scale) + } + (DetailKind::Shrub, Biome::Desert) => vec3(1.02 * f.scale, 0.92 * f.scale, 1.02 * f.scale), + (DetailKind::Shrub, Biome::Forest) => vec3(0.78 * f.scale, 0.90 * f.scale, 0.78 * f.scale), + }; + Mat4::from_trs(t, r, s).to_cols_array() +} + +/// Faceted low-poly boulder shared by every rock instance. +pub fn rock_mesh() -> (Vec<f32>, Vec<u32>) { + let (mut vertices, indices) = primitives::capsule(0.60, 0.92, 7, 2); + for (index, vertex) in vertices.chunks_exact_mut(8).enumerate() { + let warp_x = 0.82 + (index.wrapping_mul(17) % 7) as f32 * 0.045; + let warp_z = 0.84 + (index.wrapping_mul(11) % 5) as f32 * 0.055; + vertex[0] *= warp_x; + vertex[1] *= 0.72; + vertex[2] *= warp_z; + } + (vertices, indices) +} + +/// Three crossed tapered blades. Double-sided materials make the silhouette +/// stable from any view without alpha textures or overdraw-heavy billboards. +pub fn tuft_mesh() -> (Vec<f32>, Vec<u32>) { + let mut vertices = Vec::with_capacity(12 * 8); + let mut indices = Vec::with_capacity(18); + for blade in 0..3u32 { + let angle = blade as f32 * std::f32::consts::PI / 3.0; + let right = [angle.cos() * 0.22, 0.0, angle.sin() * 0.22]; + let normal = [-angle.sin(), 0.0, angle.cos()]; + let base = vertices.len() as u32 / 8; + for (x, y, u, v) in [ + (-1.0, 0.0, 0.0, 0.0), + (1.0, 0.0, 1.0, 0.0), + (0.38, 1.0, 1.0, 1.0), + (-0.38, 1.0, 0.0, 1.0), + ] { + vertices.extend_from_slice(&[ + right[0] * x, + y, + right[2] * x, + normal[0], + normal[1], + normal[2], + u, + v, + ]); + } + indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); + } + (vertices, indices) +} + +/// Three offset faceted clumps form bushes and dry tumbleweed silhouettes. +pub fn shrub_mesh() -> (Vec<f32>, Vec<u32>) { + let mut vertices = Vec::new(); + let mut indices = Vec::new(); + for (offset, scale) in [ + ([-0.28, 0.16, -0.04], [0.78, 0.72, 0.72]), + ([0.25, 0.12, 0.08], [0.70, 0.66, 0.78]), + ([0.0, 0.38, -0.10], [0.82, 0.74, 0.70]), + ] { + let (mut clump_vertices, clump_indices) = primitives::capsule(0.52, 0.90, 6, 2); + let base = vertices.len() as u32 / 8; + for vertex in clump_vertices.chunks_exact_mut(8) { + vertex[0] = vertex[0] * scale[0] + offset[0]; + vertex[1] = vertex[1] * scale[1] + offset[1]; + vertex[2] = vertex[2] * scale[2] + offset[2]; + } + vertices.extend_from_slice(&clump_vertices); + indices.extend(clump_indices.into_iter().map(|index| base + index)); + } + (vertices, indices) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_determinism() { + let seed = 12345; + let area_min = [0.0, 0.0]; + let area_max = [10.0, 10.0]; + let density = 1.5; + let is_blocked = |_| false; + + let res1 = scatter(seed, area_min, area_max, density, is_blocked); + let res2 = scatter(seed, area_min, area_max, density, is_blocked); + + assert!(!res1.is_empty()); + assert_eq!(res1, res2); + + // Different seed should result in different output + let res_diff_seed = scatter(54321, area_min, area_max, density, is_blocked); + assert_ne!(res1, res_diff_seed); + } + + #[test] + fn test_density_scaling() { + let seed = 42; + let area_min = [0.0, 0.0]; + let area_max = [10.0, 10.0]; + let is_blocked = |_| false; + + let res_low = scatter(seed, area_min, area_max, 0.5, is_blocked); + let res_high = scatter(seed, area_min, area_max, 2.5, is_blocked); + + assert!(res_high.len() > res_low.len()); + } + + #[test] + fn test_exclusion() { + let seed = 999; + let area_min = [0.0, 0.0]; + let area_max = [10.0, 10.0]; + let density = 2.0; + + // Block elements where x > 5.0 + let is_blocked = |pos: [f32; 2]| pos[0] > 5.0; + + let res = scatter(seed, area_min, area_max, density, is_blocked); + assert!(!res.is_empty()); + + for inst in &res { + assert!( + inst.pos[0] <= 5.0, + "Found inst in blocked region: {:?}", + inst.pos + ); + } + + // Without blocking, some elements should be in x > 5.0 + let res_unblocked = scatter(seed, area_min, area_max, density, |_| false); + let has_some_above_5 = res_unblocked.iter().any(|inst| inst.pos[0] > 5.0); + assert!( + has_some_above_5, + "Expected some unblocked instances above x=5.0" + ); + } + + #[test] + fn biome_scatter_fits_fixed_batches_and_contains_every_kind() { + for biome in [Biome::Desert, Biome::Forest] { + let mut peak = [0usize; 3]; + for seed in 0..32 { + let instances = + scatter(seed, [0.0, 0.0], [64.0, 64.0], biome_density(biome), |_| { + false + }); + let mut counts = [0usize; 3]; + for instance in instances { + counts[DetailKind::from_hash(instance.kind) as usize] += 1; + } + for kind in 0..3 { + peak[kind] = peak[kind].max(counts[kind]); + assert!(counts[kind] <= 128); + } + } + assert!(peak.iter().all(|count| *count > 0)); + } + } + + #[test] + fn procedural_detail_meshes_are_indexed_and_finite() { + for (vertices, indices) in [rock_mesh(), tuft_mesh(), shrub_mesh()] { + assert!(!vertices.is_empty()); + assert!(!indices.is_empty()); + assert_eq!(vertices.len() % 8, 0); + assert!(vertices.iter().all(|value| value.is_finite())); + assert!(indices + .iter() + .all(|index| *index < (vertices.len() / 8) as u32)); + } + } + + #[test] + fn test_instance_matrix() { + let inst = FloraInstance { + pos: [4.5, -1.2, 9.1], + yaw: 0.0, + scale: 2.5, + kind: 3, + }; + + let matrix = instance_matrix(&inst); + + // Column-major layout translation is in the last column + assert_eq!(matrix[12], 4.5); + assert_eq!(matrix[13], -1.2); + assert_eq!(matrix[14], 9.1); + assert_eq!(matrix[15], 1.0); + + // Scale should be applied along the diagonal since yaw = 0 + assert_eq!(matrix[0], 2.5); + assert_eq!(matrix[5], 2.5); + assert_eq!(matrix[10], 2.5); + } +} diff --git a/client-rust/source/app/src/world/mod.rs b/client-rust/source/app/src/world/mod.rs new file mode 100644 index 00000000..a374528f --- /dev/null +++ b/client-rust/source/app/src/world/mod.rs @@ -0,0 +1,33 @@ +//! World rendering: procedural terrain, prop placement, and the chunk streamer +//! that turns the shared map fixture into GPU geometry. + +/// Canonical spatial contract shared by simulation, terrain, props, pawns, and +/// cameras: one authority cell is one renderer world unit and represents one +/// metre. Asset loaders normalize authored geometry into this scale instead of +/// carrying source-package units into gameplay. +pub const WORLD_UNITS_PER_CELL: f32 = 1.0; +pub const ADULT_PAWN_HEIGHT_METERS: f32 = 1.8; +pub const TERRAIN_MATERIAL_METERS_PER_TILE: f32 = 1.25; +pub const FOLLOW_CAMERA_HEIGHT_METERS: f32 = 14.0; +pub const FOLLOW_CAMERA_BACK_METERS: f32 = 21.0; + +pub mod area; +pub mod camera; +pub mod chunks; +pub mod collision_debug; +pub mod cutaway; +pub mod environs; +pub mod flora; +pub mod movement_collision; +pub mod picking; +pub mod props; +pub mod streamed; +pub mod terrain; +pub mod terrain_material; + +const _: () = { + assert!(WORLD_UNITS_PER_CELL == 1.0); + assert!(ADULT_PAWN_HEIGHT_METERS == 1.8); + assert!(TERRAIN_MATERIAL_METERS_PER_TILE >= WORLD_UNITS_PER_CELL); + assert!(TERRAIN_MATERIAL_METERS_PER_TILE <= WORLD_UNITS_PER_CELL * 2.0); +}; diff --git a/client-rust/source/app/src/world/movement_collision.rs b/client-rust/source/app/src/world/movement_collision.rs new file mode 100644 index 00000000..465e0e2f --- /dev/null +++ b/client-rust/source/app/src/world/movement_collision.rs @@ -0,0 +1,97 @@ +use successor_engine_core::json::Json; +use successor_movement::{ + resolve_circle_move_milli, CircleAabb, CirclePoint, CIRCLE_COLLISION_RADIUS_MILLI, +}; + +use crate::world::collision_debug::{authored_collision_bounds, CollisionDebugOverlay}; + +#[derive(Default)] +pub struct MovementCollisionWorld { + blockers: Vec<CircleAabb>, +} + +impl MovementCollisionWorld { + pub fn rebuild(&mut self, slice: &Json, area_id: &str, dynamic: &CollisionDebugOverlay) { + self.blockers.clear(); + self.blockers.extend( + authored_collision_bounds(slice, area_id) + .into_iter() + .map(|bound| { + CircleAabb::new( + bound.left_milli, + bound.top_milli, + bound.right_milli, + bound.bottom_milli, + ) + }), + ); + dynamic.append_active_dynamic_bounds(&mut self.blockers); + } + + pub fn resolve_anchor_move( + &self, + anchor_x: f32, + anchor_y: f32, + delta_x: f32, + delta_y: f32, + ) -> (f32, f32) { + let center = CirclePoint { + x: (anchor_x * 1_000.0).round() as i32 + 500, + y: (anchor_y * 1_000.0).round() as i32 + 500, + }; + let resolved = resolve_circle_move_milli( + center, + (delta_x * 1_000.0).round() as i32, + (delta_y * 1_000.0).round() as i32, + CIRCLE_COLLISION_RADIUS_MILLI, + &self.blockers, + ); + ( + (resolved.x - 500) as f32 / 1_000.0, + (resolved.y - 500) as f32 / 1_000.0, + ) + } + pub fn anchor_clear(&self, anchor_x: f32, anchor_y: f32) -> bool { + let center = CirclePoint { + x: (anchor_x * 1_000.0).round() as i32 + 500, + y: (anchor_y * 1_000.0).round() as i32 + 500, + }; + !self.blockers.iter().copied().any(|blocker| { + successor_movement::circle_intersects_aabb( + center, + CIRCLE_COLLISION_RADIUS_MILLI, + blocker, + ) + }) + } + + pub fn blocker_count(&self) -> usize { + self.blockers.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn doorway_clearance_uses_player_radius_not_cell_occupancy() { + let world = MovementCollisionWorld { + blockers: vec![ + CircleAabb::new(0, 0, 1_100, 2_000), + CircleAabb::new(1_698, 0, 3_000, 2_000), + ], + }; + assert!( + !world.anchor_clear(0.9, 0.5), + "598-milli doorway is too narrow for a 600-milli diameter actor" + ); + let clear_world = MovementCollisionWorld { + blockers: vec![ + CircleAabb::new(0, 0, 1_000, 2_000), + CircleAabb::new(1_800, 0, 3_000, 2_000), + ], + }; + assert!(clear_world.anchor_clear(0.9, 0.5)); + } +} diff --git a/client-rust/source/app/src/world/picking.rs b/client-rust/source/app/src/world/picking.rs new file mode 100644 index 00000000..07f9a2b9 --- /dev/null +++ b/client-rust/source/app/src/world/picking.rs @@ -0,0 +1,150 @@ +//! Screen-space picking — port of `client-3d/src/render/picking.ts`'s core: +//! screen→NDC→ground unprojection (via `IsoCamera::ground_pick`) plus ray/AABB +//! prop hit-testing and nearest-actor selection on the ground plane. CPU-side, +//! no per-frame allocation (callers pass their own candidate slices). + +use successor_engine_core::math::Vec3; + +use super::camera::IsoCamera; + +/// Pixel (top-left origin) → NDC (`-1..1`, y up). +pub fn screen_to_ndc(px: f32, py: f32, w: f32, h: f32) -> (f32, f32) { + ((px / w) * 2.0 - 1.0, -((py / h) * 2.0 - 1.0)) +} + +/// Axis-aligned box in world space (prop footprint / bounds). +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct Aabb { + pub min: Vec3, + pub max: Vec3, +} + +/// Slab ray/AABB intersection; returns the near hit distance if the ray +/// (origin + t·dir, t ≥ 0) enters the box. +pub fn ray_aabb(origin: Vec3, dir: Vec3, b: &Aabb) -> Option<f32> { + let mut tmin = 0.0f32; + let mut tmax = f32::INFINITY; + for axis in 0..3 { + let (o, d, lo, hi) = match axis { + 0 => (origin.x, dir.x, b.min.x, b.max.x), + 1 => (origin.y, dir.y, b.min.y, b.max.y), + _ => (origin.z, dir.z, b.min.z, b.max.z), + }; + if d.abs() < 1e-8 { + if o < lo || o > hi { + return None; + } + } else { + let inv = 1.0 / d; + let mut t1 = (lo - o) * inv; + let mut t2 = (hi - o) * inv; + if t1 > t2 { + core::mem::swap(&mut t1, &mut t2); + } + tmin = tmin.max(t1); + tmax = tmax.min(t2); + if tmin > tmax { + return None; + } + } + } + Some(tmin) +} + +/// Result of a world pick at a screen position. +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum Pick { + /// A ground cell (world x, z). + Ground(f32, f32), + /// A prop by its caller-supplied index, at hit distance. + Prop(usize, f32), + /// An actor by its caller-supplied index. + Actor(usize), +} + +/// Pick the closest of: props (ray/AABB), actors (ground-disc around the foot), +/// else the ground point. `props`/`actors` carry caller indices. +pub fn pick( + camera: &IsoCamera, + aspect: f32, + ndc_x: f32, + ndc_y: f32, + props: &[(usize, Aabb)], + actors: &[(usize, Vec3, f32)], // (index, foot pos, select radius) +) -> Option<Pick> { + let ground = camera.ground_pick(aspect, ndc_x, ndc_y)?; + // Ray from the camera eye toward the ground hit (ortho: constant dir). + let eye = camera.center().add(super::camera::camera_offset()); + let dir = ground.sub(eye).normalize(); + + let mut best_prop: Option<(usize, f32)> = None; + for (idx, aabb) in props { + if let Some(t) = ray_aabb(eye, dir, aabb) { + if best_prop.map(|(_, bt)| t < bt).unwrap_or(true) { + best_prop = Some((*idx, t)); + } + } + } + + // Actor selection: nearest foot within its select radius of the ground hit. + let mut best_actor: Option<(usize, f32)> = None; + for (idx, foot, radius) in actors { + let dx = foot.x - ground.x; + let dz = foot.z - ground.z; + let d2 = dx * dx + dz * dz; + if d2 <= radius * radius && best_actor.map(|(_, bd)| d2 < bd).unwrap_or(true) { + best_actor = Some((*idx, d2)); + } + } + + // Priority: an actor under the cursor wins over a prop wins over bare ground. + if let Some((idx, _)) = best_actor { + return Some(Pick::Actor(idx)); + } + if let Some((idx, t)) = best_prop { + return Some(Pick::Prop(idx, t)); + } + Some(Pick::Ground(ground.x, ground.z)) +} + +#[cfg(test)] +mod tests { + use super::*; + use successor_engine_core::math::vec3; + + #[test] + fn ndc_center_is_origin() { + let (x, y) = screen_to_ndc(640.0, 360.0, 1280.0, 720.0); + assert!(x.abs() < 1e-6 && y.abs() < 1e-6); + } + + #[test] + fn iso_center_screen_hits_focus() { + let mut cam = IsoCamera::default(); + cam.update_focus(0.0, 0.0, 0.016); + let hit = cam + .ground_pick(16.0 / 9.0, 0.0, 0.0) + .expect("center hits ground"); + assert!(hit.x.abs() < 0.5, "x≈0 got {}", hit.x); + assert!(hit.z.abs() < 0.5, "z≈0 got {}", hit.z); + } + + #[test] + fn ray_aabb_hit_and_miss() { + let b = Aabb { + min: vec3(-1.0, -1.0, -1.0), + max: vec3(1.0, 1.0, 1.0), + }; + assert!(ray_aabb(vec3(0.0, 0.0, -5.0), vec3(0.0, 0.0, 1.0), &b).is_some()); + assert!(ray_aabb(vec3(5.0, 5.0, -5.0), vec3(0.0, 0.0, 1.0), &b).is_none()); + } + + #[test] + fn actor_beats_ground() { + let mut cam = IsoCamera::default(); + cam.update_focus(0.0, 0.0, 0.016); + let actors = [(7usize, vec3(0.0, 0.0, 0.0), 1.5f32)]; + let p = pick(&cam, 16.0 / 9.0, 0.0, 0.0, &[], &actors).unwrap(); + assert_eq!(p, Pick::Actor(7)); + } +} diff --git a/client-rust/source/app/src/world/props.rs b/client-rust/source/app/src/world/props.rs new file mode 100644 index 00000000..c85692b3 --- /dev/null +++ b/client-rust/source/app/src/world/props.rs @@ -0,0 +1,1830 @@ +//! World prop placement — port of `client-3d/src/render/props.ts` core: resolve +//! each slice prop through `props-mapping.json` (assetKey then kind), load+bake +//! its GLB once (recentered on its footprint, uniform-scaled to the cell +//! footprint), and spawn one entity per instance. Props are area-scoped (the +//! reference `propsForArea`), GLBs resolve through a stable-id byte reader +//! (platform asset read — no filesystem assumptions here), spawned entities +//! are tracked so an area transition can release them, and a mapped GLB that +//! fails to load renders the explicit missing-asset marker plus a typed +//! [`WorldAssetIssue`]. Unmapped/`placeholder` kinds render a tinted box; +//! `skip` kinds are ignored. + +use std::collections::HashMap; + +use serde_json::Value; + +use super::cutaway::{self, CutawayState, RegionMilli}; +use super::streamed::WorldAssetIssue; +use crate::assets::stream::{AssetStreamer, ByteSource}; +use crate::GameWorld; +use successor_engine_core::ecs::{Entity, WorldOps}; +use successor_engine_core::glb::{self, GlbDocument}; +use successor_engine_core::json::Json; +use successor_engine_core::math::{vec3, Mat4, Quat, Vec3}; +use successor_engine_render::components::{ + HeightCutaway, MaterialId, MeshId, MeshRenderer, SkinRef, Transform, +}; +use successor_engine_render::gi::GiOccluder; +use successor_engine_render::gpu::Gpu; +use successor_engine_render::renderer::Renderer; +use successor_platform::Platform; + +/// A distinct GLB uploaded once: its parts (mesh+material) and measured XZ +/// footprint (post-recenter), used to fit instances to their cell size. +#[derive(Clone, Copy)] +struct PropPart { + mesh: MeshId, + material: MaterialId, + local: Mat4, + /// Source GLB node, retained so per-placement metadata can select only + /// authored roof/wall/door parts after the model is cached. + node_index: usize, +} + +struct PropModel { + parts: Vec<PropPart>, + lights: Vec<crate::assets::model::EmbeddedPointLight>, + /// Names and ancestry stay alongside the baked parts; GLB node identity + /// must survive upload because enterable metadata names source nodes. + node_names: Vec<Option<String>>, + node_parents: Vec<Option<usize>>, + footprint_x: f32, + footprint_z: f32, + /// Post-recenter AABB height (min-Y..max-Y), for the GI occluder proxy. + height_y: f32, + /// Index-weighted mean base color, for the GI occluder proxy. + mean_albedo: [f32; 3], +} + +fn spawn_embedded_lights( + world: &mut GameWorld, + spawned: &mut Vec<Entity>, + lights: &[crate::assets::model::EmbeddedPointLight], + placement: Mat4, +) { + spawned.reserve(lights.len()); + for light in lights { + let (pos, rot, scale) = placement.mul(light.local).to_trs(); + let entity = world.spawn(); + world.set_component(entity, Transform { pos, rot, scale }); + world.set_component( + entity, + successor_engine_render::components::PointLight { + color: light.color, + intensity: light.intensity, + radius: light.radius, + }, + ); + spawned.push(entity); + } +} +struct EnterableProp { + entities: Vec<Entity>, + region: crate::world::cutaway::RegionMilli, + state: crate::world::cutaway::CutawayState, + fade_seconds: f64, +} + +#[derive(Clone, Copy)] +struct PlacedPart { + part: PropPart, + reveal: bool, + door: bool, +} + +/// A roof or wall entity selected by authored enterable metadata. +#[derive(Clone, Copy)] +struct RevealEntity { + entity: Entity, +} + +#[derive(Clone, Copy)] +struct DoorPart { + entity: Entity, + /// The decomposed placement * baked-local pose, copied verbatim on close. + closed: Transform, + /// Fully-open pose calculated once at placement, never per frame. + open: Transform, +} + +struct DoorInstance { + prop_id: String, + open: bool, + parts: Vec<DoorPart>, +} + +struct EnterableInstance { + cell_x: f32, + cell_z: f32, + regions: Vec<RegionMilli>, + /// Height of this interior's floor slab above the area's ground plane. An + /// actor inside stands on the slab, not on the terrain underneath it. + floor_height: f32, + reveal: Vec<RevealEntity>, + cutaway: CutawayState, + fade_seconds: f64, +} + +#[derive(Clone, Copy)] +struct SlideDoor<'a> { + node: &'a str, + axis: Vec3, + distance: f32, +} + +/// A slice prop parsed at `begin_area`, awaiting region-gated placement. +struct PendingProp { + prop: Json, + entry: Json, + id: String, + cell_x: f32, + cell_z: f32, + size_w_cells: f32, + size_h_cells: f32, + rotation: f32, + random_yaw: bool, + glb_ref: Option<String>, + stable_id: Option<String>, + /// Streaming region `(cell / REGION_CELLS)`. + region: (i32, i32), +} + +/// Streaming region edge in cells: 8×8 map chunks of 16 cells. +pub const REGION_CELLS: f32 = 128.0; + +/// Region coordinate for a cell position. +fn region_of(cell_x: f32, cell_z: f32) -> (i32, i32) { + ( + (cell_x / REGION_CELLS).floor() as i32, + (cell_z / REGION_CELLS).floor() as i32, + ) +} + +/// Chebyshev 3×3 region neighborhood test. +fn in_neighborhood(region: (i32, i32), center: (i32, i32)) -> bool { + (region.0 - center.0).abs() <= 1 && (region.1 - center.1).abs() <= 1 +} + +pub struct PropsLoader { + mapping: Json, + asset_base: String, + cache: HashMap<String, Option<PropModel>>, + /// Area props parsed by `begin_area`, placed by `sync_regions`/`load`. + pending: Vec<PendingProp>, + /// GI occluders accumulated across placements (area lifetime). + occluders: Vec<GiOccluder>, + /// Entities spawned by the last `load` calls (released by `clear`). + spawned: Vec<Entity>, + /// Area-local cutaway state and its explicitly selected render entities. + enterables: Vec<EnterableInstance>, + /// Legacy whole-prop height cutaways for mappings without reveal prefixes. + height_cutaways: Vec<EnterableProp>, + /// Area-local door state and closed/open transforms. + doors: Vec<DoorInstance>, + /// Typed optional-asset degradation (bounded, deduped). + issues: Vec<WorldAssetIssue>, + /// Streamer epoch at the last placement sweep; skips re-probing when + /// nothing new landed. + sweep_epoch: u64, + /// Region at the last sweep; a region change re-arms the sweep. + sweep_region: (i32, i32), + /// Set when `begin_area` adds new pendings; re-arms the sweep. + sweep_dirty: bool, +} + +const MAX_PROP_ISSUES: usize = 32; +/// Explicit missing-asset marker tint (matches the streamed-world pylon). +const MISSING_TINT: [f32; 4] = [0.9, 0.15, 0.75, 1.0]; +/// Clear the wall slightly above the normalized adult pawn's head. +const CUTAWAY_HEAD_CLEARANCE_METERS: f32 = 0.2; + +impl PropsLoader { + #[allow(clippy::result_unit_err)] + pub fn new(mapping_json: &str) -> Result<Self, ()> { + let mapping = Json::parse(mapping_json).map_err(|_| ())?; + let asset_base = mapping + .get("assetBase") + .and_then(Json::as_str) + .unwrap_or("/assets/world-items/") + .to_string(); + Ok(PropsLoader { + mapping, + asset_base, + pending: Vec::new(), + occluders: Vec::new(), + cache: HashMap::new(), + spawned: Vec::new(), + enterables: Vec::new(), + height_cutaways: Vec::new(), + doors: Vec::new(), + issues: Vec::new(), + sweep_epoch: u64::MAX, + sweep_region: (i32::MIN, i32::MIN), + sweep_dirty: false, + }) + } + + /// Typed degradation log (each missing model recorded once). + pub fn issues(&self) -> &[WorldAssetIssue] { + &self.issues + } + + fn record(&mut self, issue: WorldAssetIssue) { + if self.issues.len() < MAX_PROP_ISSUES && !self.issues.contains(&issue) { + self.issues.push(issue); + } + } + + /// Release every entity and area-local presentation record from prior + /// `load` calls (area transition). + pub fn clear(&mut self, world: &mut GameWorld) { + for e in self.spawned.drain(..) { + world.destroy(e); + } + self.enterables.clear(); + self.height_cutaways.clear(); + self.doors.clear(); + self.pending.clear(); + self.occluders.clear(); + world.flush(); + } + + fn entry(&self, key: &str) -> Option<&Json> { + self.mapping.get("entries").and_then(|e| e.get(key)) + } + + /// True once either cutaway path has stably entered a placed interior. + /// This is intentionally independent of fade progress. + pub fn player_inside_enterable(&self) -> bool { + self.enterables + .iter() + .any(|enterable| enterable.cutaway.inside) + || self + .height_cutaways + .iter() + .any(|enterable| enterable.state.inside) + } + + /// Height an actor at this world point stands at, given the interiors it + /// may be inside. `None` outside every interior, where terrain governs. + /// + /// A building's floor is a slab laid over the terrain, so an actor indoors + /// stands on the slab and not on the ground beneath it. Sampling terrain + /// alone sinks them into their own floor. This is the same rule the web + /// client applies in `enterableFloorYAt`: find the interior containing the + /// point and take its floor. + pub fn floor_height_at(&self, world_x: f32, world_z: f32) -> Option<f32> { + self.enterables.iter().find_map(|enterable| { + let local_x = (f64::from(world_x) - f64::from(enterable.cell_x)) * 1000.0; + let local_z = (f64::from(world_z) - f64::from(enterable.cell_z)) * 1000.0; + enterable + .regions + .iter() + .any(|region| { + local_x >= region.x_milli + && local_x <= region.x_milli + region.w_milli + && local_z >= region.y_milli + && local_z <= region.y_milli + region.h_milli + }) + .then_some(enterable.floor_height) + }) + } + + /// Synchronize area-local enterable and door presentation from accepted + /// authority data. Cutaway updates reuse placement-time component storage. + pub fn sync_enterable_presentation( + &mut self, + world: &mut GameWorld, + snapshot_tick: u64, + player_world_x: f32, + player_world_y: f32, + player_world_z: f32, + prop_states: &HashMap<String, Value>, + dt: f32, + ) { + let player_cell_x = player_world_x / WORLD_UNITS_PER_CELL; + let player_cell_z = player_world_z / WORLD_UNITS_PER_CELL; + for enterable in &mut self.enterables { + let amount = sample_enterable( + &mut enterable.cutaway, + snapshot_tick, + &enterable.regions, + enterable.cell_x, + enterable.cell_z, + player_cell_x, + player_cell_z, + enterable.fade_seconds, + dt, + ); + for record in &enterable.reveal { + world.set_component( + record.entity, + HeightCutaway { + cutoff_y: player_head_cutoff_y(player_world_y), + amount, + }, + ); + } + } + for door in &mut self.doors { + let open = prop_states + .get(&door.prop_id) + .and_then(|state| state.get("doorOpen")) + .and_then(Value::as_bool) + .unwrap_or(false); + if open == door.open { + continue; + } + door.open = open; + for part in &door.parts { + if let Some(transform) = world.get_component::<Transform>(part.entity) { + *transform = door_pose(part.closed, part.open, open); + } + } + } + } + + /// Parse the visible props of one area into the region-gated pending set. + /// Nothing is placed and no model bytes are read here; `sync_regions` + /// (streamed) or `load` (synchronous, tests/demos) performs placement. + pub fn begin_area(&mut self, slice: &Json, area_id: Option<&str>) { + self.pending.clear(); + self.sweep_dirty = true; + let Some(props) = slice.get("props").and_then(Json::as_array) else { + return; + }; + for prop in props { + if let Some(pending) = self.parse_pending(prop, area_id) { + self.pending.push(pending); + } + } + } + + /// Props still awaiting placement (region-gated). + pub fn pending_count(&self) -> usize { + self.pending.len() + } + + /// Pending props inside the single streaming region containing + /// `center_cell` (cell coordinates). The travel transition holds until + /// this reaches 0; the wider 3×3 neighborhood streams after release via + /// the region watcher. + pub fn pending_in_region(&self, center_cell: (f32, f32)) -> usize { + let player_region = region_of(center_cell.0, center_cell.1); + self.pending + .iter() + .filter(|pending| pending.region == player_region) + .count() + } + + fn parse_pending(&self, prop: &Json, area_id: Option<&str>) -> Option<PendingProp> { + if prop.get("visible").and_then(Json::as_bool) == Some(false) { + return None; + } + if let Some(area) = area_id { + if prop.get("areaId").and_then(Json::as_str) != Some(area) { + return None; + } + } + let asset_key = prop.get("assetKey").and_then(Json::as_str); + let kind = prop.get("kind").and_then(Json::as_str); + let entry = asset_key + .and_then(|k| self.entry(k)) + .or_else(|| kind.and_then(|k| self.entry(k))) + .cloned()?; + if entry.get("skip").and_then(Json::as_bool) == Some(true) { + return None; + } + let id = prop + .get("id") + .and_then(Json::as_str) + .unwrap_or("") + .to_string(); + let (cell_x, cell_z) = prop + .get("cell") + .map(|cell| { + ( + cell.get("x").and_then(Json::as_f32).unwrap_or(0.0), + cell.get("y").and_then(Json::as_f32).unwrap_or(0.0), + ) + }) + .unwrap_or((0.0, 0.0)); + let (size_w_cells, size_h_cells) = prop + .get("size") + .map(|size| { + ( + size.get("w").and_then(Json::as_f32).unwrap_or(1.0), + size.get("h").and_then(Json::as_f32).unwrap_or(1.0), + ) + }) + .unwrap_or((1.0, 1.0)); + let rotation = prop.get("rotation").and_then(Json::as_f32).unwrap_or(0.0); + let random_yaw = entry + .get("randomYaw") + .and_then(Json::as_bool) + .unwrap_or(false); + let glb_ref = entry.get("glb").and_then(Json::as_str).map(str::to_string); + let stable_id = glb_ref.as_ref().map(|glb_ref| self.stable_id_for(glb_ref)); + Some(PendingProp { + prop: prop.clone(), + entry, + id, + cell_x, + cell_z, + size_w_cells, + size_h_cells, + rotation, + random_yaw, + glb_ref, + stable_id, + region: region_of(cell_x, cell_z), + }) + } + + /// Public path (`/assets/world-items/foo.glb`) → stable asset id. + fn stable_id_for(&self, glb_ref: &str) -> String { + let public = if glb_ref.starts_with('/') { + glb_ref.to_string() + } else { + format!("{}{}", self.asset_base, glb_ref) + }; + public.trim_start_matches('/').to_string() + } + + /// Region-gated streamed placement: every pending prop inside the 3×3 + /// region neighborhood of `center_cell` (cell coordinates) whose model is + /// cached, just landed, or terminally missing places this call; the rest + /// are requested through the streamer and place on a later frame. + /// Returns placements performed this call. + #[allow(clippy::too_many_arguments)] + pub fn sync_regions<G: Gpu>( + &mut self, + world: &mut GameWorld, + renderer: &mut Renderer, + gpu: &mut G, + terrain: &TerrainStreamer, + platform: &mut dyn Platform, + streamer: &mut AssetStreamer, + center_cell: (f32, f32), + mask: u32, + ) -> usize { + let player_region = region_of(center_cell.0, center_cell.1); + // Allocation-free steady state: only re-probe pending props when the + // streamer delivered something, the player crossed a region boundary, + // or a new area was parsed. + let epoch = streamer.ready_epoch(); + if !self.sweep_dirty && self.sweep_region == player_region && self.sweep_epoch == epoch { + return 0; + } + self.sweep_dirty = false; + self.sweep_region = player_region; + self.sweep_epoch = epoch; + let mut placed = 0; + let mut index = 0; + while index < self.pending.len() { + if !in_neighborhood(self.pending[index].region, player_region) { + index += 1; + continue; + } + let ready = match ( + self.pending[index].glb_ref.clone(), + self.pending[index].stable_id.clone(), + ) { + (None, _) => true, // placeholder entry: no bytes needed + (Some(glb_ref), Some(stable_id)) => { + if self.cache.contains_key(&glb_ref) { + true + } else { + match streamer.resolve(platform, &stable_id) { + ByteSource::Pending => false, + ByteSource::Missing => { + self.record(WorldAssetIssue::MissingModel { + stable_id: stable_id.clone(), + }); + self.cache.insert(glb_ref, None); + true + } + ByteSource::Ready(bytes) => { + let model = glb::parse(&bytes) + .ok() + .and_then(|doc| upload_model(renderer, gpu, &doc)); + if model.is_none() { + self.record(WorldAssetIssue::MissingModel { stable_id }); + } + self.cache.insert(glb_ref, model); + true + } + } + } + } + (Some(_), None) => unreachable!("glb_ref implies stable_id"), + }; + if !ready { + index += 1; + continue; + } + let pending = self.pending.remove(index); + placed += usize::from(self.place_prop(world, renderer, gpu, terrain, &pending, mask)); + } + if placed > 0 { + renderer.gi_set_occluders(&self.occluders); + } + placed + } + + /// Deadline path for the travel hold: place every prop of the current + /// region immediately, substituting the explicit missing-asset marker for + /// models that never arrived (fail-closed, same presentation as a fetch + /// failure). The wider neighborhood keeps streaming via the watcher. + #[allow(clippy::too_many_arguments)] + pub fn force_place_region<G: Gpu>( + &mut self, + world: &mut GameWorld, + renderer: &mut Renderer, + gpu: &mut G, + terrain: &TerrainStreamer, + center_cell: (f32, f32), + mask: u32, + ) -> usize { + let player_region = region_of(center_cell.0, center_cell.1); + let mut placed = 0; + let mut index = 0; + while index < self.pending.len() { + if self.pending[index].region != player_region { + index += 1; + continue; + } + if let (Some(glb_ref), Some(stable_id)) = ( + self.pending[index].glb_ref.clone(), + self.pending[index].stable_id.clone(), + ) { + if !self.cache.contains_key(&glb_ref) { + self.record(WorldAssetIssue::MissingModel { stable_id }); + self.cache.insert(glb_ref, None); + } + } + let pending = self.pending.remove(index); + placed += usize::from(self.place_prop(world, renderer, gpu, terrain, &pending, mask)); + } + if placed > 0 { + renderer.gi_set_occluders(&self.occluders); + } + placed + } + + /// Place every visible prop of one area from a parsed slice into the + /// world, synchronously. Retained for tests and developer demos; the + /// connected scene streams placement through `begin_area` + + /// `sync_regions`. `area_id = None` places every area. + #[allow(clippy::too_many_arguments)] + pub fn load<G: Gpu>( + &mut self, + world: &mut GameWorld, + renderer: &mut Renderer, + gpu: &mut G, + slice: &Json, + terrain: &TerrainStreamer, + area_id: Option<&str>, + read: &mut dyn FnMut(&str) -> Option<Vec<u8>>, + mask: u32, + ) -> usize { + self.begin_area(slice, area_id); + let mut placed = 0; + while let Some(pending) = self.pending.pop() { + if let Some(glb_ref) = &pending.glb_ref { + self.ensure_model(renderer, gpu, read, glb_ref); + } + placed += usize::from(self.place_prop(world, renderer, gpu, terrain, &pending, mask)); + } + renderer.gi_set_occluders(&self.occluders); + placed + } + + /// Shared per-prop placement. The model (or its recorded miss) must + /// already sit in the cache for `glb_ref` entries. + fn place_prop<G: Gpu>( + &mut self, + world: &mut GameWorld, + renderer: &mut Renderer, + gpu: &mut G, + terrain: &TerrainStreamer, + pending: &PendingProp, + mask: u32, + ) -> bool { + let entry = &pending.entry; + let id = pending.id.as_str(); + let cx = pending.cell_x * WORLD_UNITS_PER_CELL; + let cy = pending.cell_z * WORLD_UNITS_PER_CELL; + let sw = pending.size_w_cells * WORLD_UNITS_PER_CELL; + let sh = pending.size_h_cells * WORLD_UNITS_PER_CELL; + let rotation = pending.rotation; + let random_yaw = pending.random_yaw; + let prop = &pending.prop; + let size_w_cells = pending.size_w_cells; + let size_h_cells = pending.size_h_cells; + + if let Some(glb_ref) = &pending.glb_ref { + let cached = self.cache.get(glb_ref); + let Some(cached) = cached else { + unreachable!("place_prop requires a resolved cache slot"); + }; + if cached.is_none() { + // Never invisible: the mapped model failed to load, so the + // instance renders the explicit missing-asset marker. + let ground_x = cx + sw / 2.0; + let ground_z = cy + sh / 2.0; + let ground_y = terrain.height_at(ground_x, ground_z); + let mesh = placeholder_cube(renderer, gpu); + let material = + renderer.add_material_desc(successor_engine_render::renderer::MaterialDesc { + base_color: MISSING_TINT, + ..successor_engine_render::renderer::MaterialDesc::default() + }); + let e = world.spawn(); + world.set_component( + e, + Transform { + pos: vec3(ground_x, ground_y + 0.6, ground_z), + rot: Quat::from_yaw(core::f32::consts::FRAC_PI_4), + scale: vec3(0.35, 1.2, 0.35), + }, + ); + world.set_component( + e, + MeshRenderer { + mesh, + material, + viewport_mask: mask, + skin: SkinRef::NONE, + }, + ); + self.spawned.push(e); + return true; + } + let enterable = entry.get("enterable"); + let reveal_prefixes = enterable.and_then(enterable_reveal_prefixes); + let reveal_name_includes = enterable.and_then(enterable_reveal_name_includes); + let slide_door = parse_slide_door(entry); + let (fx, fz, hy, alb, parts, lights) = { + let model = self.cache.get(glb_ref).unwrap().as_ref().unwrap(); + let parts: Vec<PlacedPart> = model + .parts + .iter() + .copied() + .map(|part| PlacedPart { + reveal: reveal_prefixes.is_some_and(|prefixes| { + part_matches_reveal_selectors( + &model.node_names, + &model.node_parents, + part.node_index, + prefixes, + reveal_name_includes, + slide_door.map(|door| door.node), + ) + }), + door: slide_door.is_some_and(|door| { + node_or_ancestor_matches( + &model.node_names, + &model.node_parents, + part.node_index, + |name| name == door.node, + ) + }), + part, + }) + .collect(); + ( + model.footprint_x, + model.footprint_z, + model.height_y, + model.mean_albedo, + parts, + model.lights.clone(), + ) + }; + let (fit_x, fit_z) = fit_footprint(entry, fx, fz); + let (yaw, scale) = placement(rotation, random_yaw, id, sw, sh, fit_x, fit_z); + let ground_x = cx + sw / 2.0; + let ground_z = cy + sh / 2.0; + let pos = vec3(ground_x, terrain.height_at(ground_x, ground_z), ground_z); + let initial_cutoff_y = player_head_cutoff_y(pos.y); + + let mut instance_entities = if enterable.is_some() && reveal_prefixes.is_none() { + Some(Vec::with_capacity(parts.len())) + } else { + None + }; + let placement = Mat4::from_trs(pos, Quat::from_yaw(yaw), vec3(scale, scale, scale)); + let mut reveal = Vec::new(); + let mut door_parts = Vec::new(); + for placed_part in parts { + let part = placed_part.part; + let (part_pos, part_rot, part_scale) = placement.mul(part.local).to_trs(); + let transform = Transform { + pos: part_pos, + rot: part_rot, + scale: part_scale, + }; + let e = world.spawn(); + self.spawned.push(e); + if let Some(entities) = instance_entities.as_mut() { + entities.push(e); + } + world.set_component(e, transform); + let mesh_renderer = MeshRenderer { + mesh: part.mesh, + material: part.material, + viewport_mask: mask, + skin: SkinRef::NONE, + }; + world.set_component(e, mesh_renderer); + if placed_part.reveal { + world.set_component( + e, + HeightCutaway { + cutoff_y: initial_cutoff_y, + amount: 0.0, + }, + ); + reveal.push(RevealEntity { entity: e }); + } else if instance_entities.is_some() { + world.set_component( + e, + HeightCutaway { + cutoff_y: initial_cutoff_y, + amount: 0.0, + }, + ); + } + if placed_part.door { + if let Some(door) = slide_door { + door_parts.push(DoorPart { + entity: e, + closed: transform, + open: door_open_transform( + transform, + yaw, + scale, + door.axis, + door.distance, + ), + }); + } + } + } + spawn_embedded_lights(world, &mut self.spawned, &lights, placement); + if let (Some(enterable), Some(_)) = (enterable, reveal_prefixes) { + self.enterables.push(EnterableInstance { + cell_x: pending.cell_x, + cell_z: pending.cell_z, + regions: placed_interior_regions(prop, size_w_cells, size_h_cells), + floor_height: enterable + .get("floorHeightM") + .and_then(Json::as_f32) + .unwrap_or(0.0), + reveal, + cutaway: CutawayState::default(), + fade_seconds: enterable_fade_seconds(enterable), + }); + } + if !door_parts.is_empty() { + self.doors.push(DoorInstance { + prop_id: id.to_string(), + open: false, + parts: door_parts, + }); + } + if let (Some(enterable), Some(entities)) = (enterable, instance_entities) { + self.height_cutaways.push(EnterableProp { + entities, + region: RegionMilli { + x_milli: cx as f64 * 1000.0, + y_milli: cy as f64 * 1000.0, + w_milli: sw as f64 * 1000.0, + h_milli: sh as f64 * 1000.0, + }, + state: CutawayState::default(), + fade_seconds: enterable_fade_seconds(enterable), + }); + } + self.occluders.push(GiOccluder { + center: [pos.x, pos.y + hy * scale * 0.5, pos.z], + half_extents: [fx * scale * 0.5, hy * scale * 0.5, fz * scale * 0.5], + yaw, + albedo: alb, + }); + return true; + } + if let Some(ph) = entry.get("placeholder") { + let height = ph.get("height").and_then(Json::as_f32).unwrap_or(0.8); + let tint = ph + .get("tint") + .and_then(Json::as_str) + .map(parse_hex) + .unwrap_or([0.43, 0.4, 0.34, 1.0]); + let (yaw, _) = placement(rotation, random_yaw, id, sw, sh, 1.0, 1.0); + let ground_x = cx + sw / 2.0; + let ground_z = cy + sh / 2.0; + let ground_y = terrain.height_at(ground_x, ground_z); + let mesh = placeholder_cube(renderer, gpu); + let material = + renderer.add_material_desc(successor_engine_render::renderer::MaterialDesc { + base_color: tint, + blend: (tint)[3] < 1.0, + ..successor_engine_render::renderer::MaterialDesc::default() + }); + let e = world.spawn(); + self.spawned.push(e); + world.set_component( + e, + Transform { + pos: vec3(ground_x, ground_y + height / 2.0, ground_z), + rot: Quat::from_yaw(yaw), + scale: vec3(sw.max(0.5), height, sh.max(0.5)), + }, + ); + world.set_component( + e, + MeshRenderer { + mesh, + material, + viewport_mask: mask, + skin: SkinRef::NONE, + }, + ); + self.occluders.push(GiOccluder { + center: [ground_x, ground_y + height * 0.5, ground_z], + half_extents: [sw.max(0.5) * 0.5, height * 0.5, sh.max(0.5) * 0.5], + yaw, + albedo: [tint[0], tint[1], tint[2]], + }); + return true; + } + false + } + + /// Update authored enterable props from the authoritative local player. + pub fn update_cutaways( + &mut self, + world: &mut GameWorld, + tick: u64, + player_x: f32, + player_y: f32, + player_ground_y: f32, + dt: f32, + ) { + for enterable in &mut self.height_cutaways { + crate::world::cutaway::sample( + &mut enterable.state, + tick as f64, + &[enterable.region], + player_x as f64 * 1000.0, + player_y as f64 * 1000.0, + ); + let amount = crate::world::cutaway::advance_fade( + &mut enterable.state, + dt as f64, + enterable.fade_seconds, + false, + ) as f32; + for entity in &enterable.entities { + world.set_component( + *entity, + HeightCutaway { + cutoff_y: player_head_cutoff_y(player_ground_y), + amount, + }, + ); + } + } + } + + /// Ensure a model is cached; `true` when loaded, `false` → typed miss + /// (recorded once; the miss itself is cached so it is not re-read). + fn ensure_model<G: Gpu>( + &mut self, + renderer: &mut Renderer, + gpu: &mut G, + read: &mut dyn FnMut(&str) -> Option<Vec<u8>>, + glb_ref: &str, + ) -> bool { + if let Some(slot) = self.cache.get(glb_ref) { + return slot.is_some(); + } + let stable_id = self.stable_id_for(glb_ref); + let model = read(&stable_id) + .and_then(|bytes| glb::parse(&bytes).ok()) + .and_then(|doc| upload_model(renderer, gpu, &doc)); + if model.is_none() { + self.record(WorldAssetIssue::MissingModel { stable_id }); + } + let loaded = model.is_some(); + self.cache.insert(glb_ref.to_string(), model); + loaded + } +} + +/// `enterable` opts an entry into the current presentation convention only +/// when it supplies actual, non-empty node prefixes. Legacy mappings therefore +/// retain their old presentation rather than falling back to a whole-prop hide. +fn enterable_reveal_prefixes(enterable: &Json) -> Option<&[Json]> { + let prefixes = enterable.get("revealPrefixes").and_then(Json::as_array)?; + prefixes + .iter() + .filter_map(Json::as_str) + .any(|prefix| !prefix.is_empty()) + .then_some(prefixes) +} + +fn enterable_reveal_name_includes(enterable: &Json) -> Option<&[Json]> { + let includes = enterable + .get("revealNameIncludes") + .and_then(Json::as_array)?; + includes + .iter() + .filter_map(Json::as_str) + .any(|include| !include.is_empty()) + .then_some(includes) +} + +fn enterable_fade_seconds(enterable: &Json) -> f64 { + let seconds = enterable + .get("fadeSeconds") + .and_then(Json::as_f64) + .unwrap_or(0.25); + if seconds.is_finite() { + seconds.max(0.01) + } else { + 0.25 + } +} + +fn player_head_cutoff_y(player_ground_y: f32) -> f32 { + player_ground_y + crate::world::ADULT_PAWN_HEIGHT_METERS + CUTAWAY_HEAD_CLEARANCE_METERS +} + +fn parse_slide_door(entry: &Json) -> Option<SlideDoor<'_>> { + let door = entry.get("slideDoor")?; + let node = door.get("node").and_then(Json::as_str)?; + if node.is_empty() { + return None; + } + let axis = door.get("axisLocal").and_then(Json::as_array)?; + if axis.len() != 3 { + return None; + } + let axis = vec3(axis[0].as_f32()?, axis[1].as_f32()?, axis[2].as_f32()?); + if !axis.x.is_finite() || !axis.y.is_finite() || !axis.z.is_finite() { + return None; + } + let axis = axis.normalize(); + if axis == Vec3::ZERO { + return None; + } + let distance = door.get("distance").and_then(Json::as_f32)?; + if !distance.is_finite() || distance <= 0.0 { + return None; + } + Some(SlideDoor { + node, + axis, + distance, + }) +} + +/// Top-level `interiorRegions` is the placed collision authoring; nested +/// `enterable.interiorBounds` is the compatible snapshot form. Both are +/// post-rotation prop-local milli-cell AABBs, so sampling must not rotate them +/// again. A mapped enterable with neither retains the legacy footprint region. +fn placed_interior_regions(prop: &Json, size_w_cells: f32, size_h_cells: f32) -> Vec<RegionMilli> { + let mut regions = Vec::new(); + if let Some(values) = prop.get("interiorRegions").and_then(Json::as_array) { + append_regions(&mut regions, values); + } + if regions.is_empty() { + if let Some(values) = prop + .get("enterable") + .and_then(|enterable| enterable.get("interiorBounds")) + .and_then(Json::as_array) + { + append_regions(&mut regions, values); + } + } + if regions.is_empty() { + let w_milli = f64::from(size_w_cells).max(0.0) * 1000.0; + let h_milli = f64::from(size_h_cells).max(0.0) * 1000.0; + if w_milli > 0.0 && h_milli > 0.0 { + regions.push(RegionMilli { + x_milli: 0.0, + y_milli: 0.0, + w_milli, + h_milli, + }); + } + } + regions +} + +fn append_regions(out: &mut Vec<RegionMilli>, values: &[Json]) { + for value in values { + let (Some(x_milli), Some(y_milli), Some(w_milli), Some(h_milli)) = ( + value.get("xMilli").and_then(Json::as_f64), + value.get("yMilli").and_then(Json::as_f64), + value.get("wMilli").and_then(Json::as_f64), + value.get("hMilli").and_then(Json::as_f64), + ) else { + continue; + }; + if x_milli.is_finite() + && y_milli.is_finite() + && w_milli.is_finite() + && h_milli.is_finite() + && w_milli > 0.0 + && h_milli > 0.0 + { + out.push(RegionMilli { + x_milli, + y_milli, + w_milli, + h_milli, + }); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn sample_enterable( + state: &mut CutawayState, + snapshot_tick: u64, + regions: &[RegionMilli], + cell_x: f32, + cell_z: f32, + player_cell_x: f32, + player_cell_z: f32, + fade_seconds: f64, + dt: f32, +) -> f32 { + cutaway::sample( + state, + snapshot_tick as f64, + regions, + (f64::from(player_cell_x) - f64::from(cell_x)) * 1000.0, + (f64::from(player_cell_z) - f64::from(cell_z)) * 1000.0, + ); + cutaway::advance_fade(state, f64::from(dt), fade_seconds, false) as f32 +} + +/// Applies the placement's yaw and uniform fit scale to a node-local door +/// axis. The supplied closed transform is never recomputed, so closing writes +/// its exact original bytes back to the entity. +fn door_open_transform( + closed: Transform, + placement_yaw: f32, + placement_scale: f32, + axis_local: Vec3, + distance: f32, +) -> Transform { + let offset = Mat4::from_trs( + Vec3::ZERO, + Quat::from_yaw(placement_yaw), + vec3(placement_scale, placement_scale, placement_scale), + ) + .transform_point(axis_local.normalize().scale(distance)); + Transform { + pos: closed.pos.add(offset), + ..closed + } +} + +fn door_pose(closed: Transform, open: Transform, is_open: bool) -> Transform { + if is_open { + open + } else { + closed + } +} + +/// Authored prefix and literal-name selection walks GLB ancestry so a mesh +/// beneath a named group is selected. Floors and gameplay doors are never +/// reveal candidates, even if malformed mapping metadata mentions them. +fn part_matches_reveal_selectors( + node_names: &[Option<String>], + node_parents: &[Option<usize>], + node_index: usize, + prefixes: &[Json], + name_includes: Option<&[Json]>, + door_node: Option<&str>, +) -> bool { + if node_or_ancestor_matches(node_names, node_parents, node_index, |name| { + name.starts_with("door_slide") || door_node.is_some_and(|door| name == door) + }) { + return false; + } + if node_or_ancestor_matches(node_names, node_parents, node_index, |name| { + ascii_contains_ignore_case(name, "floor") + }) { + return false; + } + node_or_ancestor_matches(node_names, node_parents, node_index, |name| { + prefixes + .iter() + .filter_map(Json::as_str) + .any(|prefix| !prefix.is_empty() && name.starts_with(prefix)) + || name_includes.is_some_and(|includes| { + includes + .iter() + .filter_map(Json::as_str) + .any(|include| !include.is_empty() && name.contains(include)) + }) + }) +} + +fn node_or_ancestor_matches( + node_names: &[Option<String>], + node_parents: &[Option<usize>], + node_index: usize, + mut matches: impl FnMut(&str) -> bool, +) -> bool { + let mut current = Some(node_index); + for _ in 0..node_names.len() { + let Some(index) = current else { + break; + }; + if let Some(Some(name)) = node_names.get(index) { + if matches(name) { + return true; + } + } + current = node_parents.get(index).copied().flatten(); + } + false +} + +fn ascii_contains_ignore_case(haystack: &str, needle: &str) -> bool { + let needle = needle.as_bytes(); + haystack.as_bytes().windows(needle.len()).any(|candidate| { + candidate + .iter() + .zip(needle) + .all(|(left, right)| left.eq_ignore_ascii_case(right)) + }) +} + +/// Build visual-ground and detail-scatter exclusions from authoritative +/// building cells. Small props do not flatten the landscape; only structures +/// whose placement contract owns a footprint do. `area_id = None` spans every +/// area (developer demo); connected use scopes to the active area. +pub fn building_terrain_exclusions( + slice: &Json, + area_id: Option<&str>, + padding: f32, +) -> Vec<TerrainExclusion> { + let Some(props) = slice.get("props").and_then(Json::as_array) else { + return Vec::new(); + }; + let padding = padding.max(0.0); + let mut exclusions = Vec::new(); + for prop in props { + if prop.get("visible").and_then(Json::as_bool) == Some(false) + || prop.get("kind").and_then(Json::as_str) != Some("building") + { + continue; + } + if let Some(area) = area_id { + if prop.get("areaId").and_then(Json::as_str) != Some(area) { + continue; + } + } + let Some(cell) = prop.get("cell") else { + continue; + }; + let cx = cell.get("x").and_then(Json::as_f32).unwrap_or(0.0) * WORLD_UNITS_PER_CELL; + let cz = cell.get("y").and_then(Json::as_f32).unwrap_or(0.0) * WORLD_UNITS_PER_CELL; + let (width, depth) = prop + .get("size") + .map(|size| { + ( + size.get("w").and_then(Json::as_f32).unwrap_or(1.0) * WORLD_UNITS_PER_CELL, + size.get("h").and_then(Json::as_f32).unwrap_or(1.0) * WORLD_UNITS_PER_CELL, + ) + }) + .unwrap_or((1.0, 1.0)); + exclusions.push(TerrainExclusion { + min: [cx - padding, cz - padding], + max: [cx + width + padding, cz + depth + padding], + feather: 3.0, + }); + } + exclusions +} + +fn fit_footprint(entry: &Json, measured_x: f32, measured_z: f32) -> (f32, f32) { + let Some(values) = entry.get("fitFootprintM").and_then(Json::as_array) else { + return (measured_x, measured_z); + }; + if values.len() != 2 { + return (measured_x, measured_z); + } + let (Some(x), Some(z)) = (values[0].as_f32(), values[1].as_f32()) else { + return (measured_x, measured_z); + }; + if x > 0.0 && z > 0.0 { + (x, z) + } else { + (measured_x, measured_z) + } +} + +/// composePlacement: yaw + uniform fit scale. +fn placement( + rotation: f32, + random_yaw: bool, + id: &str, + sw: f32, + sh: f32, + fx: f32, + fz: f32, +) -> (f32, f32) { + let deg2rad = core::f32::consts::PI / 180.0; + let swap = rotation == 90.0 || rotation == 270.0; + let target_w = if swap { sh } else { sw }; + let target_d = if swap { sw } else { sh }; + let use_random = random_yaw && rotation == 0.0; + let yaw = if rotation != 0.0 { + -rotation * deg2rad + } else if use_random { + hash_yaw(id) + } else { + 0.0 + }; + let fit_w = if use_random { + target_w.min(target_d) + } else { + target_w + }; + let fit_d = if use_random { + target_w.min(target_d) + } else { + target_d + }; + let scale = (fit_w / fx.max(1e-3)).min(fit_d / fz.max(1e-3)); + (yaw, scale) +} + +/// FNV-1a over the id → yaw in [0, 2π). +fn hash_yaw(id: &str) -> f32 { + let mut hash: u32 = 2166136261; + for b in id.bytes() { + hash ^= b as u32; + hash = hash.wrapping_mul(16777619); + } + (hash % 3600) as f32 * (core::f32::consts::PI / 1800.0) +} + +/// Bake all static primitives (node globals applied), recenter on the footprint +/// (XZ center → 0, min-Y → 0), and upload. Returns parts + XZ footprint. +fn upload_model<G: Gpu>( + renderer: &mut Renderer, + gpu: &mut G, + doc: &GlbDocument, +) -> Option<PropModel> { + let globals = doc.node_globals(); + let node_names = doc.nodes.iter().map(|node| node.name.clone()).collect(); + let parents = node_parents(doc); + // First pass: AABB over baked positions. + let mut min = vec3(f32::MAX, f32::MAX, f32::MAX); + let mut max = vec3(f32::MIN, f32::MIN, f32::MIN); + for (ni, node) in doc.nodes.iter().enumerate() { + let Some(mi) = node.mesh else { continue }; + let Some(mesh) = doc.meshes.get(mi) else { + continue; + }; + for prim in &mesh.primitives { + let positions = prim.morphed_positions(); + for p in &positions { + let w = globals[ni].transform_point(vec3(p[0], p[1], p[2])); + min = vec3(min.x.min(w.x), min.y.min(w.y), min.z.min(w.z)); + max = vec3(max.x.max(w.x), max.y.max(w.y), max.z.max(w.z)); + } + } + } + if min.x > max.x { + return None; + } + let cx = (min.x + max.x) * 0.5; + let cz = (min.z + max.z) * 0.5; + let offset = vec3(-cx, -min.y, -cz); + + let prepared = successor_engine_render::model::prepare_glb(doc).ok()?; + let mut lights = crate::assets::model::extract_embedded_lights( + doc, + &prepared.material_emissive_texture_means, + ) + .ok()?; + let uploaded = + successor_engine_render::model::upload_prepared_glb(renderer, gpu, doc, prepared).ok()?; + + // Accumulate a mean albedo (weighted by index count) for the GI occluder proxy. + let mut albedo_sum = [0.0f32; 3]; + let mut albedo_weight = 0.0f32; + + let mut parts = Vec::new(); + let recenter = Mat4::from_translation(offset); + for (node_index, node) in doc.nodes.iter().enumerate() { + let Some(mesh_index) = node.mesh else { + continue; + }; + for primitive in uploaded + .primitives + .iter() + .filter(|primitive| primitive.source_mesh == mesh_index) + { + let source = doc + .meshes + .get(mesh_index) + .and_then(|mesh| mesh.primitives.get(primitive.source_primitive)); + let base = source + .and_then(|primitive| primitive.material) + .and_then(|material| doc.materials.get(material)) + .map(|material| material.base_color) + .unwrap_or([0.7, 0.68, 0.64, 1.0]); + let weight = source.map_or(0.0, |primitive| primitive.indices.len() as f32); + albedo_sum[0] += base[0] * weight; + albedo_sum[1] += base[1] * weight; + albedo_sum[2] += base[2] * weight; + albedo_weight += weight; + parts.push(PropPart { + mesh: primitive.mesh, + material: primitive.material, + local: recenter.mul(globals[node_index]), + node_index, + }); + } + } + let mean_albedo = if albedo_weight > 0.0 { + [ + albedo_sum[0] / albedo_weight, + albedo_sum[1] / albedo_weight, + albedo_sum[2] / albedo_weight, + ] + } else { + [0.7, 0.68, 0.64] + }; + for light in &mut lights { + light.local = recenter + .mul(globals.get(light.node).copied().unwrap_or(Mat4::IDENTITY)) + .mul(light.local); + } + Some(PropModel { + parts, + lights, + node_names, + node_parents: parents, + footprint_x: (max.x - min.x).max(0.01), + footprint_z: (max.z - min.z).max(0.01), + height_y: (max.y - min.y).max(0.01), + mean_albedo, + }) +} + +fn node_parents(doc: &GlbDocument) -> Vec<Option<usize>> { + let mut parents = vec![None; doc.nodes.len()]; + for (parent, node) in doc.nodes.iter().enumerate() { + for &child in &node.children { + if child < parents.len() && parents[child].is_none() { + parents[child] = Some(parent); + } + } + } + parents +} + +fn placeholder_cube<G: Gpu>(renderer: &mut Renderer, gpu: &mut G) -> MeshId { + // Unit cube centered at origin, base at y=-0.5; scaled by the caller. + let (v, i) = successor_engine_render::primitives::cube(); + renderer.upload_mesh(gpu, &v, &i) +} + +fn parse_hex(s: &str) -> [f32; 4] { + let h = s.trim_start_matches('#'); + if h.len() >= 6 { + let r = u8::from_str_radix(&h[0..2], 16).unwrap_or(110) as f32 / 255.0; + let g = u8::from_str_radix(&h[2..4], 16).unwrap_or(101) as f32 / 255.0; + let b = u8::from_str_radix(&h[4..6], 16).unwrap_or(87) as f32 / 255.0; + [r, g, b, 1.0] + } else { + [0.43, 0.4, 0.34, 1.0] + } +} + +// --------------------------------------------------------------------------- +// Combined world scene: terrain + props + orbiting camera (`--demo props`). +// --------------------------------------------------------------------------- + +use super::chunks::{TerrainExclusion, TerrainStreamer}; +use super::terrain::Biome; +use super::WORLD_UNITS_PER_CELL; + +pub struct WorldScene { + pub world: GameWorld, + pub renderer: Renderer, + camera: Entity, + center: Vec3, + orbit: f32, +} + +impl WorldScene { + /// Build terrain + all slice props around the slice's prop centroid. + #[allow(clippy::result_unit_err)] + pub fn build<G: Gpu>( + gpu: &mut G, + assets_dir: &str, + mapping_json: &str, + slice_json: &str, + ) -> Result<WorldScene, ()> { + use successor_engine_render::components::{ + CamTarget, Camera, DirectionalLight, Projection, RectNorm, + }; + use successor_engine_render::gpu::ClearSpec; + + let slice = Json::parse(slice_json).map_err(|_| ())?; + let mut renderer = crate::configured_renderer(gpu).expect("renderer initialization failed"); + renderer.set_ambient(0.5); + renderer.set_fog([0.788, 0.678, 0.510], 140.0, 320.0); + let mut world = GameWorld::new(); + + // Centroid of props → focus point. + let (mut sx, mut sz, mut n) = (0.0f32, 0.0f32, 0.0f32); + if let Some(props) = slice.get("props").and_then(Json::as_array) { + for p in props { + if let Some(c) = p.get("cell") { + sx += c.get("x").and_then(Json::as_f32).unwrap_or(0.0) * WORLD_UNITS_PER_CELL; + sz += c.get("y").and_then(Json::as_f32).unwrap_or(0.0) * WORLD_UNITS_PER_CELL; + n += 1.0; + } + } + } + let center = if n > 0.0 { + vec3(sx / n, 0.0, sz / n) + } else { + vec3( + 512.0 * WORLD_UNITS_PER_CELL, + 0.0, + 512.0 * WORLD_UNITS_PER_CELL, + ) + }; + renderer.gi_set_focus([center.x, center.y, center.z]); + + // Terrain ground under the props. + let mut streamer = TerrainStreamer::new( + 0x0d3d_071e, + Biome::Desert, + 64.0 * WORLD_UNITS_PER_CELL as f64, + 3, + 0b1, + ); + let exclusions = building_terrain_exclusions(&slice, None, 1.5); + streamer.set_exclusions(&exclusions); + streamer.ensure_around( + &mut world, + &mut renderer, + gpu, + center.x as f64, + center.z as f64, + ); + + // Props (all areas — developer demo; stable ids resolve under the + // public root implied by `assets_dir`). + let public_root = assets_dir + .strip_suffix("/assets") + .unwrap_or(assets_dir) + .to_string(); + let mut read = move |stable_id: &str| -> Option<Vec<u8>> { + if stable_id.is_empty() || stable_id.contains("..") || stable_id.starts_with('/') { + return None; + } + std::fs::read(format!("{public_root}/{stable_id}")).ok() + }; + let mut loader = PropsLoader::new(mapping_json)?; + let placed = loader.load( + &mut world, + &mut renderer, + gpu, + &slice, + &streamer, + None, + &mut read, + 0b1, + ); + eprintln!("props: placed {placed} instances"); + + let sun = world.spawn(); + world.set_component( + sun, + DirectionalLight { + dir: vec3(-0.4, -1.0, -0.3).normalize(), + color: [1.0, 0.98, 0.92], + cast_shadows: true, + }, + ); + + let orbit = 60.0f32; + let camera = world.spawn(); + world.set_component( + camera, + Camera { + viewport_id: 0, + order: 0, + projection: Projection::Perspective { + fovy: 45.0_f32.to_radians(), + near: 0.5, + far: 2000.0, + }, + target: CamTarget::Screen(RectNorm::FULL), + clear: ClearSpec { + color: Some([0.788, 0.678, 0.510, 1.0]), + depth: Some(1.0), + }, + eye: center.add(vec3(orbit, orbit * 0.8, orbit)), + look_at: center, + up: Vec3::Y, + }, + ); + + Ok(WorldScene { + world, + renderer, + camera, + center, + orbit, + }) + } + + pub fn animate(&mut self, frame: u64) { + use successor_engine_render::components::Camera; + let angle = frame as f32 * 0.01; + let eye = self.center.add(vec3( + angle.cos() * self.orbit, + self.orbit * 0.8, + angle.sin() * self.orbit, + )); + if let Some(cam) = self.world.get_component::<Camera>(self.camera) { + cam.eye = eye; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prop_light_instances_share_descriptors_and_clear_with_the_region() { + let descriptor = crate::assets::model::EmbeddedPointLight { + source: crate::assets::model::EmbeddedLightSource::Inferred, + node: 0, + local: Mat4::from_translation(vec3(0.5, 1.0, -0.5)), + color: [1.0, 0.4, 0.1], + intensity: 3.0, + radius: 4.0, + }; + let mut loader = PropsLoader::new("{}").expect("loader"); + let mut world = GameWorld::new(); + spawn_embedded_lights( + &mut world, + &mut loader.spawned, + &[descriptor], + Mat4::from_translation(vec3(2.0, 3.0, 4.0)), + ); + spawn_embedded_lights( + &mut world, + &mut loader.spawned, + &[descriptor], + Mat4::from_translation(vec3(-2.0, 0.0, 1.0)), + ); + + let mut positions = Vec::new(); + let mut query = + world.query2::<successor_engine_render::components::PointLight, Transform>(); + while let Some((_, light, transform)) = query.next() { + assert_eq!(light.color, descriptor.color); + assert_eq!(light.intensity, descriptor.intensity); + assert_eq!(light.radius, descriptor.radius); + positions.push(transform.pos); + } + positions.sort_by(|left, right| left.x.total_cmp(&right.x)); + assert_eq!(positions, vec![vec3(-1.5, 1.0, 0.5), vec3(2.5, 4.0, 3.5)]); + + loader.clear(&mut world); + assert!(world + .query2::<successor_engine_render::components::PointLight, Transform>() + .next() + .is_none()); + } + + #[test] + fn hash_yaw_deterministic_and_in_range() { + let a = hash_yaw("dustgate-cloning-facility"); + let b = hash_yaw("dustgate-cloning-facility"); + assert_eq!(a, b); + assert!((0.0..core::f32::consts::PI * 2.0).contains(&a)); + assert!(hash_yaw("barrel_scav-1") != hash_yaw("barrel_scav-2")); + } + + #[test] + fn placement_fits_footprint() { + // A 4×4-cell prop from an 8-unit GLB footprint → 0.5 uniform scale. + let (yaw, scale) = placement(0.0, false, "x", 4.0, 4.0, 8.0, 8.0); + assert_eq!(yaw, 0.0); + assert!((scale - 0.5).abs() < 1e-4); + } + #[test] + fn authored_occupancy_footprint_ignores_decorative_overhangs() { + let entry = Json::parse(r#"{"fitFootprintM":[7.6,5.7]}"#).expect("mapping entry"); + assert_eq!(fit_footprint(&entry, 8.056, 7.454), (7.6, 5.7)); + let fallback = Json::parse("{}").expect("mapping entry"); + assert_eq!(fit_footprint(&fallback, 8.056, 7.454), (8.056, 7.454)); + } + + #[test] + fn rotation_90_yaw() { + let (yaw, _) = placement(90.0, false, "x", 2.0, 4.0, 2.0, 4.0); + assert!((yaw - (-90.0f32).to_radians()).abs() < 1e-4); + } + + #[test] + fn only_visible_buildings_reserve_terrain_footprints() { + let slice = Json::parse( + r#"{"props":[ + {"kind":"building","cell":{"x":10,"y":20},"size":{"w":4,"h":3}}, + {"kind":"prop","cell":{"x":30,"y":40},"size":{"w":8,"h":8}}, + {"kind":"building","visible":false,"cell":{"x":50,"y":60}} + ]}"#, + ) + .expect("slice"); + let exclusions = building_terrain_exclusions(&slice, None, 1.5); + // Area scoping: no prop carries `areaId` here, so a scoped call + // excludes them all (fail closed rather than leak across areas). + assert!(building_terrain_exclusions(&slice, Some("a"), 1.5).is_empty()); + assert_eq!( + exclusions, + vec![TerrainExclusion { + min: [8.5, 18.5], + max: [15.5, 24.5], + feather: 3.0, + }] + ); + } + + #[test] + fn enterable_selectors_select_only_authored_shell_parts() { + let names = vec![ + Some("root".to_string()), + Some("roof__shell".to_string()), + Some("wall_front__frame".to_string()), + Some("floor__slab".to_string()), + Some("door_slide__leaf".to_string()), + Some("interior__counter".to_string()), + Some("wall_back__far".to_string()), + Some("trim__roof_child".to_string()), + Some("interior__0202_ceiling_1x1__plasterlined".to_string()), + Some("wall_court__inner".to_string()), + Some("interior__floor_ceiling_panel".to_string()), + Some("door_slide_ceiling_panel".to_string()), + ]; + let parents = vec![ + None, + Some(0), + Some(0), + Some(0), + Some(0), + Some(0), + Some(0), + Some(1), + Some(0), + Some(0), + Some(0), + Some(0), + ]; + let enterable = Json::parse( + r#"{"revealPrefixes":["roof__","wall_front__","wall_right__","wall_back__","wall_left__","wall_court__"],"revealNameIncludes":["_ceiling_"]}"#, + ) + .expect("enterable mapping"); + let prefixes = enterable_reveal_prefixes(&enterable).expect("reveal prefixes"); + let includes = enterable_reveal_name_includes(&enterable).expect("reveal name includes"); + + for node in [1, 2, 6, 7, 8, 9] { + assert!(part_matches_reveal_selectors( + &names, + &parents, + node, + prefixes, + Some(includes), + Some("door_slide") + )); + } + for node in [3, 4, 5, 10, 11] { + assert!(!part_matches_reveal_selectors( + &names, + &parents, + node, + prefixes, + Some(includes), + Some("door_slide") + )); + } + } + + #[test] + fn placed_regions_prefer_collision_authoring_then_snapshot_bounds() { + let placed = Json::parse( + r#"{ + "interiorRegions":[{"xMilli":579,"yMilli":579,"wMilli":8842,"hMilli":7000}], + "enterable":{"interiorBounds":[{"xMilli":1,"yMilli":2,"wMilli":3,"hMilli":4}]} + }"#, + ) + .expect("placed prop"); + assert_eq!( + placed_interior_regions(&placed, 10.0, 8.0), + vec![RegionMilli { + x_milli: 579.0, + y_milli: 579.0, + w_milli: 8842.0, + h_milli: 7000.0, + }] + ); + + let snapshot = Json::parse( + r#"{"enterable":{"interiorBounds":[{"xMilli":505,"yMilli":2553,"wMilli":8490,"hMilli":2447}]}}"#, + ) + .expect("snapshot prop"); + assert_eq!( + placed_interior_regions(&snapshot, 10.0, 8.0), + vec![RegionMilli { + x_milli: 505.0, + y_milli: 2553.0, + w_milli: 8490.0, + h_milli: 2447.0, + }] + ); + } + + #[test] + fn stable_entry_exit_animates_cutaway_above_player_head() { + let regions = [RegionMilli { + x_milli: 0.0, + y_milli: 0.0, + w_milli: 1000.0, + h_milli: 1000.0, + }]; + let mut state = CutawayState::default(); + + assert_eq!( + sample_enterable(&mut state, 1, ®ions, 10.0, 20.0, 10.5, 20.5, 0.25, 0.05), + 0.0 + ); + let entering = + sample_enterable(&mut state, 2, ®ions, 10.0, 20.0, 10.5, 20.5, 0.25, 0.05); + assert!(state.inside); + assert!(entering > 0.0 && entering < 1.0); + + sample_enterable(&mut state, 2, ®ions, 10.0, 20.0, 10.5, 20.5, 0.25, 0.1); + let interior = sample_enterable(&mut state, 2, ®ions, 10.0, 20.0, 10.5, 20.5, 0.25, 0.1); + assert_eq!(interior, 1.0); + assert_eq!(player_head_cutoff_y(2.0), 4.0); + + assert_eq!( + sample_enterable(&mut state, 3, ®ions, 10.0, 20.0, 12.0, 22.0, 0.25, 0.05), + 1.0 + ); + let exiting = sample_enterable(&mut state, 4, ®ions, 10.0, 20.0, 12.0, 22.0, 0.25, 0.05); + assert!(!state.inside); + assert!(exiting > 0.0 && exiting < 1.0); + } + + #[test] + fn door_open_applies_placement_yaw_scale_and_close_is_exact() { + let closed = Transform { + pos: vec3(10.0, 1.5, 20.0), + rot: Quat::from_yaw(0.37), + scale: vec3(1.2, 0.9, 1.1), + }; + let open = door_open_transform( + closed, + core::f32::consts::FRAC_PI_2, + 2.0, + vec3(2.0, 0.0, 0.0), + 3.0, + ); + assert!((open.pos.x - 10.0).abs() < 1e-5); + assert!((open.pos.y - 1.5).abs() < 1e-5); + assert!((open.pos.z - 14.0).abs() < 1e-5); + assert_eq!(open.rot, closed.rot); + assert_eq!(open.scale, closed.scale); + assert_eq!(door_pose(closed, open, false), closed); + } + #[test] + fn parse_hex_basic() { + assert_eq!(parse_hex("#ff0000"), [1.0, 0.0, 0.0, 1.0]); + } +} diff --git a/client-rust/source/app/src/world/streamed.rs b/client-rust/source/app/src/world/streamed.rs new file mode 100644 index 00000000..2878b8e4 --- /dev/null +++ b/client-rust/source/app/src/world/streamed.rs @@ -0,0 +1,1250 @@ +//! Streamed world-entity families → ECS presentation. Every AOI-scoped +//! section the authority streams (placed extractors, camps, player corpses, +//! farm plots, parcels, player-built structures) gets an explicit, pooled, +//! state-keyed presentation reconciled against the store each frame: +//! entities spawn on first sight, restyle only when their decoded state +//! changes, and despawn the frame their row leaves the stream — the +//! `placedCamps` reconcile pattern from the reference renderers +//! (`extractors.ts`, `camps.ts`, `playerCorpses.ts`, `crops.ts`, +//! `building/renderer.ts`). Steady state (no row/state churn) touches no ECS +//! entity and allocates nothing. +//! +//! Optional GLB models (extractor category models, the scout podtent + +//! campfire) load through the platform asset reader; a miss records a typed +//! [`WorldAssetIssue`] once and the instance renders the explicit placeholder +//! marker instead — never invisible, never silent. + +use std::collections::{HashMap, HashSet}; + +use serde_json::Value; +use successor_engine_core::ecs::{Entity, WorldOps}; +use successor_engine_core::math::{vec3, Mat4, Quat, Vec3}; +use successor_engine_render::components::{ + HeightCutaway, MaterialId, MeshId, MeshRenderer, SkinRef, Transform, +}; +use successor_engine_render::gpu::Gpu; +use successor_engine_render::renderer::{MaterialDesc, Renderer}; + +use crate::game::authority::AuthorityStore; +use crate::world::area::fnv1a32; +use crate::world::chunks::TerrainStreamer; +use crate::world::WORLD_UNITS_PER_CELL; +use crate::GameWorld; + +/// Typed optional world-asset degradation (bounded, deduped). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum WorldAssetIssue { + MissingModel { stable_id: String }, +} + +const MAX_ISSUES: usize = 32; +/// Entities rendered in the main + minimap viewports. +const MASK: u32 = 0b11; + +/// FNV-1a over words — cheap state keys for change detection. +fn state_key(parts: &[u64]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for p in parts { + for b in p.to_le_bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + } + h +} + +fn qf(v: f32) -> u64 { + (v * 1000.0) as i64 as u64 +} + +struct Slot { + entities: Vec<Entity>, + key: u64, + mark: u64, +} + +#[derive(Default)] +struct Pool { + slots: HashMap<String, Slot>, +} + +impl Pool { + /// True if the row is unchanged (slot marked); false → caller respawns. + /// A changed slot has its entities despawned here. + fn keep_if_unchanged( + &mut self, + world: &mut GameWorld, + id: &str, + key: u64, + generation: u64, + ) -> bool { + if let Some(slot) = self.slots.get_mut(id) { + if slot.key == key { + slot.mark = generation; + return true; + } + for e in slot.entities.drain(..) { + world.destroy(e); + } + } + false + } + + fn insert(&mut self, id: String, entities: Vec<Entity>, key: u64, generation: u64) { + self.slots.insert( + id, + Slot { + entities, + key, + mark: generation, + }, + ); + } + + /// Sweep every slot not marked with `generation`. + fn sweep(&mut self, world: &mut GameWorld, generation: u64) { + self.slots.retain(|_, slot| { + if slot.mark == generation { + true + } else { + for e in &slot.entities { + world.destroy(*e); + } + false + } + }); + } +} + +/// A cached optional GLB model (uploaded once) or its typed absence. +enum ModelSlot { + Loaded(crate::assets::model::StaticModel), + Missing, +} + +/// Decoded farm-tile presentation (unit-testable without a GPU). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FarmTileView { + pub cell_x: f32, + pub cell_y: f32, + pub tilled: bool, + pub moisture01: f32, + /// 0..1 growth (stage / stageCount); None = no crop. + pub growth: Option<f32>, + pub mature: bool, + pub blighted: bool, +} + +/// Decode one `FarmTileVM`; malformed tiles are skipped (fail closed). +pub fn decode_farm_tile(tile: &Value) -> Option<FarmTileView> { + let cell_x = tile.get("cellX")?.as_f64()? as f32; + let cell_y = tile.get("cellY")?.as_f64()? as f32; + if !cell_x.is_finite() || !cell_y.is_finite() { + return None; + } + let tilled = tile.get("tilled").and_then(Value::as_bool).unwrap_or(false); + let moisture01 = (tile + .get("moisturePct") + .and_then(Value::as_f64) + .unwrap_or(0.0) as f32 + / 100.0) + .clamp(0.0, 1.0); + let crop = tile.get("crop").filter(|c| !c.is_null()); + let (growth, mature, blighted) = match crop { + Some(c) => { + let stage = c.get("stage").and_then(Value::as_f64).unwrap_or(0.0) as f32; + let count = c + .get("stageCount") + .and_then(Value::as_f64) + .unwrap_or(1.0) + .max(1.0) as f32; + let mature = c.get("mature").and_then(Value::as_bool).unwrap_or(false); + let blight = c + .get("blight") + .and_then(Value::as_str) + .map(|b| b != "none" && !b.is_empty()) + .unwrap_or(false); + (Some((stage / count).clamp(0.0, 1.0)), mature, blight) + } + None => (None, false, false), + }; + Some(FarmTileView { + cell_x, + cell_y, + tilled, + moisture01, + growth, + mature, + blighted, + }) +} + +/// Extractor family label → model category (mirrors `extractors.ts` GLB map). +pub fn extractor_category(family_label: &str) -> &'static str { + let lower = family_label.to_ascii_lowercase(); + if lower.contains("gas") { + "gas" + } else if lower.contains("chem") { + "chemical" + } else if lower.contains("water") || lower.contains("liquid") { + "water" + } else { + "mineral" + } +} + +pub struct StreamedWorld { + extractors: Pool, + camps: Pool, + corpses: Pool, + farm_tiles: Pool, + parcels: Pool, + building: Pool, + cutaway_states: HashMap<String, crate::world::cutaway::CutawayState>, + cutaway_cells: HashSet<String>, + cutaway_amount: f32, + generation: u64, + /// stable id → uploaded model (or typed absence). + models: HashMap<String, ModelSlot>, + issues: Vec<WorldAssetIssue>, + // Shared primitive resources (built on first use). + cube: Option<MeshId>, + materials: HashMap<u32, MaterialId>, + /// Character-scoped world waypoint beam (datapad-owned; scene sets it). + waypoint: Option<(f32, f32)>, + waypoint_entity: Option<Entity>, + waypoint_pulse: f32, + /// Scratch for farm tile ids (reused). + tile_id_scratch: String, +} + +impl Default for StreamedWorld { + fn default() -> Self { + Self::new() + } +} + +impl StreamedWorld { + pub fn new() -> Self { + Self { + extractors: Pool::default(), + camps: Pool::default(), + corpses: Pool::default(), + farm_tiles: Pool::default(), + parcels: Pool::default(), + building: Pool::default(), + cutaway_states: HashMap::new(), + cutaway_cells: HashSet::new(), + cutaway_amount: 0.0, + generation: 0, + models: HashMap::new(), + issues: Vec::new(), + cube: None, + materials: HashMap::new(), + waypoint: None, + waypoint_entity: None, + waypoint_pulse: 0.0, + tile_id_scratch: String::with_capacity(64), + } + } + + pub fn issues(&self) -> &[WorldAssetIssue] { + &self.issues + } + + /// Set/clear the world waypoint beam target (sim cells). + pub fn set_waypoint(&mut self, target: Option<(f32, f32)>) { + self.waypoint = target; + } + + /// Drop every spawned entity (area transition: area-scoped state must be + /// released before the new area builds). + pub fn clear(&mut self, world: &mut GameWorld) { + self.generation += 1; + let generation = self.generation; + self.extractors.sweep(world, generation); + self.camps.sweep(world, generation); + self.corpses.sweep(world, generation); + self.farm_tiles.sweep(world, generation); + self.parcels.sweep(world, generation); + self.building.sweep(world, generation); + self.cutaway_states.clear(); + self.cutaway_cells.clear(); + self.cutaway_amount = 0.0; + if let Some(e) = self.waypoint_entity.take() { + world.destroy(e); + } + world.flush(); + } + + fn record(&mut self, issue: WorldAssetIssue) { + if self.issues.len() < MAX_ISSUES && !self.issues.contains(&issue) { + self.issues.push(issue); + } + } + + fn cube<G: Gpu>(&mut self, renderer: &mut Renderer, gpu: &mut G) -> MeshId { + *self.cube.get_or_insert_with(|| { + let (v, i) = successor_engine_render::primitives::cube(); + renderer.upload_mesh(gpu, &v, &i) + }) + } + + fn material(&mut self, renderer: &mut Renderer, rgba: [f32; 4]) -> MaterialId { + let key = (((rgba[0] * 255.0) as u32) << 24) + | (((rgba[1] * 255.0) as u32) << 16) + | (((rgba[2] * 255.0) as u32) << 8) + | ((rgba[3] * 255.0) as u32); + *self.materials.entry(key).or_insert_with(|| { + renderer.add_material_desc(MaterialDesc { + base_color: rgba, + blend: rgba[3] < 1.0, + ..MaterialDesc::default() + }) + }) + } + + /// Ensure a model is cached; true when loaded, false → typed miss. + fn model<G: Gpu>( + &mut self, + renderer: &mut Renderer, + gpu: &mut G, + read: &mut dyn FnMut(&str) -> Option<Vec<u8>>, + stable_id: &str, + ) -> bool { + if !self.models.contains_key(stable_id) { + let slot = match read(stable_id).and_then(|bytes| { + crate::assets::model::upload_static_model(gpu, renderer, &bytes).ok() + }) { + Some(model) => ModelSlot::Loaded(model), + None => { + self.record(WorldAssetIssue::MissingModel { + stable_id: stable_id.to_string(), + }); + ModelSlot::Missing + } + }; + self.models.insert(stable_id.to_string(), slot); + } + matches!(self.models.get(stable_id), Some(ModelSlot::Loaded(_))) + } + + /// Spawn one box entity (unit cube scaled), returning it. + #[allow(clippy::too_many_arguments)] + fn spawn_box<G: Gpu>( + &mut self, + world: &mut GameWorld, + renderer: &mut Renderer, + gpu: &mut G, + center: Vec3, + scale: Vec3, + yaw: f32, + rgba: [f32; 4], + ) -> Entity { + let mesh = self.cube(renderer, gpu); + let material = self.material(renderer, rgba); + let e = world.spawn(); + world.set_component( + e, + Transform { + pos: center, + rot: Quat::from_yaw(yaw), + scale, + }, + ); + world.set_component( + e, + MeshRenderer { + mesh, + material, + viewport_mask: MASK, + skin: SkinRef::NONE, + }, + ); + e + } + + /// Spawn a loaded model's parts at a placement; appends the entities. + fn spawn_model( + &self, + world: &mut GameWorld, + stable_id: &str, + placement: Mat4, + out: &mut Vec<Entity>, + ) { + let Some(ModelSlot::Loaded(model)) = self.models.get(stable_id) else { + return; + }; + out.reserve(model.parts.len() + model.lights.len()); + for part in &model.parts { + let (pos, rot, scale) = placement.mul(part.local).to_trs(); + let entity = world.spawn(); + world.set_component(entity, Transform { pos, rot, scale }); + world.set_component( + entity, + MeshRenderer { + mesh: part.mesh, + material: part.material, + viewport_mask: MASK, + skin: SkinRef::NONE, + }, + ); + out.push(entity); + } + for light in &model.lights { + let (pos, rot, scale) = placement.mul(light.local).to_trs(); + let entity = world.spawn(); + world.set_component(entity, Transform { pos, rot, scale }); + world.set_component( + entity, + successor_engine_render::components::PointLight { + color: light.color, + intensity: light.intensity, + radius: light.radius, + }, + ); + out.push(entity); + } + } + + /// The explicit missing-asset marker: a magenta pylon. Any optional model + /// that fails to load renders this instead of nothing. + fn spawn_missing_marker<G: Gpu>( + &mut self, + world: &mut GameWorld, + renderer: &mut Renderer, + gpu: &mut G, + at: Vec3, + out: &mut Vec<Entity>, + ) { + let e = self.spawn_box( + world, + renderer, + gpu, + at.add(vec3(0.0, 0.6, 0.0)), + vec3(0.35, 1.2, 0.35), + core::f32::consts::FRAC_PI_4, + [0.9, 0.15, 0.75, 1.0], + ); + out.push(e); + } + + /// Reconcile every streamed family against the store. Terrain supplies + /// ground heights; `read` supplies optional models; `area_id` scopes + /// area-tagged rows. + #[allow(clippy::too_many_arguments)] + pub fn sync<G: Gpu>( + &mut self, + world: &mut GameWorld, + renderer: &mut Renderer, + gpu: &mut G, + terrain: &TerrainStreamer, + store: &AuthorityStore, + area_id: &str, + read: &mut dyn FnMut(&str) -> Option<Vec<u8>>, + dt: f32, + ) { + self.generation += 1; + let generation = self.generation; + + // ── Placed extractors ──────────────────────────────────────────── + for row in &store.placed_extractors { + let Some(id) = row.get("extractorId").and_then(Value::as_str) else { + continue; + }; + if row.get("areaId").and_then(Value::as_str) != Some(area_id) { + continue; + } + let (Some(cx), Some(cy)) = ( + row.get("cellX").and_then(Value::as_f64), + row.get("cellY").and_then(Value::as_f64), + ) else { + continue; + }; + let mode = row.get("mode").and_then(Value::as_str).unwrap_or("idle"); + let hopper = row.get("hopperPct").and_then(Value::as_f64).unwrap_or(0.0) as f32; + let battery = row.get("batteryPct").and_then(Value::as_f64).unwrap_or(0.0) as f32; + let family = row.get("familyLabel").and_then(Value::as_str).unwrap_or(""); + let key = state_key(&[ + qf(cx as f32), + qf(cy as f32), + fnv1a32(mode) as u64, + qf(hopper), + qf(battery), + ]); + if self + .extractors + .keep_if_unchanged(world, id, key, generation) + { + continue; + } + let wx = (cx as f32 + 0.5) * WORLD_UNITS_PER_CELL; + let wz = (cy as f32 + 0.5) * WORLD_UNITS_PER_CELL; + let ground = vec3(wx, terrain.height_at(wx, wz), wz); + let category = extractor_category(family); + let stable_id = format!("assets/world-items/extractor_{category}.glb"); + let mut entities = Vec::new(); + if self.model(renderer, gpu, read, &stable_id) { + // Authored ~0.6 m; reference upscales 1.25× for readability. + let placement = Mat4::from_trs(ground, Quat::from_yaw(0.0), vec3(1.25, 1.25, 1.25)); + self.spawn_model(world, &stable_id, placement, &mut entities); + } else { + self.spawn_missing_marker(world, renderer, gpu, ground, &mut entities); + } + // State column: hopper fill (amber), battery sliver (cyan), and + // a mode lamp (green manual / blue battery / dark idle). + let fill = hopper.clamp(0.0, 100.0) / 100.0; + if fill > 0.01 { + let h = 0.5 * fill; + let e = self.spawn_box( + world, + renderer, + gpu, + ground.add(vec3(0.55, h * 0.5, 0.0)), + vec3(0.1, h.max(0.02), 0.1), + 0.0, + [0.94, 0.77, 0.38, 1.0], + ); + entities.push(e); + } + if battery > 0.5 { + let e = self.spawn_box( + world, + renderer, + gpu, + ground.add(vec3(-0.55, 0.15, 0.0)), + vec3(0.1, 0.3 * (battery / 100.0).clamp(0.05, 1.0), 0.1), + 0.0, + [0.35, 0.8, 0.9, 1.0], + ); + entities.push(e); + } + let lamp = match mode { + "manual" => [0.35, 0.9, 0.4, 1.0], + "battery" => [0.35, 0.55, 0.95, 1.0], + _ => [0.25, 0.25, 0.25, 1.0], + }; + let e = self.spawn_box( + world, + renderer, + gpu, + ground.add(vec3(0.0, 0.95, 0.0)), + vec3(0.08, 0.08, 0.08), + 0.0, + lamp, + ); + entities.push(e); + self.extractors + .insert(id.to_string(), entities, key, generation); + } + self.extractors.sweep(world, generation); + + // ── Placed camps ───────────────────────────────────────────────── + for row in &store.placed_camps { + let Some(id) = row.get("campId").and_then(Value::as_str) else { + continue; + }; + if row.get("areaId").and_then(Value::as_str) != Some(area_id) { + continue; + } + let (Some(cx), Some(cy)) = ( + row.get("cellX").and_then(Value::as_f64), + row.get("cellY").and_then(Value::as_f64), + ) else { + continue; + }; + let packing = row + .get("abandonSecondsRemaining") + .and_then(Value::as_f64) + .is_some(); + let key = state_key(&[qf(cx as f32), qf(cy as f32), packing as u64]); + if self.camps.keep_if_unchanged(world, id, key, generation) { + continue; + } + let wx = (cx as f32 + 0.5) * WORLD_UNITS_PER_CELL; + let wz = (cy as f32 + 0.5) * WORLD_UNITS_PER_CELL; + let ground = vec3(wx, terrain.height_at(wx, wz), wz); + let mut entities = Vec::new(); + const TENT: &str = "assets/world-items/podtent_scout.glb"; + const FIRE: &str = "assets/world-items/campfire_scout.glb"; + // Pack-up presentation: the armed shelter shrinks toward its crate. + let tent_scale = if packing { 0.6 } else { 1.0 }; + if self.model(renderer, gpu, read, TENT) { + let placement = Mat4::from_trs( + ground, + Quat::from_yaw(0.0), + vec3(tent_scale, tent_scale, tent_scale), + ); + self.spawn_model(world, TENT, placement, &mut entities); + } else { + self.spawn_missing_marker(world, renderer, gpu, ground, &mut entities); + } + let fire_at = ground.add(vec3(1.6, 0.0, 1.2)); + if self.model(renderer, gpu, read, FIRE) { + let placement = + Mat4::from_trs(fire_at, Quat::from_yaw(0.6), vec3(1.15, 1.15, 1.15)); + self.spawn_model(world, FIRE, placement, &mut entities); + } + self.camps.insert(id.to_string(), entities, key, generation); + } + self.camps.sweep(world, generation); + + // ── Player corpses (built-in body bag; no GLB fetch by design) ─── + for row in &store.player_corpses { + let Some(id) = row.get("id").and_then(Value::as_str) else { + continue; + }; + if row.get("areaId").and_then(Value::as_str) != Some(area_id) { + continue; + } + let (Some(x), Some(y)) = ( + row.get("x").and_then(Value::as_f64), + row.get("y").and_then(Value::as_f64), + ) else { + continue; + }; + let is_owner = row.get("isOwner").and_then(Value::as_bool).unwrap_or(false); + let key = state_key(&[qf(x as f32), qf(y as f32), is_owner as u64]); + if self.corpses.keep_if_unchanged(world, id, key, generation) { + continue; + } + let wx = (x as f32 + 0.5) * WORLD_UNITS_PER_CELL; + let wz = (y as f32 + 0.5) * WORLD_UNITS_PER_CELL; + let g = vec3(wx, terrain.height_at(wx, wz), wz); + let yaw = (fnv1a32(id) % 628) as f32 / 100.0; + let graphite = [0.16, 0.17, 0.19, 1.0]; + // Own-corpse accent: the established amber; strangers stay brass. + let strap = if is_owner { + [0.54, 0.39, 0.13, 1.0] + } else { + [0.30, 0.28, 0.26, 1.0] + }; + let tag = if is_owner { + [0.91, 0.70, 0.25, 1.0] + } else { + [0.69, 0.55, 0.34, 1.0] + }; + let mut entities = Vec::new(); + let (s, c) = yaw.sin_cos(); + let along = vec3(c, 0.0, -s); + // Flat-lying body bag + two straps + tag plate. + entities.push(self.spawn_box( + world, + renderer, + gpu, + g.add(vec3(0.0, 0.18, 0.0)), + vec3(1.8, 0.34, 0.72), + yaw, + graphite, + )); + for side in [0.45f32, -0.45] { + entities.push(self.spawn_box( + world, + renderer, + gpu, + g.add(vec3(along.x * side, 0.37, along.z * side)), + vec3(0.08, 0.04, 0.78), + yaw, + strap, + )); + } + entities.push(self.spawn_box( + world, + renderer, + gpu, + g.add(vec3(along.x * 0.8, 0.38, along.z * 0.8)), + vec3(0.16, 0.02, 0.12), + yaw, + tag, + )); + self.corpses + .insert(id.to_string(), entities, key, generation); + } + self.corpses.sweep(world, generation); + + // ── Farm plots (per-tile soil + growth) ────────────────────────── + for plot in &store.farm_plots { + if plot.get("areaId").and_then(Value::as_str) != Some(area_id) { + continue; + } + let parcel = plot + .get("parcelId") + .and_then(Value::as_str) + .unwrap_or("plot"); + let Some(tiles) = plot.get("tiles").and_then(Value::as_array) else { + continue; + }; + for tile in tiles { + let Some(view) = decode_farm_tile(tile) else { + continue; + }; + if !view.tilled && view.growth.is_none() { + continue; + } + use core::fmt::Write as _; + self.tile_id_scratch.clear(); + let _ = write!( + self.tile_id_scratch, + "{parcel}:{}:{}", + view.cell_x as i64, view.cell_y as i64 + ); + let key = state_key(&[ + view.tilled as u64, + qf(view.moisture01), + view.growth.map(qf).unwrap_or(u64::MAX), + view.mature as u64, + view.blighted as u64, + ]); + let tile_id = core::mem::take(&mut self.tile_id_scratch); + if self + .farm_tiles + .keep_if_unchanged(world, &tile_id, key, generation) + { + self.tile_id_scratch = tile_id; + continue; + } + let wx = (view.cell_x + 0.5) * WORLD_UNITS_PER_CELL; + let wz = (view.cell_y + 0.5) * WORLD_UNITS_PER_CELL; + let g = vec3(wx, terrain.height_at(wx, wz), wz); + let mut entities = Vec::new(); + if view.tilled { + // Moist soil reads darker. + let m = view.moisture01; + let soil = [ + 0.42 + (0.24 - 0.42) * m, + 0.30 + (0.17 - 0.30) * m, + 0.20 + (0.12 - 0.20) * m, + 1.0, + ]; + entities.push(self.spawn_box( + world, + renderer, + gpu, + g.add(vec3(0.0, 0.03, 0.0)), + vec3(0.94, 0.06, 0.94), + 0.0, + soil, + )); + } + if let Some(growth) = view.growth { + let h = 0.15 + growth * 0.75; + let healthy = if view.mature { + [0.45, 0.68, 0.25, 1.0] + } else { + [0.35, 0.58, 0.30, 1.0] + }; + let color = if view.blighted { + [0.48, 0.42, 0.22, 1.0] + } else { + healthy + }; + // Crossed-blade plant marker scaled by growth stage. + for (sx, sz) in [(0.08f32, 0.30f32), (0.30, 0.08)] { + entities.push(self.spawn_box( + world, + renderer, + gpu, + g.add(vec3(0.0, h * 0.5 + 0.06, 0.0)), + vec3(sx, h, sz), + 0.0, + color, + )); + } + if view.mature { + entities.push(self.spawn_box( + world, + renderer, + gpu, + g.add(vec3(0.0, h + 0.14, 0.0)), + vec3(0.12, 0.12, 0.12), + 0.6, + [0.9, 0.75, 0.3, 1.0], + )); + } + } + self.farm_tiles.insert(tile_id, entities, key, generation); + } + } + self.farm_tiles.sweep(world, generation); + + // ── Parcels (claimed-land boundary posts) ──────────────────────── + for row in &store.placed_parcels { + let Some(id) = row.get("parcelId").and_then(Value::as_str) else { + continue; + }; + if row.get("areaId").and_then(Value::as_str) != Some(area_id) { + continue; + } + let Some(rect) = row.get("rect") else { + continue; + }; + let (Some(x), Some(y), Some(w), Some(h)) = ( + rect.get("x").and_then(Value::as_f64), + rect.get("y").and_then(Value::as_f64), + rect.get("w").and_then(Value::as_f64), + rect.get("h").and_then(Value::as_f64), + ) else { + continue; + }; + let is_owner = row.get("isOwner").and_then(Value::as_bool).unwrap_or(false); + let key = state_key(&[ + qf(x as f32), + qf(y as f32), + qf(w as f32), + qf(h as f32), + is_owner as u64, + ]); + if self.parcels.keep_if_unchanged(world, id, key, generation) { + continue; + } + let color = if is_owner { + [0.91, 0.70, 0.25, 1.0] + } else { + [0.55, 0.55, 0.5, 1.0] + }; + let mut entities = Vec::new(); + let corners = [ + (x as f32, y as f32), + (x as f32 + w as f32, y as f32), + (x as f32, y as f32 + h as f32), + (x as f32 + w as f32, y as f32 + h as f32), + ]; + for (cx, cy) in corners { + let wx = cx * WORLD_UNITS_PER_CELL; + let wz = cy * WORLD_UNITS_PER_CELL; + let g = vec3(wx, terrain.height_at(wx, wz), wz); + entities.push(self.spawn_box( + world, + renderer, + gpu, + g.add(vec3(0.0, 0.55, 0.0)), + vec3(0.12, 1.1, 0.12), + 0.0, + color, + )); + } + self.parcels + .insert(id.to_string(), entities, key, generation); + } + self.parcels.sweep(world, generation); + self.cutaway_cells.clear(); + self.cutaway_amount = 0.0; + if let (Some(building), Some(player)) = ( + store.building.as_ref(), + store.actors.get(&store.player_actor_id), + ) { + if let Some(interiors) = building.get("interiors").and_then(Value::as_array) { + for interior in interiors { + if !interior + .get("roofed") + .and_then(Value::as_bool) + .unwrap_or(false) + || !interior + .get("enclosed") + .and_then(Value::as_bool) + .unwrap_or(false) + { + continue; + } + let Some(id) = interior.get("interiorId").and_then(Value::as_str) else { + continue; + }; + let Some(cells) = interior.get("cellKeys").and_then(Value::as_array) else { + continue; + }; + let mut min_x = i32::MAX; + let mut min_y = i32::MAX; + let mut max_x = i32::MIN; + let mut max_y = i32::MIN; + for cell in cells.iter().filter_map(Value::as_str) { + let Some((x, y)) = cell.split_once(':') else { + continue; + }; + let (Ok(x), Ok(y)) = (x.parse::<i32>(), y.parse::<i32>()) else { + continue; + }; + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + } + if min_x > max_x || min_y > max_y { + continue; + } + let region = crate::world::cutaway::RegionMilli { + x_milli: min_x as f64 * 1000.0, + y_milli: min_y as f64 * 1000.0, + w_milli: (max_x - min_x + 1) as f64 * 1000.0, + h_milli: (max_y - min_y + 1) as f64 * 1000.0, + }; + if !self.cutaway_states.contains_key(id) { + self.cutaway_states.insert( + id.to_string(), + crate::world::cutaway::CutawayState::default(), + ); + } + let state = self.cutaway_states.get_mut(id).expect("inserted above"); + crate::world::cutaway::sample( + state, + store.tick as f64, + &[region], + player.x as f64 * 1000.0, + player.y as f64 * 1000.0, + ); + let amount = + crate::world::cutaway::advance_fade(state, dt as f64, 0.25, false) as f32; + if amount > 0.0 { + self.cutaway_amount = self.cutaway_amount.max(amount); + self.cutaway_cells + .extend(cells.iter().filter_map(Value::as_str).map(str::to_string)); + } + } + } + } + + // ── Player-built structures (Rust-authority building projection) ─ + let components = store + .building + .as_ref() + .and_then(|p| p.get("components")) + .and_then(Value::as_array); + if let Some(components) = components { + for comp in components { + let Some(id) = comp.get("componentId").and_then(Value::as_str) else { + continue; + }; + if comp.get("areaId").and_then(Value::as_str) != Some(area_id) { + continue; + } + let (Some(cx), Some(cy)) = ( + comp.get("cellX").and_then(Value::as_f64), + comp.get("cellY").and_then(Value::as_f64), + ) else { + continue; + }; + let kind = comp.get("kind").and_then(Value::as_str).unwrap_or("wall"); + let quarters = comp + .get("rotationQuarters") + .and_then(Value::as_f64) + .unwrap_or(0.0) as i64; + let door_open = comp + .get("doorOpen") + .and_then(Value::as_bool) + .unwrap_or(false); + self.tile_id_scratch.clear(); + use core::fmt::Write; + let _ = write!(&mut self.tile_id_scratch, "{}:{}", cx as i32, cy as i32); + let cutaway_amount = if self.cutaway_cells.contains(&self.tile_id_scratch) { + self.cutaway_amount + } else { + 0.0 + }; + let wx = (cx as f32 + 0.5) * WORLD_UNITS_PER_CELL; + let wz = (cy as f32 + 0.5) * WORLD_UNITS_PER_CELL; + let g = vec3(wx, terrain.height_at(wx, wz), wz); + let cutaway = HeightCutaway { + cutoff_y: g.y + 2.5 * (2.0 / 3.0), + amount: cutaway_amount, + }; + let key = state_key(&[ + qf(cx as f32), + qf(cy as f32), + fnv1a32(kind) as u64, + quarters as u64, + door_open as u64, + ]); + if self.building.keep_if_unchanged(world, id, key, generation) { + if let Some(slot) = self.building.slots.get(id) { + for entity in &slot.entities { + world.set_component(*entity, cutaway); + } + } + continue; + } + + let yaw = quarters as f32 * core::f32::consts::FRAC_PI_2; + let primary = comp + .get("palette") + .and_then(|p| p.get("primary")) + .and_then(Value::as_str) + .map(hex_rgba) + .unwrap_or([0.55, 0.5, 0.45, 1.0]); + let mut entities = Vec::new(); + match kind { + "floor" => entities.push(self.spawn_box( + world, + renderer, + gpu, + g.add(vec3(0.0, 0.04, 0.0)), + vec3(1.0, 0.08, 1.0), + yaw, + primary, + )), + "door" => { + // Frame posts + a panel that swings open 100°. + let (s, c) = yaw.sin_cos(); + let across = vec3(c, 0.0, -s); + for side in [-0.45f32, 0.45] { + entities.push(self.spawn_box( + world, + renderer, + gpu, + g.add(vec3(across.x * side, 1.1, across.z * side)), + vec3(0.12, 2.2, 0.12), + yaw, + primary, + )); + } + let panel_yaw = if door_open { yaw + 1.75 } else { yaw }; + let (ps, pc) = panel_yaw.sin_cos(); + let panel_across = vec3(pc, 0.0, -ps); + // Hinge at the -side post; the panel extends across. + let hinge = g.add(vec3(across.x * -0.45, 0.0, across.z * -0.45)); + entities.push(self.spawn_box( + world, + renderer, + gpu, + hinge.add(vec3(panel_across.x * 0.42, 1.05, panel_across.z * 0.42)), + vec3(0.84, 2.1, 0.08), + panel_yaw, + [primary[0] * 0.85, primary[1] * 0.85, primary[2] * 0.85, 1.0], + )); + } + "roof" => entities.push(self.spawn_box( + world, + renderer, + gpu, + g.add(vec3(0.0, 2.45, 0.0)), + vec3(1.04, 0.1, 1.04), + yaw, + [primary[0] * 0.8, primary[1] * 0.8, primary[2] * 0.8, 1.0], + )), + // Walls and unrecognized kinds present as a wall slab — + // explicit; collision stays authority-owned. + _ => entities.push(self.spawn_box( + world, + renderer, + gpu, + g.add(vec3(0.0, 1.2, 0.0)), + vec3(1.0, 2.4, 0.15), + yaw, + primary, + )), + } + for entity in &entities { + world.set_component(*entity, cutaway); + } + self.building + .insert(id.to_string(), entities, key, generation); + } + } + self.building.sweep(world, generation); + + // ── Waypoint beam ──────────────────────────────────────────────── + self.waypoint_pulse += dt; + match self.waypoint { + Some((sx, sy)) => { + let wx = (sx + 0.5) * WORLD_UNITS_PER_CELL; + let wz = (sy + 0.5) * WORLD_UNITS_PER_CELL; + let base = terrain.height_at(wx, wz); + let pulse = 0.85 + 0.15 * (self.waypoint_pulse * 2.4).sin(); + if self.waypoint_entity.is_none() { + let e = self.spawn_box( + world, + renderer, + gpu, + vec3(wx, base + 6.0, wz), + vec3(0.18, 12.0, 0.18), + 0.0, + [0.98, 0.78, 0.30, 0.55], + ); + self.waypoint_entity = Some(e); + } + if let Some(e) = self.waypoint_entity { + if let Some(tr) = world.get_component::<Transform>(e) { + tr.pos = vec3(wx, base + 6.0, wz); + tr.scale = vec3(0.18 * pulse, 12.0, 0.18 * pulse); + } + } + } + None => { + if let Some(e) = self.waypoint_entity.take() { + world.destroy(e); + } + } + } + + world.flush(); + } + + /// Campfire flame emission for live camps (bounded: one particle per camp + /// per frame into the shared pool, distance-culled). + pub fn emit_camp_fx( + &self, + terrain: &TerrainStreamer, + store: &AuthorityStore, + area_id: &str, + pool: &mut successor_engine_render::fx::ParticlePool, + listener: [f32; 3], + ) { + for row in &store.placed_camps { + if row.get("areaId").and_then(Value::as_str) != Some(area_id) { + continue; + } + let (Some(cx), Some(cy)) = ( + row.get("cellX").and_then(Value::as_f64), + row.get("cellY").and_then(Value::as_f64), + ) else { + continue; + }; + let wx = (cx as f32 + 0.5) * WORLD_UNITS_PER_CELL + 1.6; + let wz = (cy as f32 + 0.5) * WORLD_UNITS_PER_CELL + 1.2; + let dx = wx - listener[0]; + let dz = wz - listener[2]; + if dx * dx + dz * dz > 60.0 * 60.0 { + continue; // visual/audible cutoff + } + let y = terrain.height_at(wx, wz) + 0.25; + pool.additive.push( + [wx, y, wz], + [0.0, 0.9, 0.0], + 0.7, + 0.16, + 0.05, + 0.8, + 0.0, + [1.0, 0.62, 0.22], + [0.7, 0.2, 0.05], + ); + } + } +} + +fn hex_rgba(s: &str) -> [f32; 4] { + let h = s.trim_start_matches('#'); + if h.len() >= 6 { + let r = u8::from_str_radix(&h[0..2], 16).unwrap_or(140) as f32 / 255.0; + let g = u8::from_str_radix(&h[2..4], 16).unwrap_or(128) as f32 / 255.0; + let b = u8::from_str_radix(&h[4..6], 16).unwrap_or(115) as f32 / 255.0; + [r, g, b, 1.0] + } else { + [0.55, 0.5, 0.45, 1.0] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::assets::model::{EmbeddedLightSource, EmbeddedPointLight, StaticModel}; + use serde_json::json; + use successor_engine_render::components::PointLight; + + #[test] + fn model_lights_follow_each_instance_and_pool_lifecycle() { + let mut streamed = StreamedWorld::new(); + streamed.models.insert( + "fixture".to_string(), + ModelSlot::Loaded(StaticModel { + parts: Vec::new(), + lights: vec![EmbeddedPointLight { + source: EmbeddedLightSource::Inferred, + node: 0, + local: Mat4::from_translation(vec3(1.0, 2.0, 3.0)), + color: [0.2, 0.7, 1.0], + intensity: 4.0, + radius: 6.0, + }], + }), + ); + let mut world = GameWorld::new(); + let mut entities = Vec::new(); + streamed.spawn_model( + &mut world, + "fixture", + Mat4::from_translation(vec3(10.0, 0.0, -2.0)), + &mut entities, + ); + streamed.spawn_model( + &mut world, + "fixture", + Mat4::from_translation(vec3(-4.0, 1.0, 8.0)), + &mut entities, + ); + assert_eq!(entities.len(), 2); + + let mut positions = Vec::new(); + let mut query = world.query2::<PointLight, Transform>(); + while let Some((_, light, transform)) = query.next() { + assert_eq!(light.color, [0.2, 0.7, 1.0]); + assert_eq!(light.intensity, 4.0); + assert_eq!(light.radius, 6.0); + positions.push(transform.pos); + } + positions.sort_by(|left, right| left.x.total_cmp(&right.x)); + assert_eq!(positions, vec![vec3(-3.0, 3.0, 11.0), vec3(11.0, 2.0, 1.0)]); + + streamed + .camps + .insert("fixture-row".to_string(), entities, 1, 0); + streamed.clear(&mut world); + assert!(world.query2::<PointLight, Transform>().next().is_none()); + } + + #[test] + fn farm_tile_decodes_growth_and_moisture() { + let tile = json!({ + "cellX": 40, "cellY": 40, "tilled": true, "moisturePct": 50, + "crop": { "species": "graincorn", "stage": 2, "stageCount": 4, + "health": "healthy", "blight": "none", "mature": false }, + "legalVerbs": [], + }); + let v = decode_farm_tile(&tile).expect("decodes"); + assert!(v.tilled); + assert!((v.moisture01 - 0.5).abs() < 1e-6); + assert_eq!(v.growth, Some(0.5)); + assert!(!v.mature); + assert!(!v.blighted); + } + + #[test] + fn farm_tile_blight_and_null_crop() { + let tile = json!({ + "cellX": 1, "cellY": 2, "tilled": true, "moisturePct": 0, "crop": null, + }); + let v = decode_farm_tile(&tile).unwrap(); + assert_eq!(v.growth, None); + + let sick = json!({ + "cellX": 1, "cellY": 2, "tilled": true, "moisturePct": 0, + "crop": { "stage": 4, "stageCount": 4, "blight": "rot", "mature": true }, + }); + let v = decode_farm_tile(&sick).unwrap(); + assert!(v.blighted); + assert!(v.mature); + assert_eq!(v.growth, Some(1.0)); + } + + #[test] + fn malformed_tile_fails_closed() { + assert_eq!(decode_farm_tile(&json!({ "tilled": true })), None); + assert_eq!(decode_farm_tile(&json!({ "cellX": "a", "cellY": 2 })), None); + } + + #[test] + fn extractor_category_routing() { + assert_eq!(extractor_category("Ferrous Metals"), "mineral"); + assert_eq!(extractor_category("Reactive Gas"), "gas"); + assert_eq!(extractor_category("Chemical Slurry"), "chemical"); + assert_eq!(extractor_category("Ground Water"), "water"); + assert_eq!(extractor_category(""), "mineral"); + } + + #[test] + fn state_keys_differ_on_state_change() { + let a = state_key(&[qf(1.0), qf(2.0), 0]); + let b = state_key(&[qf(1.0), qf(2.0), 1]); + let c = state_key(&[qf(1.0), qf(2.0), 0]); + assert_ne!(a, b); + assert_eq!(a, c); + } +} diff --git a/client-rust/source/app/src/world/terrain.rs b/client-rust/source/app/src/world/terrain.rs new file mode 100644 index 00000000..703b8608 --- /dev/null +++ b/client-rust/source/app/src/world/terrain.rs @@ -0,0 +1,598 @@ +//! Deterministic terrain painting — a byte-exact port of +//! `client-3d/src/render/terrain/procgen.ts` (TERRAIN_RULES_VERSION 6). Adjacent +//! chunks resolve identical colours at shared world coordinates because every +//! field is sampled in world space with a 32-bit integer hash (no `Math.random`, +//! cross-language deterministic). +//! +//! Constants transcribed verbatim from `client-3d/src/config.ts` +//! (`SUCCESSOR_3D_CONFIG.terrain`, `.biomes`, `.environment.wind`). The +//! `tools/successor/dump-terrain-fixture.mjs` reference (a verbatim copy of the +//! TS) pins the RGBA output; see `tests`. + +// Palettes (config.ts DESERT_BIOME / FOREST_BIOME). +const DESERT: [f64; 3] = [208.0, 165.0, 92.0]; +const SCRUB: [f64; 3] = [188.0, 151.0, 84.0]; +const HARDPAN: [f64; 3] = [224.0, 190.0, 124.0]; +const LOAM: [f64; 3] = [128.0, 110.0, 78.0]; +const MOSS: [f64; 3] = [110.0, 130.0, 78.0]; +const DUFF: [f64; 3] = [150.0, 128.0, 86.0]; + +const UINT_TO_UNIT: f64 = 1.0 / 4294967295.0; // 1 / 0xffffffff +const TAU: f64 = core::f64::consts::PI * 2.0; + +// config: terrain.texturePixels = 1024, chunkCells = 256; wind.baseDirDeg = 115. +const TERRAIN_TEXELS_PER_CELL: f64 = (1024.0 - 1.0) / 256.0; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum TerrainKind { + Desert = 0, + Scrub = 1, + Hardpan = 2, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum Biome { + #[default] + Desert, + Forest, +} + +/// One painted texel: RGBA8 + the classified kind (drives footstep audio). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Texel { + pub rgba: [u8; 4], + pub kind: TerrainKind, +} + +/// Continuous low-frequency material controls sampled by the PBR terrain path. +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct TerrainSample { + pub weights: [f32; 3], + pub macro_tint: f32, + pub kind: TerrainKind, +} + +fn wind_axis() -> (f64, f64, f64, f64) { + let rad = 115.0_f64 * core::f64::consts::PI / 180.0; + let ax = rad.cos(); + let az = rad.sin(); + (ax, az, -az, ax) // axis_x, axis_z, across_x, across_z +} + +/// Paint one terrain texel at world coordinates. +pub fn paint_terrain_pixel(seed: i32, world_x: f64, world_z: f64, biome: Biome) -> Texel { + if biome == Biome::Forest { + return paint_forest_terrain_pixel(seed, world_x, world_z); + } + let (wax, waz, wcx, wcz) = wind_axis(); + + let macro_ = fbm(seed, world_x * 0.0045, world_z * 0.0045, 0x2b01); + let scrub_field = fbm( + seed, + world_x * 0.018 + 37.17, + world_z * 0.018 - 19.31, + 0x5107, + ); + let salt_long = fbm( + seed, + world_x * 0.0075 + world_z * 0.0015, + world_z * 0.052, + 0x91af, + ); + let salt_fine = value_noise(seed, world_x * 0.022, world_z * 0.19, 0xbad5); + let hardpan_w = smoothstep( + 0.54, + 0.73, + salt_long * 0.82 + salt_fine * 0.18 + (macro_ - 0.5) * 0.1, + ); + let scrub_w = (1.0 - hardpan_w) * smoothstep(0.56, 0.75, scrub_field + (0.53 - macro_) * 0.14); + let desert_w = (1.0 - hardpan_w - scrub_w).max(0.0); + + let along = world_x * wax + world_z * waz; + let across = world_x * wcx + world_z * wcz; + let fine = value_noise(seed, world_x * 0.92, world_z * 0.92, 0x7001) * 2.0 - 1.0; + let gravel = gravel_speckle(seed, world_x, world_z) * (desert_w + scrub_w * 0.85); + let striation = + wind_striation(seed, along, across) * (desert_w + scrub_w * 0.62 + hardpan_w * 0.25); + let cracks = if hardpan_w > 0.34 { + hardpan_crack(seed, world_x, world_z, hardpan_w) + } else { + 0.0 + }; + let scrub_tuft = if scrub_w > 0.18 + && hash_unit( + seed, + (world_x * 1.55).floor() as i32, + (world_z * 1.55).floor() as i32, + 0x7a11, + ) > 0.82 + { + -10.0 * scrub_w + } else { + 0.0 + }; + let hardpan_mottle = + (value_noise(seed, world_x * 0.045, world_z * 1.18, 0x55aa) - 0.5) * 5.5 * hardpan_w; + let value_scale = 1.0 + (macro_ - 0.5) * 0.062 + fine * 0.026 + gravel + striation + cracks; + + let mut r = (DESERT[0] * desert_w + SCRUB[0] * scrub_w + HARDPAN[0] * hardpan_w) * value_scale; + let mut g = (DESERT[1] * desert_w + SCRUB[1] * scrub_w + HARDPAN[1] * hardpan_w) * value_scale; + let mut b = (DESERT[2] * desert_w + SCRUB[2] * scrub_w + HARDPAN[2] * hardpan_w) * value_scale; + + r += scrub_tuft + hardpan_mottle; + g += scrub_tuft + hardpan_mottle * 0.9; + b += scrub_tuft * 0.7 + hardpan_mottle * 0.55; + + let kind = if hardpan_w >= scrub_w && hardpan_w > 0.32 { + TerrainKind::Hardpan + } else if scrub_w > 0.32 { + TerrainKind::Scrub + } else { + TerrainKind::Desert + }; + Texel { + rgba: [clamp_byte(r), clamp_byte(g), clamp_byte(b), 255], + kind, + } +} + +/// Sample continuous material weights without baking final surface color. +pub fn sample_terrain(seed: i32, world_x: f64, world_z: f64, biome: Biome) -> TerrainSample { + if biome == Biome::Forest { + let clearing = clearing_mask_at(seed, world_x, world_z); + let clearing_blend = smoothstep(0.34, 0.78, clearing); + let canopy = 1.0 - clearing_blend; + let macro_shade = fbm( + seed, + world_x * 0.0065 + 13.7, + world_z * 0.0065 - 21.9, + 0x4f31, + ); + let moss_field = fbm(seed, world_x * 0.016 - 8.1, world_z * 0.016 + 5.4, 0x8d22); + let duff_field = fbm(seed, world_x * 0.024 + 41.3, world_z * 0.024 - 15.8, 0xa907); + let mut moss = 0.16 + moss_field * 0.24 + clearing_blend * 0.14; + let mut duff = 0.28 + duff_field * 0.28 + canopy * 0.16; + let mut loam = (1.0 - moss - duff).max(0.18); + let total = moss + duff + loam; + moss /= total; + duff /= total; + loam /= total; + let kind = if clearing_blend > 0.58 { + TerrainKind::Scrub + } else if duff >= moss && duff > 0.34 { + TerrainKind::Hardpan + } else { + TerrainKind::Desert + }; + return TerrainSample { + weights: [moss as f32, duff as f32, loam as f32], + macro_tint: (0.88 + macro_shade * 0.24 + clearing_blend * 0.08) as f32, + kind, + }; + } + + let macro_ = fbm(seed, world_x * 0.0045, world_z * 0.0045, 0x2b01); + let scrub_field = fbm( + seed, + world_x * 0.018 + 37.17, + world_z * 0.018 - 19.31, + 0x5107, + ); + let salt_long = fbm( + seed, + world_x * 0.0075 + world_z * 0.0015, + world_z * 0.052, + 0x91af, + ); + let salt_fine = value_noise(seed, world_x * 0.022, world_z * 0.19, 0xbad5); + let hardpan = smoothstep( + 0.54, + 0.73, + salt_long * 0.82 + salt_fine * 0.18 + (macro_ - 0.5) * 0.1, + ); + let scrub = (1.0 - hardpan) * smoothstep(0.56, 0.75, scrub_field + (0.53 - macro_) * 0.14); + let desert = (1.0 - hardpan - scrub).max(0.0); + let kind = if hardpan >= scrub && hardpan > 0.32 { + TerrainKind::Hardpan + } else if scrub > 0.32 { + TerrainKind::Scrub + } else { + TerrainKind::Desert + }; + TerrainSample { + weights: [desert as f32, scrub as f32, hardpan as f32], + macro_tint: (0.88 + macro_ * 0.24) as f32, + kind, + } +} + +/// Continuous visual ground height in world units. The field is sampled in +/// world space so independently streamed chunks share identical edge heights. +pub fn terrain_height(seed: i32, world_x: f64, world_z: f64, biome: Biome) -> f32 { + let height = match biome { + Biome::Desert => { + let dunes = fbm(seed, world_x * 0.0075, world_z * 0.0075, 0xd017); + let ridge_source = fbm(seed, world_x * 0.021 + 17.0, world_z * 0.021 - 31.0, 0xd018); + let ridges = 1.0 - (ridge_source * 2.0 - 1.0).abs(); + let grit = fbm(seed, world_x * 0.052 - 9.0, world_z * 0.052 + 14.0, 0xd019); + (dunes - 0.5) * 3.8 + (ridges - 0.5) * 1.15 + (grit - 0.5) * 0.28 + } + Biome::Forest => { + let rolling = fbm(seed, world_x * 0.006 + 23.0, world_z * 0.006 - 7.0, 0xf017); + let hummocks = fbm(seed, world_x * 0.027 - 15.0, world_z * 0.027 + 19.0, 0xf018); + let roots = fbm(seed, world_x * 0.071 + 3.0, world_z * 0.071 - 11.0, 0xf019); + (rolling - 0.5) * 2.6 + (hummocks - 0.5) * 0.75 + (roots - 0.5) * 0.16 + } + }; + height.clamp(-3.75, 3.75) as f32 +} + +pub fn clearing_mask_at(seed: i32, world_x: f64, world_z: f64) -> f64 { + let (wax, waz, wcx, wcz) = wind_axis(); + let along = world_x * wax + world_z * waz; + let across = world_x * wcx + world_z * wcz; + let field = fbm(seed, along * 0.0115, across * 0.0115, 0x77aa); + let grove = fbm(seed, along * 0.0127 + 5.1, across * 0.0127 - 7.4, 0x77ab); + smoothstep(0.42, 0.66, field) * smoothstep(0.44, 0.72, grove) +} + +fn paint_forest_terrain_pixel(seed: i32, world_x: f64, world_z: f64) -> Texel { + let clearing = clearing_mask_at(seed, world_x, world_z); + let clearing_blend = smoothstep(0.34, 0.78, clearing); + let canopy = 1.0 - clearing_blend; + let macro_shade = fbm( + seed, + world_x * 0.0065 + 13.7, + world_z * 0.0065 - 21.9, + 0x4f31, + ); + let moss_field = fbm(seed, world_x * 0.016 - 8.1, world_z * 0.016 + 5.4, 0x8d22); + let duff_field = fbm(seed, world_x * 0.024 + 41.3, world_z * 0.024 - 15.8, 0xa907); + let mut moss_w = 0.16 + moss_field * 0.24 + clearing_blend * 0.14; + let mut duff_w = 0.28 + duff_field * 0.28 + canopy * 0.16; + let mut loam_w = (1.0 - moss_w - duff_w).max(0.18); + let total = loam_w + moss_w + duff_w; + loam_w /= total; + moss_w /= total; + duff_w /= total; + + let canopy_r = LOAM[0] * loam_w + MOSS[0] * moss_w + DUFF[0] * duff_w; + let canopy_g = LOAM[1] * loam_w + MOSS[1] * moss_w + DUFF[1] * duff_w; + let canopy_b = LOAM[2] * loam_w + MOSS[2] * moss_w + DUFF[2] * duff_w; + let clearing_r = (MOSS[0] * 0.78 + DUFF[0] * 0.22) * 1.12; + let clearing_g = (MOSS[1] * 0.84 + DUFF[1] * 0.16) * 1.12; + let clearing_b = (MOSS[2] * 0.82 + LOAM[2] * 0.18) * 1.12; + let clear_mix = clearing_blend * 0.78; + + let leaf = + (value_noise(seed, world_x * 0.075 + 3.7, world_z * 0.075 - 2.9, 0xd4f1) - 0.5) * 0.1; + let speckle = forest_speckle(seed, world_x, world_z); + let veins = root_vein_dark(seed, world_x, world_z, canopy); + let value_scale = + 0.91 + (macro_shade - 0.5) * 0.07 + clearing_blend * 0.13 + leaf + speckle + veins; + + let r = lerp(canopy_r, clearing_r, clear_mix) * value_scale; + let g = lerp(canopy_g, clearing_g, clear_mix) * value_scale; + let b = lerp(canopy_b, clearing_b, clear_mix) * value_scale; + + let kind = if clearing_blend > 0.58 { + TerrainKind::Scrub + } else if duff_w >= moss_w && duff_w > 0.34 { + TerrainKind::Hardpan + } else { + TerrainKind::Desert + }; + Texel { + rgba: [clamp_byte(r), clamp_byte(g), clamp_byte(b), 255], + kind, + } +} + +fn forest_speckle(seed: i32, world_x: f64, world_z: f64) -> f64 { + let tx = (world_x * TERRAIN_TEXELS_PER_CELL).floor() as i32; + let tz = (world_z * TERRAIN_TEXELS_PER_CELL).floor() as i32; + (hash_unit(seed, tx, tz, 0x3eaf) * 2.0 - 1.0) * 0.04 +} + +fn root_vein_dark(seed: i32, world_x: f64, world_z: f64, canopy: f64) -> f64 { + worley_vein( + seed, + world_x * 0.112, + world_z * 0.112, + 0x6a31, + 0x6a32, + 0x6a33, + 0.18, + 0.82, + 0.09, + 0.078, + 0.024, + -0.08, + 0.18, + 0.92, + canopy, + ) +} + +fn hardpan_crack(seed: i32, world_x: f64, world_z: f64, hardpan_w: f64) -> f64 { + worley_vein( + seed, + world_x * 0.064, + world_z * 0.064, + 0x9c21, + 0xa17d, + 0x4e11, + 0.42, + 0.74, + 0.13, + 0.072, + 0.018, + -0.1, + 0.34, + 0.78, + hardpan_w, + ) +} + +/// Shared Worley-edge vein used by both `root_vein_dark` and `hardpan_crack`. +/// `skip_pct` is the hash threshold below which a cell contributes nothing +/// (`0x18`→0.18·? no — it is a 0..255 style compare); we pass it as a 0..1 +/// fraction hashed comparison exactly as the TS. +#[allow(clippy::too_many_arguments)] +fn worley_vein( + seed: i32, + cell_x: f64, + cell_z: f64, + salt_a: i32, + salt_b: i32, + salt_skip: i32, + skip: f64, + jitter: f64, + bias: f64, + edge0: f64, + edge1: f64, + strength: f64, + mask0: f64, + mask1: f64, + mask_input: f64, +) -> f64 { + let xi = cell_x.floor() as i32; + let zi = cell_z.floor() as i32; + let mut nearest = f64::INFINITY; + let mut second = f64::INFINITY; + let mut ncx = xi; + let mut ncz = zi; + for dz in -1..=1 { + for dx in -1..=1 { + let cx = xi + dx; + let cz = zi + dz; + let sx = cx as f64 + hash_unit(seed, cx, cz, salt_a) * jitter + bias; + let sz = cz as f64 + hash_unit(seed, cx, cz, salt_b) * jitter + bias; + let ddx = sx - cell_x; + let ddz = sz - cell_z; + let dist = ddx * ddx + ddz * ddz; + if dist < nearest { + second = nearest; + nearest = dist; + ncx = cx; + ncz = cz; + } else if dist < second { + second = dist; + } + } + } + if hash_unit(seed, ncx, ncz, salt_skip) < skip { + return 0.0; + } + let edge_gap = second.sqrt() - nearest.sqrt(); + let vein = smoothstep(edge0, edge1, edge_gap); + strength * vein * smoothstep(mask0, mask1, mask_input) +} + +fn wind_striation(seed: i32, along: f64, across: f64) -> f64 { + let field = fbm(seed, along * 0.0031, across * 0.0031, 0x77aa); + let field_mask = 0.18 + 0.82 * smoothstep(0.42, 0.66, field); + let wavelength = 6.0 + value_noise(seed, along * 0.006, across * 0.022, 0x6d51) * 8.0; + let drift = + (fbm(seed, along * 0.018 + 9.7, across * 0.006 - 4.3, 0x72a9) - 0.5) * wavelength * 1.35; + let phase = ((across + drift) / wavelength) * TAU; + let ridged = phase.cos() * 0.68 + (phase * 2.0 + drift * 0.19).cos() * 0.32; + let amplitude = (0.06 + + value_noise(seed, along * 0.011 - 2.1, across * 0.011 + 5.8, 0x3217) * 0.03) + * field_mask; + ridged * amplitude +} + +fn gravel_speckle(seed: i32, world_x: f64, world_z: f64) -> f64 { + let tx = (world_x * TERRAIN_TEXELS_PER_CELL).floor() as i32; + let tz = (world_z * TERRAIN_TEXELS_PER_CELL).floor() as i32; + (hash_unit(seed, tx, tz, 0xf00d) * 2.0 - 1.0) * 0.04 +} + +fn fbm(seed: i32, x: f64, y: f64, salt: i32) -> f64 { + let a = value_noise(seed, x, y, salt); + let b = value_noise(seed, x * 2.03 + 17.2, y * 2.03 - 11.7, salt + 0x1f3d); + let c = value_noise(seed, x * 4.07 - 5.9, y * 4.07 + 23.1, salt + 0x3d79); + a * 0.57 + b * 0.29 + c * 0.14 +} + +fn value_noise(seed: i32, x: f64, y: f64, salt: i32) -> f64 { + let xi = x.floor() as i32; + let yi = y.floor() as i32; + let tx = smootherstep(x - xi as f64); + let ty = smootherstep(y - yi as f64); + let a = hash_unit(seed, xi, yi, salt); + let b = hash_unit(seed, xi + 1, yi, salt); + let c = hash_unit(seed, xi, yi + 1, salt); + let d = hash_unit(seed, xi + 1, yi + 1, salt); + lerp(lerp(a, b, tx), lerp(c, d, tx), ty) +} + +/// 32-bit integer hash → [0,1). Mirrors the JS `Math.imul`/`>>>` sequence +/// exactly using wrapping i32 multiply and unsigned shifts. +fn hash_unit(seed: i32, x: i32, y: i32, salt: i32) -> f64 { + let mut h: i32 = + seed ^ x.wrapping_mul(0x27d4_eb2d_u32 as i32) ^ y.wrapping_mul(0x1656_67b1) ^ salt; + h = (h ^ (((h as u32) >> 15) as i32)).wrapping_mul(0x2c1b_3c6d); + h = (h ^ (((h as u32) >> 12) as i32)).wrapping_mul(0x297a_2d39); + let hu = (h ^ (((h as u32) >> 15) as i32)) as u32; + hu as f64 * UINT_TO_UNIT +} + +fn smootherstep(t: f64) -> f64 { + t * t * t * (t * (t * 6.0 - 15.0) + 10.0) +} + +fn smoothstep(edge0: f64, edge1: f64, value: f64) -> f64 { + let t = ((value - edge0) / (edge1 - edge0)).clamp(0.0, 1.0); + smootherstep(t) +} + +fn lerp(a: f64, b: f64, t: f64) -> f64 { + a + (b - a) * t +} + +/// `clampByte` composed with `Uint8ClampedArray`'s `ToUint8Clamp` (round half +/// to even), matching the TS assignment `target[i] = clampByte(v)` exactly. +fn clamp_byte(v: f64) -> u8 { + let x = if v <= 0.0 { + 0.0 + } else if v >= 255.0 { + 255.0 + } else { + v + 0.5 + }; + to_uint8_clamp(x) +} + +/// ECMAScript `ToUint8Clamp`: clamp to [0,255], round to nearest, ties to even. +fn to_uint8_clamp(x: f64) -> u8 { + if x <= 0.0 { + return 0; + } + if x >= 255.0 { + return 255; + } + let f = x.floor(); + let frac = x - f; + let rounded = if frac < 0.5 { + f + } else if frac > 0.5 { + f + 1.0 + } else if (f as i64) % 2 == 0 { + f + } else { + f + 1.0 + }; + rounded as u8 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deterministic() { + let a = paint_terrain_pixel(0x0d3d_071e, 12.5, 34.25, Biome::Desert); + let b = paint_terrain_pixel(0x0d3d_071e, 12.5, 34.25, Biome::Desert); + assert_eq!(a, b); + } + + #[test] + fn shared_edge_matches_across_chunks() { + // A world coordinate on a chunk boundary paints identically regardless + // of which chunk requests it (world-space sampling invariant). + let seed = 0x0d3d_071e; + let p = paint_terrain_pixel(seed, 256.0, 100.0, Biome::Desert); + let q = paint_terrain_pixel(seed, 256.0, 100.0, Biome::Desert); + assert_eq!(p.rgba, q.rgba); + } + + #[test] + fn height_field_is_deterministic_bounded_and_cross_chunk_continuous() { + for biome in [Biome::Desert, Biome::Forest] { + let first = terrain_height(42, 256.0, -31.25, biome); + let second = terrain_height(42, 256.0, -31.25, biome); + assert_eq!(first, second); + assert!((-3.75..=3.75).contains(&first)); + let epsilon = 0.001; + let left = terrain_height(42, 256.0 - epsilon, -31.25, biome); + let right = terrain_height(42, 256.0 + epsilon, -31.25, biome); + assert!((left - right).abs() < 0.01, "{biome:?}: {left} vs {right}"); + } + } + + #[test] + fn bytes_in_range_and_opaque() { + for i in 0..200 { + let x = i as f64 * 1.37; + let t = paint_terrain_pixel(42, x, x * 0.5, Biome::Desert); + assert_eq!(t.rgba[3], 255); + let f = paint_terrain_pixel(42, x, x * 0.5, Biome::Forest); + assert_eq!(f.rgba[3], 255); + } + } + + #[test] + fn to_uint8_clamp_ties_even() { + assert_eq!(to_uint8_clamp(2.5), 2); // tie → even + assert_eq!(to_uint8_clamp(3.5), 4); // tie → even + assert_eq!(to_uint8_clamp(2.4), 2); + assert_eq!(to_uint8_clamp(2.6), 3); + } + + #[test] + fn continuous_sample_matches_legacy_classification_and_normalizes_weights() { + for biome in [Biome::Desert, Biome::Forest] { + for i in -64..64 { + let x = i as f64 * 7.125; + let z = i as f64 * -3.375 + 256.0; + let sample = sample_terrain(0x0d3d_071e, x, z, biome); + let legacy = paint_terrain_pixel(0x0d3d_071e, x, z, biome); + assert_eq!(sample.kind, legacy.kind); + let total: f32 = sample.weights.iter().sum(); + assert!((total - 1.0).abs() < 1.0e-5); + assert!(sample.weights.iter().all(|weight| *weight >= 0.0)); + } + } + } + + #[test] + fn continuous_controls_are_world_space_at_chunk_edges() { + for biome in [Biome::Desert, Biome::Forest] { + let left = sample_terrain(42, 256.0, -31.25, biome); + let right = sample_terrain(42, 256.0, -31.25, biome); + assert_eq!(left, right); + } + } + + // Byte-exact cross-check against the verbatim-JS reference fixture. + // Regenerate: `node tools/successor/dump-terrain-fixture.mjs > \ + // client-rust/source/app/src/world/terrain_fixture.json` + #[test] + fn matches_reference_fixture() { + let raw = include_str!("terrain_fixture.json"); + // Minimal parse: the fixture is an array of {seed,x,z,biome,r,g,b,kind}. + let doc = successor_engine_core::json::Json::parse(raw).expect("fixture json"); + let arr = doc.as_array().expect("array"); + assert!(!arr.is_empty(), "fixture must have samples"); + for row in arr { + let seed = row.get("seed").and_then(|v| v.as_i64()).unwrap() as i32; + let x = row.get("x").and_then(|v| v.as_f64()).unwrap(); + let z = row.get("z").and_then(|v| v.as_f64()).unwrap(); + let biome = match row.get("biome").and_then(|v| v.as_str()) { + Some("forest") => Biome::Forest, + _ => Biome::Desert, + }; + let er = row.get("r").and_then(|v| v.as_i64()).unwrap() as u8; + let eg = row.get("g").and_then(|v| v.as_i64()).unwrap() as u8; + let eb = row.get("b").and_then(|v| v.as_i64()).unwrap() as u8; + let got = paint_terrain_pixel(seed, x, z, biome); + assert_eq!( + [got.rgba[0], got.rgba[1], got.rgba[2]], + [er, eg, eb], + "mismatch at seed={seed} x={x} z={z} biome={biome:?}" + ); + } + } +} diff --git a/client-rust/source/app/src/world/terrain_fixture.json b/client-rust/source/app/src/world/terrain_fixture.json new file mode 100644 index 00000000..1364b216 --- /dev/null +++ b/client-rust/source/app/src/world/terrain_fixture.json @@ -0,0 +1 @@ +[{"seed":222103326,"x":-30,"z":256,"biome":"desert","r":210,"g":167,"b":93,"kind":0},{"seed":222103326,"x":-30,"z":265.11,"biome":"desert","r":223,"g":177,"b":99,"kind":0},{"seed":222103326,"x":-30,"z":274.22,"biome":"desert","r":195,"g":155,"b":87,"kind":0},{"seed":222103326,"x":-30,"z":283.33,"biome":"desert","r":212,"g":168,"b":94,"kind":0},{"seed":222103326,"x":-30,"z":292.44,"biome":"desert","r":201,"g":159,"b":89,"kind":0},{"seed":222103326,"x":-30,"z":301.55,"biome":"desert","r":220,"g":178,"b":105,"kind":0},{"seed":222103326,"x":-30,"z":310.65999999999997,"biome":"desert","r":199,"g":159,"b":90,"kind":0},{"seed":222103326,"x":-30,"z":319.77,"biome":"desert","r":224,"g":190,"b":124,"kind":2},{"seed":222103326,"x":-16.630000000000003,"z":256,"biome":"desert","r":196,"g":155,"b":87,"kind":0},{"seed":222103326,"x":-16.630000000000003,"z":265.11,"biome":"desert","r":231,"g":183,"b":102,"kind":0},{"seed":222103326,"x":-16.630000000000003,"z":274.22,"biome":"desert","r":198,"g":157,"b":88,"kind":0},{"seed":222103326,"x":-16.630000000000003,"z":283.33,"biome":"desert","r":202,"g":160,"b":90,"kind":0},{"seed":222103326,"x":-16.630000000000003,"z":292.44,"biome":"desert","r":200,"g":158,"b":89,"kind":0},{"seed":222103326,"x":-16.630000000000003,"z":301.55,"biome":"desert","r":199,"g":160,"b":92,"kind":0},{"seed":222103326,"x":-16.630000000000003,"z":310.65999999999997,"biome":"desert","r":205,"g":163,"b":91,"kind":0},{"seed":222103326,"x":-16.630000000000003,"z":319.77,"biome":"desert","r":231,"g":196,"b":128,"kind":2},{"seed":222103326,"x":-3.2600000000000016,"z":256,"biome":"desert","r":210,"g":167,"b":93,"kind":0},{"seed":222103326,"x":-3.2600000000000016,"z":265.11,"biome":"desert","r":212,"g":169,"b":94,"kind":1},{"seed":222103326,"x":-3.2600000000000016,"z":274.22,"biome":"desert","r":205,"g":164,"b":91,"kind":1},{"seed":222103326,"x":-3.2600000000000016,"z":283.33,"biome":"desert","r":203,"g":161,"b":90,"kind":0},{"seed":222103326,"x":-3.2600000000000016,"z":292.44,"biome":"desert","r":221,"g":175,"b":98,"kind":0},{"seed":222103326,"x":-3.2600000000000016,"z":301.55,"biome":"desert","r":199,"g":158,"b":89,"kind":0},{"seed":222103326,"x":-3.2600000000000016,"z":310.65999999999997,"biome":"desert","r":205,"g":163,"b":91,"kind":0},{"seed":222103326,"x":-3.2600000000000016,"z":319.77,"biome":"desert","r":225,"g":191,"b":125,"kind":2},{"seed":222103326,"x":10.11,"z":256,"biome":"desert","r":203,"g":162,"b":90,"kind":1},{"seed":222103326,"x":10.11,"z":265.11,"biome":"desert","r":223,"g":177,"b":99,"kind":0},{"seed":222103326,"x":10.11,"z":274.22,"biome":"desert","r":187,"g":148,"b":82,"kind":1},{"seed":222103326,"x":10.11,"z":283.33,"biome":"desert","r":215,"g":171,"b":96,"kind":0},{"seed":222103326,"x":10.11,"z":292.44,"biome":"desert","r":215,"g":171,"b":95,"kind":0},{"seed":222103326,"x":10.11,"z":301.55,"biome":"desert","r":210,"g":167,"b":93,"kind":0},{"seed":222103326,"x":10.11,"z":310.65999999999997,"biome":"desert","r":213,"g":169,"b":95,"kind":0},{"seed":222103326,"x":10.11,"z":319.77,"biome":"desert","r":222,"g":188,"b":123,"kind":2},{"seed":222103326,"x":23.479999999999997,"z":256,"biome":"desert","r":206,"g":163,"b":91,"kind":0},{"seed":222103326,"x":23.479999999999997,"z":265.11,"biome":"desert","r":205,"g":163,"b":91,"kind":0},{"seed":222103326,"x":23.479999999999997,"z":274.22,"biome":"desert","r":204,"g":165,"b":96,"kind":0},{"seed":222103326,"x":23.479999999999997,"z":283.33,"biome":"desert","r":185,"g":147,"b":81,"kind":1},{"seed":222103326,"x":23.479999999999997,"z":292.44,"biome":"desert","r":209,"g":167,"b":93,"kind":0},{"seed":222103326,"x":23.479999999999997,"z":301.55,"biome":"desert","r":212,"g":169,"b":94,"kind":0},{"seed":222103326,"x":23.479999999999997,"z":310.65999999999997,"biome":"desert","r":215,"g":171,"b":96,"kind":0},{"seed":222103326,"x":23.479999999999997,"z":319.77,"biome":"desert","r":223,"g":189,"b":123,"kind":2},{"seed":222103326,"x":36.849999999999994,"z":256,"biome":"desert","r":204,"g":162,"b":90,"kind":0},{"seed":222103326,"x":36.849999999999994,"z":265.11,"biome":"desert","r":213,"g":171,"b":99,"kind":0},{"seed":222103326,"x":36.849999999999994,"z":274.22,"biome":"desert","r":218,"g":183,"b":117,"kind":2},{"seed":222103326,"x":36.849999999999994,"z":283.33,"biome":"desert","r":199,"g":158,"b":88,"kind":0},{"seed":222103326,"x":36.849999999999994,"z":292.44,"biome":"desert","r":195,"g":155,"b":87,"kind":1},{"seed":222103326,"x":36.849999999999994,"z":301.55,"biome":"desert","r":203,"g":162,"b":90,"kind":0},{"seed":222103326,"x":36.849999999999994,"z":310.65999999999997,"biome":"desert","r":200,"g":158,"b":89,"kind":0},{"seed":222103326,"x":36.849999999999994,"z":319.77,"biome":"desert","r":221,"g":186,"b":119,"kind":2},{"seed":222103326,"x":50.22,"z":256,"biome":"desert","r":214,"g":170,"b":95,"kind":0},{"seed":222103326,"x":50.22,"z":265.11,"biome":"desert","r":223,"g":187,"b":119,"kind":2},{"seed":222103326,"x":50.22,"z":274.22,"biome":"desert","r":227,"g":192,"b":126,"kind":2},{"seed":222103326,"x":50.22,"z":283.33,"biome":"desert","r":185,"g":149,"b":83,"kind":1},{"seed":222103326,"x":50.22,"z":292.44,"biome":"desert","r":191,"g":153,"b":86,"kind":1},{"seed":222103326,"x":50.22,"z":301.55,"biome":"desert","r":190,"g":152,"b":85,"kind":1},{"seed":222103326,"x":50.22,"z":310.65999999999997,"biome":"desert","r":204,"g":163,"b":91,"kind":1},{"seed":222103326,"x":50.22,"z":319.77,"biome":"desert","r":224,"g":189,"b":121,"kind":2},{"seed":222103326,"x":63.58999999999999,"z":256,"biome":"desert","r":206,"g":163,"b":91,"kind":0},{"seed":222103326,"x":63.58999999999999,"z":265.11,"biome":"desert","r":222,"g":188,"b":123,"kind":2},{"seed":222103326,"x":63.58999999999999,"z":274.22,"biome":"desert","r":222,"g":189,"b":123,"kind":2},{"seed":222103326,"x":63.58999999999999,"z":283.33,"biome":"desert","r":179,"g":142,"b":78,"kind":1},{"seed":222103326,"x":63.58999999999999,"z":292.44,"biome":"desert","r":185,"g":148,"b":83,"kind":1},{"seed":222103326,"x":63.58999999999999,"z":301.55,"biome":"desert","r":186,"g":149,"b":83,"kind":1},{"seed":222103326,"x":63.58999999999999,"z":310.65999999999997,"biome":"desert","r":193,"g":155,"b":87,"kind":1},{"seed":222103326,"x":63.58999999999999,"z":319.77,"biome":"desert","r":217,"g":181,"b":113,"kind":2},{"seed":222103326,"x":-30,"z":256,"biome":"forest","r":114,"g":106,"b":70,"kind":2},{"seed":222103326,"x":-30,"z":265.11,"biome":"forest","r":117,"g":109,"b":72,"kind":2},{"seed":222103326,"x":-30,"z":274.22,"biome":"forest","r":124,"g":116,"b":76,"kind":2},{"seed":222103326,"x":-30,"z":283.33,"biome":"forest","r":127,"g":119,"b":78,"kind":2},{"seed":222103326,"x":-30,"z":292.44,"biome":"forest","r":117,"g":110,"b":72,"kind":2},{"seed":222103326,"x":-30,"z":301.55,"biome":"forest","r":122,"g":113,"b":74,"kind":2},{"seed":222103326,"x":-30,"z":310.65999999999997,"biome":"forest","r":127,"g":118,"b":77,"kind":2},{"seed":222103326,"x":-30,"z":319.77,"biome":"forest","r":126,"g":116,"b":77,"kind":2},{"seed":222103326,"x":-16.630000000000003,"z":256,"biome":"forest","r":122,"g":113,"b":74,"kind":2},{"seed":222103326,"x":-16.630000000000003,"z":265.11,"biome":"forest","r":120,"g":112,"b":74,"kind":2},{"seed":222103326,"x":-16.630000000000003,"z":274.22,"biome":"forest","r":119,"g":111,"b":73,"kind":2},{"seed":222103326,"x":-16.630000000000003,"z":283.33,"biome":"forest","r":119,"g":110,"b":73,"kind":2},{"seed":222103326,"x":-16.630000000000003,"z":292.44,"biome":"forest","r":116,"g":108,"b":71,"kind":2},{"seed":222103326,"x":-16.630000000000003,"z":301.55,"biome":"forest","r":109,"g":101,"b":67,"kind":2},{"seed":222103326,"x":-16.630000000000003,"z":310.65999999999997,"biome":"forest","r":123,"g":114,"b":75,"kind":2},{"seed":222103326,"x":-16.630000000000003,"z":319.77,"biome":"forest","r":121,"g":112,"b":74,"kind":2},{"seed":222103326,"x":-3.2600000000000016,"z":256,"biome":"forest","r":123,"g":114,"b":75,"kind":2},{"seed":222103326,"x":-3.2600000000000016,"z":265.11,"biome":"forest","r":120,"g":111,"b":74,"kind":2},{"seed":222103326,"x":-3.2600000000000016,"z":274.22,"biome":"forest","r":112,"g":104,"b":69,"kind":2},{"seed":222103326,"x":-3.2600000000000016,"z":283.33,"biome":"forest","r":121,"g":112,"b":74,"kind":2},{"seed":222103326,"x":-3.2600000000000016,"z":292.44,"biome":"forest","r":112,"g":103,"b":68,"kind":2},{"seed":222103326,"x":-3.2600000000000016,"z":301.55,"biome":"forest","r":113,"g":105,"b":69,"kind":2},{"seed":222103326,"x":-3.2600000000000016,"z":310.65999999999997,"biome":"forest","r":112,"g":104,"b":69,"kind":2},{"seed":222103326,"x":-3.2600000000000016,"z":319.77,"biome":"forest","r":113,"g":105,"b":69,"kind":2},{"seed":222103326,"x":10.11,"z":256,"biome":"forest","r":123,"g":115,"b":75,"kind":2},{"seed":222103326,"x":10.11,"z":265.11,"biome":"forest","r":123,"g":114,"b":75,"kind":2},{"seed":222103326,"x":10.11,"z":274.22,"biome":"forest","r":126,"g":116,"b":77,"kind":2},{"seed":222103326,"x":10.11,"z":283.33,"biome":"forest","r":122,"g":113,"b":75,"kind":2},{"seed":222103326,"x":10.11,"z":292.44,"biome":"forest","r":114,"g":106,"b":70,"kind":2},{"seed":222103326,"x":10.11,"z":301.55,"biome":"forest","r":119,"g":110,"b":72,"kind":2},{"seed":222103326,"x":10.11,"z":310.65999999999997,"biome":"forest","r":123,"g":114,"b":75,"kind":2},{"seed":222103326,"x":10.11,"z":319.77,"biome":"forest","r":125,"g":115,"b":76,"kind":2},{"seed":222103326,"x":23.479999999999997,"z":256,"biome":"forest","r":122,"g":113,"b":75,"kind":2},{"seed":222103326,"x":23.479999999999997,"z":265.11,"biome":"forest","r":115,"g":106,"b":70,"kind":2},{"seed":222103326,"x":23.479999999999997,"z":274.22,"biome":"forest","r":122,"g":112,"b":74,"kind":2},{"seed":222103326,"x":23.479999999999997,"z":283.33,"biome":"forest","r":125,"g":115,"b":76,"kind":2},{"seed":222103326,"x":23.479999999999997,"z":292.44,"biome":"forest","r":114,"g":105,"b":69,"kind":2},{"seed":222103326,"x":23.479999999999997,"z":301.55,"biome":"forest","r":127,"g":117,"b":77,"kind":2},{"seed":222103326,"x":23.479999999999997,"z":310.65999999999997,"biome":"forest","r":128,"g":118,"b":78,"kind":2},{"seed":222103326,"x":23.479999999999997,"z":319.77,"biome":"forest","r":123,"g":113,"b":74,"kind":2},{"seed":222103326,"x":36.849999999999994,"z":256,"biome":"forest","r":130,"g":120,"b":79,"kind":2},{"seed":222103326,"x":36.849999999999994,"z":265.11,"biome":"forest","r":125,"g":115,"b":76,"kind":2},{"seed":222103326,"x":36.849999999999994,"z":274.22,"biome":"forest","r":128,"g":117,"b":78,"kind":2},{"seed":222103326,"x":36.849999999999994,"z":283.33,"biome":"forest","r":128,"g":117,"b":78,"kind":2},{"seed":222103326,"x":36.849999999999994,"z":292.44,"biome":"forest","r":121,"g":111,"b":73,"kind":2},{"seed":222103326,"x":36.849999999999994,"z":301.55,"biome":"forest","r":130,"g":119,"b":78,"kind":2},{"seed":222103326,"x":36.849999999999994,"z":310.65999999999997,"biome":"forest","r":126,"g":115,"b":76,"kind":2},{"seed":222103326,"x":36.849999999999994,"z":319.77,"biome":"forest","r":123,"g":112,"b":74,"kind":2},{"seed":222103326,"x":50.22,"z":256,"biome":"forest","r":130,"g":121,"b":79,"kind":2},{"seed":222103326,"x":50.22,"z":265.11,"biome":"forest","r":127,"g":116,"b":77,"kind":2},{"seed":222103326,"x":50.22,"z":274.22,"biome":"forest","r":126,"g":116,"b":77,"kind":2},{"seed":222103326,"x":50.22,"z":283.33,"biome":"forest","r":117,"g":107,"b":71,"kind":2},{"seed":222103326,"x":50.22,"z":292.44,"biome":"forest","r":120,"g":110,"b":73,"kind":2},{"seed":222103326,"x":50.22,"z":301.55,"biome":"forest","r":121,"g":111,"b":74,"kind":2},{"seed":222103326,"x":50.22,"z":310.65999999999997,"biome":"forest","r":116,"g":106,"b":70,"kind":2},{"seed":222103326,"x":50.22,"z":319.77,"biome":"forest","r":128,"g":117,"b":78,"kind":2},{"seed":222103326,"x":63.58999999999999,"z":256,"biome":"forest","r":123,"g":114,"b":75,"kind":2},{"seed":222103326,"x":63.58999999999999,"z":265.11,"biome":"forest","r":129,"g":119,"b":79,"kind":2},{"seed":222103326,"x":63.58999999999999,"z":274.22,"biome":"forest","r":127,"g":116,"b":77,"kind":2},{"seed":222103326,"x":63.58999999999999,"z":283.33,"biome":"forest","r":122,"g":111,"b":74,"kind":2},{"seed":222103326,"x":63.58999999999999,"z":292.44,"biome":"forest","r":118,"g":107,"b":71,"kind":2},{"seed":222103326,"x":63.58999999999999,"z":301.55,"biome":"forest","r":119,"g":108,"b":72,"kind":2},{"seed":222103326,"x":63.58999999999999,"z":310.65999999999997,"biome":"forest","r":120,"g":110,"b":73,"kind":2},{"seed":222103326,"x":63.58999999999999,"z":319.77,"biome":"forest","r":115,"g":106,"b":70,"kind":2},{"seed":42,"x":-30,"z":256,"biome":"desert","r":207,"g":165,"b":92,"kind":0},{"seed":42,"x":-30,"z":265.11,"biome":"desert","r":220,"g":187,"b":122,"kind":2},{"seed":42,"x":-30,"z":274.22,"biome":"desert","r":218,"g":173,"b":97,"kind":0},{"seed":42,"x":-30,"z":283.33,"biome":"desert","r":224,"g":189,"b":121,"kind":2},{"seed":42,"x":-30,"z":292.44,"biome":"desert","r":214,"g":180,"b":115,"kind":2},{"seed":42,"x":-30,"z":301.55,"biome":"desert","r":207,"g":165,"b":92,"kind":0},{"seed":42,"x":-30,"z":310.65999999999997,"biome":"desert","r":199,"g":158,"b":88,"kind":0},{"seed":42,"x":-30,"z":319.77,"biome":"desert","r":206,"g":163,"b":91,"kind":0},{"seed":42,"x":-16.630000000000003,"z":256,"biome":"desert","r":213,"g":169,"b":94,"kind":0},{"seed":42,"x":-16.630000000000003,"z":265.11,"biome":"desert","r":221,"g":188,"b":123,"kind":2},{"seed":42,"x":-16.630000000000003,"z":274.22,"biome":"desert","r":210,"g":167,"b":93,"kind":0},{"seed":42,"x":-16.630000000000003,"z":283.33,"biome":"desert","r":220,"g":187,"b":122,"kind":2},{"seed":42,"x":-16.630000000000003,"z":292.44,"biome":"desert","r":214,"g":177,"b":110,"kind":2},{"seed":42,"x":-16.630000000000003,"z":301.55,"biome":"desert","r":217,"g":172,"b":96,"kind":0},{"seed":42,"x":-16.630000000000003,"z":310.65999999999997,"biome":"desert","r":203,"g":161,"b":90,"kind":0},{"seed":42,"x":-16.630000000000003,"z":319.77,"biome":"desert","r":206,"g":164,"b":91,"kind":0},{"seed":42,"x":-3.2600000000000016,"z":256,"biome":"desert","r":218,"g":173,"b":97,"kind":0},{"seed":42,"x":-3.2600000000000016,"z":265.11,"biome":"desert","r":214,"g":174,"b":104,"kind":2},{"seed":42,"x":-3.2600000000000016,"z":274.22,"biome":"desert","r":209,"g":166,"b":93,"kind":0},{"seed":42,"x":-3.2600000000000016,"z":283.33,"biome":"desert","r":229,"g":194,"b":126,"kind":2},{"seed":42,"x":-3.2600000000000016,"z":292.44,"biome":"desert","r":204,"g":163,"b":92,"kind":0},{"seed":42,"x":-3.2600000000000016,"z":301.55,"biome":"desert","r":203,"g":161,"b":90,"kind":0},{"seed":42,"x":-3.2600000000000016,"z":310.65999999999997,"biome":"desert","r":200,"g":159,"b":89,"kind":0},{"seed":42,"x":-3.2600000000000016,"z":319.77,"biome":"desert","r":209,"g":166,"b":93,"kind":0},{"seed":42,"x":10.11,"z":256,"biome":"desert","r":205,"g":163,"b":91,"kind":0},{"seed":42,"x":10.11,"z":265.11,"biome":"desert","r":205,"g":163,"b":91,"kind":0},{"seed":42,"x":10.11,"z":274.22,"biome":"desert","r":189,"g":150,"b":84,"kind":0},{"seed":42,"x":10.11,"z":283.33,"biome":"desert","r":209,"g":171,"b":104,"kind":2},{"seed":42,"x":10.11,"z":292.44,"biome":"desert","r":197,"g":156,"b":87,"kind":0},{"seed":42,"x":10.11,"z":301.55,"biome":"desert","r":208,"g":165,"b":92,"kind":0},{"seed":42,"x":10.11,"z":310.65999999999997,"biome":"desert","r":189,"g":150,"b":84,"kind":0},{"seed":42,"x":10.11,"z":319.77,"biome":"desert","r":202,"g":161,"b":90,"kind":0},{"seed":42,"x":23.479999999999997,"z":256,"biome":"desert","r":222,"g":176,"b":99,"kind":0},{"seed":42,"x":23.479999999999997,"z":265.11,"biome":"desert","r":192,"g":153,"b":85,"kind":0},{"seed":42,"x":23.479999999999997,"z":274.22,"biome":"desert","r":194,"g":154,"b":86,"kind":0},{"seed":42,"x":23.479999999999997,"z":283.33,"biome":"desert","r":209,"g":166,"b":93,"kind":0},{"seed":42,"x":23.479999999999997,"z":292.44,"biome":"desert","r":217,"g":172,"b":96,"kind":0},{"seed":42,"x":23.479999999999997,"z":301.55,"biome":"desert","r":208,"g":165,"b":92,"kind":0},{"seed":42,"x":23.479999999999997,"z":310.65999999999997,"biome":"desert","r":207,"g":164,"b":92,"kind":0},{"seed":42,"x":23.479999999999997,"z":319.77,"biome":"desert","r":192,"g":152,"b":85,"kind":0},{"seed":42,"x":36.849999999999994,"z":256,"biome":"desert","r":201,"g":160,"b":89,"kind":0},{"seed":42,"x":36.849999999999994,"z":265.11,"biome":"desert","r":196,"g":156,"b":87,"kind":0},{"seed":42,"x":36.849999999999994,"z":274.22,"biome":"desert","r":196,"g":156,"b":87,"kind":0},{"seed":42,"x":36.849999999999994,"z":283.33,"biome":"desert","r":202,"g":160,"b":90,"kind":0},{"seed":42,"x":36.849999999999994,"z":292.44,"biome":"desert","r":225,"g":179,"b":100,"kind":0},{"seed":42,"x":36.849999999999994,"z":301.55,"biome":"desert","r":205,"g":163,"b":91,"kind":0},{"seed":42,"x":36.849999999999994,"z":310.65999999999997,"biome":"desert","r":210,"g":166,"b":93,"kind":0},{"seed":42,"x":36.849999999999994,"z":319.77,"biome":"desert","r":208,"g":165,"b":93,"kind":0},{"seed":42,"x":50.22,"z":256,"biome":"desert","r":219,"g":174,"b":97,"kind":0},{"seed":42,"x":50.22,"z":265.11,"biome":"desert","r":207,"g":164,"b":92,"kind":0},{"seed":42,"x":50.22,"z":274.22,"biome":"desert","r":204,"g":162,"b":91,"kind":0},{"seed":42,"x":50.22,"z":283.33,"biome":"desert","r":210,"g":167,"b":93,"kind":0},{"seed":42,"x":50.22,"z":292.44,"biome":"desert","r":210,"g":166,"b":93,"kind":0},{"seed":42,"x":50.22,"z":301.55,"biome":"desert","r":210,"g":166,"b":93,"kind":0},{"seed":42,"x":50.22,"z":310.65999999999997,"biome":"desert","r":205,"g":163,"b":91,"kind":0},{"seed":42,"x":50.22,"z":319.77,"biome":"desert","r":209,"g":167,"b":96,"kind":0},{"seed":42,"x":63.58999999999999,"z":256,"biome":"desert","r":197,"g":156,"b":87,"kind":0},{"seed":42,"x":63.58999999999999,"z":265.11,"biome":"desert","r":200,"g":159,"b":89,"kind":0},{"seed":42,"x":63.58999999999999,"z":274.22,"biome":"desert","r":207,"g":164,"b":92,"kind":0},{"seed":42,"x":63.58999999999999,"z":283.33,"biome":"desert","r":200,"g":159,"b":89,"kind":0},{"seed":42,"x":63.58999999999999,"z":292.44,"biome":"desert","r":210,"g":167,"b":93,"kind":0},{"seed":42,"x":63.58999999999999,"z":301.55,"biome":"desert","r":192,"g":152,"b":85,"kind":0},{"seed":42,"x":63.58999999999999,"z":310.65999999999997,"biome":"desert","r":213,"g":169,"b":94,"kind":0},{"seed":42,"x":63.58999999999999,"z":319.77,"biome":"desert","r":194,"g":154,"b":87,"kind":0},{"seed":42,"x":-30,"z":256,"biome":"forest","r":127,"g":116,"b":77,"kind":2},{"seed":42,"x":-30,"z":265.11,"biome":"forest","r":126,"g":115,"b":76,"kind":2},{"seed":42,"x":-30,"z":274.22,"biome":"forest","r":126,"g":116,"b":77,"kind":2},{"seed":42,"x":-30,"z":283.33,"biome":"forest","r":130,"g":120,"b":79,"kind":2},{"seed":42,"x":-30,"z":292.44,"biome":"forest","r":127,"g":117,"b":77,"kind":2},{"seed":42,"x":-30,"z":301.55,"biome":"forest","r":120,"g":110,"b":73,"kind":2},{"seed":42,"x":-30,"z":310.65999999999997,"biome":"forest","r":122,"g":112,"b":74,"kind":2},{"seed":42,"x":-30,"z":319.77,"biome":"forest","r":133,"g":123,"b":81,"kind":2},{"seed":42,"x":-16.630000000000003,"z":256,"biome":"forest","r":132,"g":121,"b":80,"kind":2},{"seed":42,"x":-16.630000000000003,"z":265.11,"biome":"forest","r":132,"g":121,"b":80,"kind":2},{"seed":42,"x":-16.630000000000003,"z":274.22,"biome":"forest","r":126,"g":116,"b":77,"kind":2},{"seed":42,"x":-16.630000000000003,"z":283.33,"biome":"forest","r":123,"g":113,"b":75,"kind":2},{"seed":42,"x":-16.630000000000003,"z":292.44,"biome":"forest","r":123,"g":114,"b":75,"kind":2},{"seed":42,"x":-16.630000000000003,"z":301.55,"biome":"forest","r":118,"g":110,"b":72,"kind":2},{"seed":42,"x":-16.630000000000003,"z":310.65999999999997,"biome":"forest","r":124,"g":115,"b":76,"kind":2},{"seed":42,"x":-16.630000000000003,"z":319.77,"biome":"forest","r":132,"g":128,"b":82,"kind":2},{"seed":42,"x":-3.2600000000000016,"z":256,"biome":"forest","r":120,"g":111,"b":73,"kind":2},{"seed":42,"x":-3.2600000000000016,"z":265.11,"biome":"forest","r":126,"g":116,"b":76,"kind":2},{"seed":42,"x":-3.2600000000000016,"z":274.22,"biome":"forest","r":124,"g":114,"b":75,"kind":2},{"seed":42,"x":-3.2600000000000016,"z":283.33,"biome":"forest","r":127,"g":118,"b":78,"kind":2},{"seed":42,"x":-3.2600000000000016,"z":292.44,"biome":"forest","r":122,"g":114,"b":75,"kind":2},{"seed":42,"x":-3.2600000000000016,"z":301.55,"biome":"forest","r":119,"g":110,"b":73,"kind":2},{"seed":42,"x":-3.2600000000000016,"z":310.65999999999997,"biome":"forest","r":119,"g":111,"b":73,"kind":2},{"seed":42,"x":-3.2600000000000016,"z":319.77,"biome":"forest","r":116,"g":108,"b":71,"kind":2},{"seed":42,"x":10.11,"z":256,"biome":"forest","r":118,"g":108,"b":71,"kind":2},{"seed":42,"x":10.11,"z":265.11,"biome":"forest","r":120,"g":110,"b":73,"kind":2},{"seed":42,"x":10.11,"z":274.22,"biome":"forest","r":125,"g":115,"b":76,"kind":2},{"seed":42,"x":10.11,"z":283.33,"biome":"forest","r":118,"g":110,"b":72,"kind":2},{"seed":42,"x":10.11,"z":292.44,"biome":"forest","r":118,"g":110,"b":72,"kind":2},{"seed":42,"x":10.11,"z":301.55,"biome":"forest","r":109,"g":101,"b":67,"kind":2},{"seed":42,"x":10.11,"z":310.65999999999997,"biome":"forest","r":125,"g":116,"b":76,"kind":2},{"seed":42,"x":10.11,"z":319.77,"biome":"forest","r":121,"g":112,"b":74,"kind":2},{"seed":42,"x":23.479999999999997,"z":256,"biome":"forest","r":127,"g":118,"b":78,"kind":2},{"seed":42,"x":23.479999999999997,"z":265.11,"biome":"forest","r":121,"g":113,"b":74,"kind":2},{"seed":42,"x":23.479999999999997,"z":274.22,"biome":"forest","r":124,"g":115,"b":76,"kind":2},{"seed":42,"x":23.479999999999997,"z":283.33,"biome":"forest","r":123,"g":115,"b":76,"kind":2},{"seed":42,"x":23.479999999999997,"z":292.44,"biome":"forest","r":123,"g":115,"b":76,"kind":2},{"seed":42,"x":23.479999999999997,"z":301.55,"biome":"forest","r":118,"g":109,"b":72,"kind":2},{"seed":42,"x":23.479999999999997,"z":310.65999999999997,"biome":"forest","r":115,"g":107,"b":71,"kind":2},{"seed":42,"x":23.479999999999997,"z":319.77,"biome":"forest","r":120,"g":112,"b":74,"kind":2},{"seed":42,"x":36.849999999999994,"z":256,"biome":"forest","r":130,"g":121,"b":80,"kind":2},{"seed":42,"x":36.849999999999994,"z":265.11,"biome":"forest","r":117,"g":109,"b":72,"kind":2},{"seed":42,"x":36.849999999999994,"z":274.22,"biome":"forest","r":118,"g":110,"b":72,"kind":2},{"seed":42,"x":36.849999999999994,"z":283.33,"biome":"forest","r":122,"g":114,"b":75,"kind":2},{"seed":42,"x":36.849999999999994,"z":292.44,"biome":"forest","r":122,"g":114,"b":75,"kind":2},{"seed":42,"x":36.849999999999994,"z":301.55,"biome":"forest","r":108,"g":100,"b":66,"kind":2},{"seed":42,"x":36.849999999999994,"z":310.65999999999997,"biome":"forest","r":122,"g":113,"b":74,"kind":2},{"seed":42,"x":36.849999999999994,"z":319.77,"biome":"forest","r":117,"g":109,"b":72,"kind":2},{"seed":42,"x":50.22,"z":256,"biome":"forest","r":125,"g":117,"b":77,"kind":2},{"seed":42,"x":50.22,"z":265.11,"biome":"forest","r":112,"g":104,"b":69,"kind":2},{"seed":42,"x":50.22,"z":274.22,"biome":"forest","r":114,"g":105,"b":69,"kind":2},{"seed":42,"x":50.22,"z":283.33,"biome":"forest","r":122,"g":112,"b":74,"kind":2},{"seed":42,"x":50.22,"z":292.44,"biome":"forest","r":113,"g":104,"b":69,"kind":2},{"seed":42,"x":50.22,"z":301.55,"biome":"forest","r":124,"g":115,"b":76,"kind":2},{"seed":42,"x":50.22,"z":310.65999999999997,"biome":"forest","r":125,"g":115,"b":76,"kind":2},{"seed":42,"x":50.22,"z":319.77,"biome":"forest","r":116,"g":108,"b":71,"kind":2},{"seed":42,"x":63.58999999999999,"z":256,"biome":"forest","r":125,"g":118,"b":77,"kind":2},{"seed":42,"x":63.58999999999999,"z":265.11,"biome":"forest","r":134,"g":142,"b":87,"kind":1},{"seed":42,"x":63.58999999999999,"z":274.22,"biome":"forest","r":141,"g":150,"b":92,"kind":1},{"seed":42,"x":63.58999999999999,"z":283.33,"biome":"forest","r":126,"g":125,"b":80,"kind":2},{"seed":42,"x":63.58999999999999,"z":292.44,"biome":"forest","r":123,"g":112,"b":74,"kind":2},{"seed":42,"x":63.58999999999999,"z":301.55,"biome":"forest","r":116,"g":106,"b":70,"kind":2},{"seed":42,"x":63.58999999999999,"z":310.65999999999997,"biome":"forest","r":120,"g":110,"b":73,"kind":2},{"seed":42,"x":63.58999999999999,"z":319.77,"biome":"forest","r":126,"g":117,"b":77,"kind":2},{"seed":7,"x":-30,"z":256,"biome":"desert","r":182,"g":145,"b":79,"kind":1},{"seed":7,"x":-30,"z":265.11,"biome":"desert","r":213,"g":169,"b":95,"kind":0},{"seed":7,"x":-30,"z":274.22,"biome":"desert","r":205,"g":163,"b":91,"kind":0},{"seed":7,"x":-30,"z":283.33,"biome":"desert","r":200,"g":159,"b":89,"kind":0},{"seed":7,"x":-30,"z":292.44,"biome":"desert","r":220,"g":186,"b":121,"kind":2},{"seed":7,"x":-30,"z":301.55,"biome":"desert","r":223,"g":189,"b":124,"kind":2},{"seed":7,"x":-30,"z":310.65999999999997,"biome":"desert","r":222,"g":187,"b":120,"kind":2},{"seed":7,"x":-30,"z":319.77,"biome":"desert","r":219,"g":183,"b":116,"kind":2},{"seed":7,"x":-16.630000000000003,"z":256,"biome":"desert","r":191,"g":153,"b":86,"kind":1},{"seed":7,"x":-16.630000000000003,"z":265.11,"biome":"desert","r":194,"g":155,"b":86,"kind":1},{"seed":7,"x":-16.630000000000003,"z":274.22,"biome":"desert","r":196,"g":156,"b":87,"kind":0},{"seed":7,"x":-16.630000000000003,"z":283.33,"biome":"desert","r":212,"g":168,"b":94,"kind":0},{"seed":7,"x":-16.630000000000003,"z":292.44,"biome":"desert","r":211,"g":170,"b":100,"kind":0},{"seed":7,"x":-16.630000000000003,"z":301.55,"biome":"desert","r":224,"g":190,"b":124,"kind":2},{"seed":7,"x":-16.630000000000003,"z":310.65999999999997,"biome":"desert","r":225,"g":188,"b":119,"kind":2},{"seed":7,"x":-16.630000000000003,"z":319.77,"biome":"desert","r":214,"g":176,"b":108,"kind":2},{"seed":7,"x":-3.2600000000000016,"z":256,"biome":"desert","r":172,"g":136,"b":75,"kind":1},{"seed":7,"x":-3.2600000000000016,"z":265.11,"biome":"desert","r":208,"g":170,"b":101,"kind":1},{"seed":7,"x":-3.2600000000000016,"z":274.22,"biome":"desert","r":209,"g":167,"b":93,"kind":1},{"seed":7,"x":-3.2600000000000016,"z":283.33,"biome":"desert","r":209,"g":166,"b":93,"kind":0},{"seed":7,"x":-3.2600000000000016,"z":292.44,"biome":"desert","r":210,"g":166,"b":93,"kind":0},{"seed":7,"x":-3.2600000000000016,"z":301.55,"biome":"desert","r":229,"g":192,"b":123,"kind":2},{"seed":7,"x":-3.2600000000000016,"z":310.65999999999997,"biome":"desert","r":220,"g":183,"b":115,"kind":2},{"seed":7,"x":-3.2600000000000016,"z":319.77,"biome":"desert","r":206,"g":166,"b":96,"kind":0},{"seed":7,"x":10.11,"z":256,"biome":"desert","r":182,"g":146,"b":82,"kind":1},{"seed":7,"x":10.11,"z":265.11,"biome":"desert","r":213,"g":177,"b":110,"kind":2},{"seed":7,"x":10.11,"z":274.22,"biome":"desert","r":206,"g":164,"b":92,"kind":0},{"seed":7,"x":10.11,"z":283.33,"biome":"desert","r":200,"g":159,"b":89,"kind":0},{"seed":7,"x":10.11,"z":292.44,"biome":"desert","r":213,"g":169,"b":95,"kind":0},{"seed":7,"x":10.11,"z":301.55,"biome":"desert","r":211,"g":175,"b":108,"kind":2},{"seed":7,"x":10.11,"z":310.65999999999997,"biome":"desert","r":226,"g":187,"b":116,"kind":2},{"seed":7,"x":10.11,"z":319.77,"biome":"desert","r":207,"g":165,"b":93,"kind":0},{"seed":7,"x":23.479999999999997,"z":256,"biome":"desert","r":213,"g":169,"b":95,"kind":0},{"seed":7,"x":23.479999999999997,"z":265.11,"biome":"desert","r":216,"g":179,"b":111,"kind":2},{"seed":7,"x":23.479999999999997,"z":274.22,"biome":"desert","r":217,"g":179,"b":111,"kind":2},{"seed":7,"x":23.479999999999997,"z":283.33,"biome":"desert","r":216,"g":171,"b":96,"kind":0},{"seed":7,"x":23.479999999999997,"z":292.44,"biome":"desert","r":215,"g":174,"b":101,"kind":0},{"seed":7,"x":23.479999999999997,"z":301.55,"biome":"desert","r":204,"g":164,"b":94,"kind":0},{"seed":7,"x":23.479999999999997,"z":310.65999999999997,"biome":"desert","r":221,"g":183,"b":114,"kind":2},{"seed":7,"x":23.479999999999997,"z":319.77,"biome":"desert","r":216,"g":172,"b":96,"kind":0},{"seed":7,"x":36.849999999999994,"z":256,"biome":"desert","r":202,"g":161,"b":90,"kind":0},{"seed":7,"x":36.849999999999994,"z":265.11,"biome":"desert","r":220,"g":182,"b":114,"kind":2},{"seed":7,"x":36.849999999999994,"z":274.22,"biome":"desert","r":225,"g":191,"b":125,"kind":2},{"seed":7,"x":36.849999999999994,"z":283.33,"biome":"desert","r":205,"g":162,"b":91,"kind":0},{"seed":7,"x":36.849999999999994,"z":292.44,"biome":"desert","r":223,"g":187,"b":119,"kind":2},{"seed":7,"x":36.849999999999994,"z":301.55,"biome":"desert","r":216,"g":172,"b":96,"kind":0},{"seed":7,"x":36.849999999999994,"z":310.65999999999997,"biome":"desert","r":201,"g":169,"b":108,"kind":2},{"seed":7,"x":36.849999999999994,"z":319.77,"biome":"desert","r":213,"g":169,"b":95,"kind":0},{"seed":7,"x":50.22,"z":256,"biome":"desert","r":209,"g":166,"b":93,"kind":0},{"seed":7,"x":50.22,"z":265.11,"biome":"desert","r":216,"g":181,"b":117,"kind":2},{"seed":7,"x":50.22,"z":274.22,"biome":"desert","r":227,"g":193,"b":126,"kind":2},{"seed":7,"x":50.22,"z":283.33,"biome":"desert","r":219,"g":174,"b":97,"kind":0},{"seed":7,"x":50.22,"z":292.44,"biome":"desert","r":219,"g":186,"b":121,"kind":2},{"seed":7,"x":50.22,"z":301.55,"biome":"desert","r":210,"g":167,"b":93,"kind":0},{"seed":7,"x":50.22,"z":310.65999999999997,"biome":"desert","r":222,"g":187,"b":119,"kind":2},{"seed":7,"x":50.22,"z":319.77,"biome":"desert","r":214,"g":170,"b":95,"kind":0},{"seed":7,"x":63.58999999999999,"z":256,"biome":"desert","r":209,"g":166,"b":93,"kind":0},{"seed":7,"x":63.58999999999999,"z":265.11,"biome":"desert","r":222,"g":187,"b":121,"kind":2},{"seed":7,"x":63.58999999999999,"z":274.22,"biome":"desert","r":225,"g":191,"b":125,"kind":2},{"seed":7,"x":63.58999999999999,"z":283.33,"biome":"desert","r":208,"g":165,"b":92,"kind":0},{"seed":7,"x":63.58999999999999,"z":292.44,"biome":"desert","r":227,"g":193,"b":126,"kind":2},{"seed":7,"x":63.58999999999999,"z":301.55,"biome":"desert","r":204,"g":162,"b":91,"kind":0},{"seed":7,"x":63.58999999999999,"z":310.65999999999997,"biome":"desert","r":220,"g":183,"b":114,"kind":2},{"seed":7,"x":63.58999999999999,"z":319.77,"biome":"desert","r":203,"g":163,"b":93,"kind":0},{"seed":7,"x":-30,"z":256,"biome":"forest","r":128,"g":117,"b":78,"kind":2},{"seed":7,"x":-30,"z":265.11,"biome":"forest","r":130,"g":119,"b":79,"kind":2},{"seed":7,"x":-30,"z":274.22,"biome":"forest","r":127,"g":116,"b":77,"kind":2},{"seed":7,"x":-30,"z":283.33,"biome":"forest","r":133,"g":121,"b":80,"kind":2},{"seed":7,"x":-30,"z":292.44,"biome":"forest","r":127,"g":116,"b":77,"kind":2},{"seed":7,"x":-30,"z":301.55,"biome":"forest","r":123,"g":114,"b":75,"kind":2},{"seed":7,"x":-30,"z":310.65999999999997,"biome":"forest","r":133,"g":123,"b":81,"kind":2},{"seed":7,"x":-30,"z":319.77,"biome":"forest","r":129,"g":119,"b":79,"kind":2},{"seed":7,"x":-16.630000000000003,"z":256,"biome":"forest","r":125,"g":115,"b":76,"kind":2},{"seed":7,"x":-16.630000000000003,"z":265.11,"biome":"forest","r":120,"g":110,"b":73,"kind":2},{"seed":7,"x":-16.630000000000003,"z":274.22,"biome":"forest","r":132,"g":121,"b":80,"kind":2},{"seed":7,"x":-16.630000000000003,"z":283.33,"biome":"forest","r":129,"g":119,"b":78,"kind":2},{"seed":7,"x":-16.630000000000003,"z":292.44,"biome":"forest","r":124,"g":115,"b":76,"kind":2},{"seed":7,"x":-16.630000000000003,"z":301.55,"biome":"forest","r":124,"g":114,"b":75,"kind":2},{"seed":7,"x":-16.630000000000003,"z":310.65999999999997,"biome":"forest","r":121,"g":112,"b":74,"kind":2},{"seed":7,"x":-16.630000000000003,"z":319.77,"biome":"forest","r":121,"g":112,"b":74,"kind":2},{"seed":7,"x":-3.2600000000000016,"z":256,"biome":"forest","r":126,"g":116,"b":77,"kind":2},{"seed":7,"x":-3.2600000000000016,"z":265.11,"biome":"forest","r":126,"g":116,"b":77,"kind":2},{"seed":7,"x":-3.2600000000000016,"z":274.22,"biome":"forest","r":128,"g":117,"b":78,"kind":2},{"seed":7,"x":-3.2600000000000016,"z":283.33,"biome":"forest","r":127,"g":117,"b":77,"kind":2},{"seed":7,"x":-3.2600000000000016,"z":292.44,"biome":"forest","r":126,"g":116,"b":77,"kind":2},{"seed":7,"x":-3.2600000000000016,"z":301.55,"biome":"forest","r":122,"g":112,"b":74,"kind":2},{"seed":7,"x":-3.2600000000000016,"z":310.65999999999997,"biome":"forest","r":127,"g":116,"b":77,"kind":2},{"seed":7,"x":-3.2600000000000016,"z":319.77,"biome":"forest","r":121,"g":112,"b":74,"kind":2},{"seed":7,"x":10.11,"z":256,"biome":"forest","r":126,"g":116,"b":76,"kind":2},{"seed":7,"x":10.11,"z":265.11,"biome":"forest","r":123,"g":113,"b":75,"kind":2},{"seed":7,"x":10.11,"z":274.22,"biome":"forest","r":117,"g":107,"b":71,"kind":2},{"seed":7,"x":10.11,"z":283.33,"biome":"forest","r":128,"g":117,"b":77,"kind":2},{"seed":7,"x":10.11,"z":292.44,"biome":"forest","r":134,"g":122,"b":81,"kind":2},{"seed":7,"x":10.11,"z":301.55,"biome":"forest","r":128,"g":116,"b":77,"kind":2},{"seed":7,"x":10.11,"z":310.65999999999997,"biome":"forest","r":129,"g":123,"b":80,"kind":2},{"seed":7,"x":10.11,"z":319.77,"biome":"forest","r":136,"g":145,"b":89,"kind":1},{"seed":7,"x":23.479999999999997,"z":256,"biome":"forest","r":123,"g":113,"b":75,"kind":2},{"seed":7,"x":23.479999999999997,"z":265.11,"biome":"forest","r":114,"g":104,"b":69,"kind":2},{"seed":7,"x":23.479999999999997,"z":274.22,"biome":"forest","r":116,"g":106,"b":70,"kind":2},{"seed":7,"x":23.479999999999997,"z":283.33,"biome":"forest","r":140,"g":148,"b":91,"kind":1},{"seed":7,"x":23.479999999999997,"z":292.44,"biome":"forest","r":147,"g":156,"b":95,"kind":1},{"seed":7,"x":23.479999999999997,"z":301.55,"biome":"forest","r":135,"g":143,"b":88,"kind":1},{"seed":7,"x":23.479999999999997,"z":310.65999999999997,"biome":"forest","r":138,"g":146,"b":90,"kind":1},{"seed":7,"x":23.479999999999997,"z":319.77,"biome":"forest","r":144,"g":153,"b":94,"kind":1},{"seed":7,"x":36.849999999999994,"z":256,"biome":"forest","r":116,"g":107,"b":71,"kind":2},{"seed":7,"x":36.849999999999994,"z":265.11,"biome":"forest","r":125,"g":115,"b":76,"kind":2},{"seed":7,"x":36.849999999999994,"z":274.22,"biome":"forest","r":123,"g":112,"b":74,"kind":2},{"seed":7,"x":36.849999999999994,"z":283.33,"biome":"forest","r":136,"g":144,"b":88,"kind":1},{"seed":7,"x":36.849999999999994,"z":292.44,"biome":"forest","r":144,"g":153,"b":94,"kind":1},{"seed":7,"x":36.849999999999994,"z":301.55,"biome":"forest","r":141,"g":150,"b":92,"kind":1},{"seed":7,"x":36.849999999999994,"z":310.65999999999997,"biome":"forest","r":144,"g":153,"b":94,"kind":1},{"seed":7,"x":36.849999999999994,"z":319.77,"biome":"forest","r":131,"g":140,"b":85,"kind":1},{"seed":7,"x":50.22,"z":256,"biome":"forest","r":128,"g":117,"b":77,"kind":2},{"seed":7,"x":50.22,"z":265.11,"biome":"forest","r":129,"g":118,"b":78,"kind":2},{"seed":7,"x":50.22,"z":274.22,"biome":"forest","r":131,"g":125,"b":81,"kind":2},{"seed":7,"x":50.22,"z":283.33,"biome":"forest","r":143,"g":152,"b":93,"kind":1},{"seed":7,"x":50.22,"z":292.44,"biome":"forest","r":141,"g":150,"b":92,"kind":1},{"seed":7,"x":50.22,"z":301.55,"biome":"forest","r":142,"g":150,"b":92,"kind":1},{"seed":7,"x":50.22,"z":310.65999999999997,"biome":"forest","r":138,"g":144,"b":89,"kind":1},{"seed":7,"x":50.22,"z":319.77,"biome":"forest","r":135,"g":143,"b":88,"kind":1},{"seed":7,"x":63.58999999999999,"z":256,"biome":"forest","r":122,"g":112,"b":74,"kind":2},{"seed":7,"x":63.58999999999999,"z":265.11,"biome":"forest","r":118,"g":108,"b":72,"kind":2},{"seed":7,"x":63.58999999999999,"z":274.22,"biome":"forest","r":130,"g":119,"b":79,"kind":2},{"seed":7,"x":63.58999999999999,"z":283.33,"biome":"forest","r":134,"g":134,"b":85,"kind":2},{"seed":7,"x":63.58999999999999,"z":292.44,"biome":"forest","r":111,"g":105,"b":69,"kind":2},{"seed":7,"x":63.58999999999999,"z":301.55,"biome":"forest","r":119,"g":109,"b":72,"kind":2},{"seed":7,"x":63.58999999999999,"z":310.65999999999997,"biome":"forest","r":131,"g":121,"b":80,"kind":2},{"seed":7,"x":63.58999999999999,"z":319.77,"biome":"forest","r":127,"g":118,"b":78,"kind":2}] \ No newline at end of file diff --git a/client-rust/source/app/src/world/terrain_material.rs b/client-rust/source/app/src/world/terrain_material.rs new file mode 100644 index 00000000..587d8551 --- /dev/null +++ b/client-rust/source/app/src/world/terrain_material.rs @@ -0,0 +1,330 @@ +//! Deterministic, tileable PBR surface library for world-space terrain shading. + +use super::terrain::Biome; + +pub const TILE_SIZE: u32 = 512; +pub const SURFACE_COUNT: u32 = 3; +pub const VARIANTS_PER_SURFACE: u32 = 8; +pub const TILE_LAYERS: u32 = SURFACE_COUNT * VARIANTS_PER_SURFACE; + +#[derive(Debug)] +pub struct TerrainTiles { + /// sRGB albedo, tightly packed by array layer. + pub albedo: Vec<u8>, + /// Linear normal XY, roughness, AO, tightly packed by array layer. + pub nrma: Vec<u8>, +} + +#[derive(Clone, Copy)] +struct Surface { + color: [f32; 3], + roughness: [f32; 2], + relief: f32, + grain: f32, +} + +const DESERT: [Surface; 3] = [ + Surface { + color: [0.72, 0.49, 0.22], + roughness: [0.78, 0.94], + relief: 0.75, + grain: 0.55, + }, + Surface { + color: [0.52, 0.34, 0.14], + roughness: [0.66, 0.90], + relief: 1.10, + grain: 0.95, + }, + Surface { + color: [0.80, 0.62, 0.36], + roughness: [0.48, 0.82], + relief: 1.30, + grain: 0.34, + }, +]; + +const FOREST: [Surface; 3] = [ + Surface { + color: [0.38, 0.50, 0.22], + roughness: [0.84, 0.98], + relief: 0.80, + grain: 0.80, + }, + Surface { + color: [0.55, 0.36, 0.14], + roughness: [0.72, 0.94], + relief: 1.20, + grain: 0.92, + }, + Surface { + color: [0.30, 0.22, 0.11], + roughness: [0.38, 0.70], + relief: 0.95, + grain: 0.46, + }, +]; + +pub fn generate_terrain_tiles(biome: Biome) -> TerrainTiles { + let texels = (TILE_SIZE * TILE_SIZE * TILE_LAYERS) as usize; + let mut albedo = vec![0u8; texels * 4]; + let mut nrma = vec![0u8; texels * 4]; + let surfaces = match biome { + Biome::Desert => &DESERT, + Biome::Forest => &FOREST, + }; + for (surface_index, surface) in surfaces.iter().copied().enumerate() { + for variant in 0..VARIANTS_PER_SURFACE as usize { + let layer = surface_index * VARIANTS_PER_SURFACE as usize + variant; + let seed = 0x9e37_79b9_u32 + ^ (surface_index as u32).wrapping_mul(0x85eb_ca6b) + ^ (variant as u32).wrapping_mul(0xc2b2_ae35) + ^ if biome == Biome::Forest { + 0x4f31_8d22 + } else { + 0x2b01_5107 + }; + for y in 0..TILE_SIZE { + for x in 0..TILE_SIZE { + let height = surface_height(seed, x as i32, y as i32, surface); + let hx0 = surface_height(seed, x as i32 - 1, y as i32, surface); + let hx1 = surface_height(seed, x as i32 + 1, y as i32, surface); + let hy0 = surface_height(seed, x as i32, y as i32 - 1, surface); + let hy1 = surface_height(seed, x as i32, y as i32 + 1, surface); + let dx = (hx1 - hx0) * surface.relief; + let dy = (hy1 - hy0) * surface.relief; + let inv = (dx * dx + dy * dy + 1.0).sqrt().recip(); + let nx = -dx * inv; + let ny = -dy * inv; + let variation = 0.65 + height * 0.70; + let rough_mix = periodic_noise(seed ^ 0xa511_e9b3, x as i32, y as i32, 37); + let roughness = surface.roughness[0] + + (surface.roughness[1] - surface.roughness[0]) * rough_mix; + let cavity = ((hx0 + hx1 + hy0 + hy1) * 0.25 - height).max(0.0); + let ao = (1.0 - cavity * 0.8).clamp(0.62, 1.0); + let offset = (layer * (TILE_SIZE * TILE_SIZE) as usize + + (y * TILE_SIZE + x) as usize) + * 4; + for channel in 0..3 { + albedo[offset + channel] = to_byte(surface.color[channel] * variation); + } + albedo[offset + 3] = 255; + nrma[offset] = to_byte(nx * 0.5 + 0.5); + nrma[offset + 1] = to_byte(ny * 0.5 + 0.5); + nrma[offset + 2] = to_byte(roughness); + nrma[offset + 3] = to_byte(ao); + } + } + } + } + TerrainTiles { albedo, nrma } +} + +fn surface_height(seed: u32, x: i32, y: i32, surface: Surface) -> f32 { + let size = TILE_SIZE as i32; + let x = x.rem_euclid(size); + let y = y.rem_euclid(size); + let grit = hash01(seed, x, y) - 0.5; + let fine = periodic_noise(seed ^ 0x7f4a_7c15, x, y, 61) - 0.5; + let grain = periodic_noise(seed ^ 0xa511_e9b3, x, y, 29) - 0.5; + let broad = periodic_noise(seed ^ 0x63d8_35f1, x, y, 11) - 0.5; + (0.5 + grit * surface.grain * 0.10 + + fine * surface.grain * 0.24 + + grain * surface.grain * 0.32 + + broad * 0.12) + .clamp(0.0, 1.0) +} + +fn periodic_noise(seed: u32, x: i32, y: i32, period: i32) -> f32 { + let size = TILE_SIZE as f32; + let px = x.rem_euclid(TILE_SIZE as i32) as f32 / size * period as f32; + let py = y.rem_euclid(TILE_SIZE as i32) as f32 / size * period as f32; + let gx = px.floor() as i32; + let gy = py.floor() as i32; + let tx = px - gx as f32; + let ty = py - gy as f32; + let sx = tx * tx * (3.0 - 2.0 * tx); + let sy = ty * ty * (3.0 - 2.0 * ty); + let x0 = gx.rem_euclid(period); + let y0 = gy.rem_euclid(period); + let x1 = (gx + 1).rem_euclid(period); + let y1 = (gy + 1).rem_euclid(period); + let a = hash01(seed, x0, y0); + let b = hash01(seed, x1, y0); + let c = hash01(seed, x0, y1); + let d = hash01(seed, x1, y1); + let top = a + (b - a) * sx; + let bottom = c + (d - c) * sx; + top + (bottom - top) * sy +} + +fn hash01(seed: u32, x: i32, y: i32) -> f32 { + let mut value = + seed ^ (x as u32).wrapping_mul(0x27d4_eb2d) ^ (y as u32).wrapping_mul(0x1656_67b1); + value = (value ^ (value >> 15)).wrapping_mul(0x2c1b_3c6d); + value = (value ^ (value >> 12)).wrapping_mul(0x297a_2d39); + (value ^ (value >> 15)) as f32 / u32::MAX as f32 +} + +fn to_byte(value: f32) -> u8 { + (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8 +} + +#[derive(Clone, Copy, Debug)] +pub struct TerrainProbe { + pub luma_mean: f64, + pub luma_stddev: f64, + pub neighbor_delta: f64, + /// Mean luma difference at a quarter-frame offset. Near-zero means a + /// screen-space macro pattern visibly repeats. + pub repeat_delta: f64, +} + +pub fn probe_rgba(rgba: &[u8], width: u32, height: u32) -> Result<TerrainProbe, String> { + if rgba.len() != (width * height * 4) as usize || width < 2 || height < 2 { + return Err("terrain probe received invalid framebuffer".to_string()); + } + let y_start = height / 2; + let mut count = 0.0f64; + let mut sum = 0.0f64; + let mut sum2 = 0.0f64; + let mut neighbor_delta = 0.0f64; + let mut neighbor_count = 0.0f64; + let repeat_offset = (width / 4).max(1); + let mut repeat_delta = 0.0f64; + let mut repeat_count = 0.0f64; + for y in y_start..height { + for x in 0..width { + let offset = ((y * width + x) * 4) as usize; + let luma = pixel_luma(rgba, offset); + sum += luma; + sum2 += luma * luma; + count += 1.0; + if x > 0 { + neighbor_delta += (luma - pixel_luma(rgba, offset - 4)).abs(); + neighbor_count += 1.0; + } + if x + repeat_offset < width { + let repeat = pixel_luma(rgba, offset + repeat_offset as usize * 4); + repeat_delta += (luma - repeat).abs(); + repeat_count += 1.0; + } + } + } + let mean = sum / count; + let probe = TerrainProbe { + luma_mean: mean, + luma_stddev: (sum2 / count - mean * mean).max(0.0).sqrt(), + neighbor_delta: neighbor_delta / neighbor_count, + repeat_delta: repeat_delta / repeat_count.max(1.0), + }; + if probe.luma_stddev < 0.025 { + return Err("terrain lacks macro/material variation".to_string()); + } + if probe.neighbor_delta < 0.0005 { + return Err("terrain lacks close surface detail".to_string()); + } + if probe.neighbor_delta > 0.08 { + return Err("terrain detail aliases excessively".to_string()); + } + if probe.repeat_delta < 0.01 { + return Err("terrain contains visible macro repetition".to_string()); + } + Ok(probe) +} + +fn pixel_luma(rgba: &[u8], offset: usize) -> f64 { + (rgba[offset] as f64 * 0.299 + + rgba[offset + 1] as f64 * 0.587 + + rgba[offset + 2] as f64 * 0.114) + / 255.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tile_generation_is_deterministic_and_complete() { + let first = generate_terrain_tiles(Biome::Desert); + let second = generate_terrain_tiles(Biome::Desert); + assert_eq!(first.albedo, second.albedo); + assert_eq!(first.nrma, second.nrma); + let expected = (TILE_SIZE * TILE_SIZE * TILE_LAYERS * 4) as usize; + assert_eq!(first.albedo.len(), expected); + assert_eq!(first.nrma.len(), expected); + } + + #[test] + fn desert_and_forest_libraries_are_distinct() { + let desert = generate_terrain_tiles(Biome::Desert); + let forest = generate_terrain_tiles(Biome::Forest); + assert_ne!(desert.albedo, forest.albedo); + } + + #[test] + fn normal_roughness_ao_channels_stay_physical() { + let tiles = generate_terrain_tiles(Biome::Forest); + for texel in tiles.nrma.chunks_exact(4) { + let nx = texel[0] as f32 / 255.0 * 2.0 - 1.0; + let ny = texel[1] as f32 / 255.0 * 2.0 - 1.0; + assert!(nx * nx + ny * ny <= 1.01); + assert!((90..=252).contains(&texel[2])); + assert!((158..=255).contains(&texel[3])); + } + } + + #[test] + fn biome_profiles_cover_distinct_rough_and_smooth_surfaces() { + for surfaces in [&DESERT, &FOREST] { + let min = surfaces + .iter() + .map(|surface| surface.roughness[0]) + .fold(1.0f32, f32::min); + let max = surfaces + .iter() + .map(|surface| surface.roughness[1]) + .fold(0.0f32, f32::max); + assert!(min >= 0.35 && max <= 1.0); + assert!(max - min >= 0.45); + assert!(surfaces.iter().all(|surface| { + surface + .color + .iter() + .all(|channel| (0.0..=1.0).contains(channel)) + && surface.relief > 0.0 + && surface.grain > 0.0 + })); + } + assert_ne!(DESERT[0].color, FOREST[0].color); + } + + #[test] + fn probe_rejects_quarter_frame_macro_repetition() { + let (width, height) = (64u32, 32u32); + let mut rgba = vec![0u8; (width * height * 4) as usize]; + for y in 0..height { + for x in 0..width { + let wave = ((x % 16) as f32 / 16.0 * core::f32::consts::TAU).sin(); + let value = (128.0 + wave * 45.0) as u8; + let offset = ((y * width + x) * 4) as usize; + rgba[offset..offset + 4].copy_from_slice(&[value, value, value, 255]); + } + } + let error = probe_rgba(&rgba, width, height).expect_err("repeating terrain must fail"); + assert_eq!(error, "terrain contains visible macro repetition"); + } + + #[test] + fn height_function_wraps_at_tile_edges() { + let surface = DESERT[0]; + assert_eq!( + surface_height(7, 0, 19, surface), + surface_height(7, TILE_SIZE as i32, 19, surface) + ); + assert_eq!( + surface_height(7, 27, 0, surface), + surface_height(7, 27, TILE_SIZE as i32, surface) + ); + } +} diff --git a/client-rust/source/client-proto/Cargo.toml b/client-rust/source/client-proto/Cargo.toml new file mode 100644 index 00000000..5d9ed9a5 --- /dev/null +++ b/client-rust/source/client-proto/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "successor-client-proto" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Sans-IO Colyseus client protocol + Successor server packet types. Reuses successor-net command vocabulary." + +[dependencies] +serde.workspace = true +serde_json.workspace = true +rmp-serde.workspace = true +successor-net = { path = "../../../crates/successor-net" } + +[dev-dependencies] +tungstenite.workspace = true diff --git a/client-rust/source/client-proto/fixtures/game_acks.json b/client-rust/source/client-proto/fixtures/game_acks.json new file mode 100644 index 00000000..920b50ca --- /dev/null +++ b/client-rust/source/client-proto/fixtures/game_acks.json @@ -0,0 +1,24 @@ +{ + "_comment": "Synthetic fixture representing game.acks", + "type": "game.acks", + "acks": [ + [104, 1, 45] + ], + "playerActor": { + "id": "actor-player-1", + "label": "player", + "display_name": "Dev Player", + "areaId": "open-desert-overworld", + "x": 524.0, + "y": 520.0, + "direction": "right", + "vitals": { + "health": 100.0, + "action": 77.0, + "spirit": 50.0 + }, + "lifeState": "alive" + }, + "playerPosition": [524.0, 520.0], + "events": [] +} diff --git a/client-rust/source/client-proto/fixtures/game_delta.json b/client-rust/source/client-proto/fixtures/game_delta.json new file mode 100644 index 00000000..7ef67379 --- /dev/null +++ b/client-rust/source/client-proto/fixtures/game_delta.json @@ -0,0 +1,32 @@ +{ + "_comment": "Synthetic fixture representing game.delta", + "type": "game.delta", + "delta": { + "schema": "successor.authoritative-shard-delta.v1", + "shardId": "open-desert-persistent", + "tick": 44, + "playerActorId": "actor-player-1", + "actors": {}, + "actorPatches": { + "actor-player-1": { + "id": "actor-player-1", + "x": 523.0, + "y": 520.0, + "vitals": { + "health": 100.0, + "action": 78.0, + "spirit": 50.0 + } + } + }, + "actorRemovals": [] + }, + "receipts": [ + { + "commandId": 102, + "accepted": true, + "tick": 43 + } + ], + "events": [] +} diff --git a/client-rust/source/client-proto/fixtures/game_error.json b/client-rust/source/client-proto/fixtures/game_error.json new file mode 100644 index 00000000..e8679148 --- /dev/null +++ b/client-rust/source/client-proto/fixtures/game_error.json @@ -0,0 +1,6 @@ +{ + "_comment": "Synthetic fixture representing game.error", + "type": "game.error", + "code": "INVALID_COMMAND", + "message": "The move intent parameters are invalid." +} diff --git a/client-rust/source/client-proto/fixtures/game_hello.json b/client-rust/source/client-proto/fixtures/game_hello.json new file mode 100644 index 00000000..a3635823 --- /dev/null +++ b/client-rust/source/client-proto/fixtures/game_hello.json @@ -0,0 +1,30 @@ +{ + "_comment": "Synthetic fixture representing game.hello", + "type": "game.hello", + "sessionId": "test-session-id-12345", + "playerActorId": "actor-player-1", + "snapshot": { + "schema": "successor.authoritative-shard-snapshot.v1", + "shardId": "open-desert-persistent", + "tick": 42, + "playerActorId": "actor-player-1", + "actors": { + "actor-player-1": { + "id": "actor-player-1", + "label": "player", + "display_name": "Dev Player", + "areaId": "open-desert-overworld", + "x": 521.0, + "y": 520.0, + "direction": "right", + "vitals": { + "health": 100.0, + "action": 80.0, + "spirit": 50.0 + }, + "lifeState": "alive" + } + } + }, + "serverTime": "2026-07-29T12:00:00Z" +} diff --git a/client-rust/source/client-proto/fixtures/game_receipts.json b/client-rust/source/client-proto/fixtures/game_receipts.json new file mode 100644 index 00000000..8eabd93a --- /dev/null +++ b/client-rust/source/client-proto/fixtures/game_receipts.json @@ -0,0 +1,12 @@ +{ + "_comment": "Synthetic fixture representing game.receipts", + "type": "game.receipts", + "receipts": [ + { + "commandId": 103, + "accepted": true, + "tick": 44 + } + ], + "events": [] +} diff --git a/client-rust/source/client-proto/fixtures/game_snapshot.json b/client-rust/source/client-proto/fixtures/game_snapshot.json new file mode 100644 index 00000000..cdcec0e1 --- /dev/null +++ b/client-rust/source/client-proto/fixtures/game_snapshot.json @@ -0,0 +1,35 @@ +{ + "_comment": "Synthetic fixture representing game.snapshot", + "type": "game.snapshot", + "snapshot": { + "schema": "successor.authoritative-shard-snapshot.v1", + "shardId": "open-desert-persistent", + "tick": 43, + "playerActorId": "actor-player-1", + "actors": { + "actor-player-1": { + "id": "actor-player-1", + "label": "player", + "display_name": "Dev Player", + "areaId": "open-desert-overworld", + "x": 522.0, + "y": 520.0, + "direction": "right", + "vitals": { + "health": 100.0, + "action": 79.0, + "spirit": 50.0 + }, + "lifeState": "alive" + } + } + }, + "receipts": [ + { + "commandId": 101, + "accepted": true, + "tick": 42 + } + ], + "events": [] +} diff --git a/client-rust/source/client-proto/fixtures/pong.json b/client-rust/source/client-proto/fixtures/pong.json new file mode 100644 index 00000000..0cd9a180 --- /dev/null +++ b/client-rust/source/client-proto/fixtures/pong.json @@ -0,0 +1,6 @@ +{ + "_comment": "Synthetic fixture representing pong", + "type": "pong", + "requestId": "ping-req-456", + "at": 1785293204217.0 +} diff --git a/client-rust/source/client-proto/src/colyseus.rs b/client-rust/source/client-proto/src/colyseus.rs new file mode 100644 index 00000000..abf29126 --- /dev/null +++ b/client-rust/source/client-proto/src/colyseus.rs @@ -0,0 +1,193 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +// Colyseus 0.17 Protocol opcodes transcribed from node_modules/@colyseus/shared-types/src/Protocol.ts +pub mod opcodes { + pub const JOIN_ROOM: u8 = 10; + pub const ERROR: u8 = 11; + pub const LEAVE_ROOM: u8 = 12; + pub const ROOM_DATA: u8 = 13; + pub const ROOM_STATE: u8 = 14; + pub const ROOM_STATE_PATCH: u8 = 15; + pub const ROOM_DATA_BYTES: u8 = 17; + pub const PING: u8 = 18; +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SeatReservation { + pub room_id: String, + pub session_id: String, + pub process_id: Option<String>, + pub protocol: Option<String>, + pub name: Option<String>, + pub public_address: Option<String>, +} + +/// Builds the matchmaking HTTP request URL/path and body bytes. +/// Method: POST, path: `/matchmake/joinOrCreate/game` +/// Transcribed from client/node_modules/@colyseus/sdk/src/Client.ts +pub fn build_matchmake_request( + endpoint: &str, + join_options: &Value, +) -> Result<(String, Vec<u8>), serde_json::Error> { + let mut base = endpoint.to_string(); + if base.starts_with("ws://") { + base = base.replacen("ws://", "http://", 1); + } else if base.starts_with("wss://") { + base = base.replacen("wss://", "https://", 1); + } + let base = base.trim_end_matches('/'); + let url = format!("{}/matchmake/joinOrCreate/game", base); + let body = serde_json::to_vec(join_options)?; + Ok((url, body)) +} + +/// Parses the seat reservation JSON response from the matchmaker. +pub fn parse_seat_reservation(json_bytes: &[u8]) -> Result<SeatReservation, serde_json::Error> { + serde_json::from_slice(json_bytes) +} + +/// Builds the WebSocket connection URL. +/// Transcribed from client/node_modules/@colyseus/sdk/src/Client.ts (buildEndpoint) +pub fn build_ws_url(endpoint: &str, seat_res: &SeatReservation) -> String { + let mut base = endpoint.to_string(); + if base.starts_with("http://") { + base = base.replacen("http://", "ws://", 1); + } else if base.starts_with("https://") { + base = base.replacen("https://", "wss://", 1); + } + let base = base.trim_end_matches('/'); + + if let Some(proc_id) = &seat_res.process_id { + if !proc_id.is_empty() { + return format!( + "{}/{}/{}?sessionId={}", + base, proc_id, seat_res.room_id, seat_res.session_id + ); + } + } + format!( + "{}/{}?sessionId={}", + base, seat_res.room_id, seat_res.session_id + ) +} + +/// Encodes a room message `room.send(type, payload)` into msgpack bytes. +/// Format: [ROOM_DATA (13), messageType string (msgpack), payload (msgpack)] +/// Transcribed from client/node_modules/@colyseus/sdk/src/Room.ts (send) +pub fn encode_room_message<T: Serialize>( + msg_type: &str, + payload: &T, +) -> Result<Vec<u8>, rmp_serde::encode::Error> { + let mut buf = Vec::new(); + buf.push(opcodes::ROOM_DATA); + rmp_serde::encode::write(&mut buf, msg_type)?; + rmp_serde::encode::write(&mut buf, payload)?; + Ok(buf) +} + +#[derive(Debug, Clone, PartialEq)] +pub enum InboundFrame { + JoinRoom { + reconnection_token: String, + serializer_id: String, + }, + Error { + code: u16, + message: String, + }, + LeaveRoom, + RoomState(Vec<u8>), + RoomStatePatch(Vec<u8>), + RoomData { + msg_type: String, + payload: Value, + }, + RoomDataBytes { + msg_type: String, + payload: Vec<u8>, + }, + Ping, + Unknown(u8), +} + +/// Decodes an inbound WebSocket binary frame. +/// Transcribed from client/node_modules/@colyseus/sdk/src/Room.ts (onMessageCallback) +pub fn decode_inbound_frame(bytes: &[u8]) -> Result<InboundFrame, String> { + if bytes.is_empty() { + return Err("Empty frame".to_string()); + } + let code = bytes[0]; + match code { + opcodes::JOIN_ROOM => { + let mut offset = 1; + if bytes.len() <= offset { + return Err("Malformed JOIN_ROOM frame".to_string()); + } + let token_len = bytes[offset] as usize; + offset += 1; + if bytes.len() < offset + token_len { + return Err("Malformed JOIN_ROOM token".to_string()); + } + let token = String::from_utf8_lossy(&bytes[offset..offset + token_len]).into_owned(); + offset += token_len; + + if bytes.len() <= offset { + return Err("Malformed JOIN_ROOM frame trailing".to_string()); + } + let serializer_len = bytes[offset] as usize; + offset += 1; + if bytes.len() < offset + serializer_len { + return Err("Malformed JOIN_ROOM serializer".to_string()); + } + let serializer = + String::from_utf8_lossy(&bytes[offset..offset + serializer_len]).into_owned(); + + Ok(InboundFrame::JoinRoom { + reconnection_token: token, + serializer_id: serializer, + }) + } + opcodes::ERROR => { + let mut cursor = std::io::Cursor::new(&bytes[1..]); + let mut de = rmp_serde::Deserializer::new(&mut cursor); + let code: u16 = Deserialize::deserialize(&mut de) + .map_err(|e| format!("Failed to decode error code: {}", e))?; + let message: String = Deserialize::deserialize(&mut de) + .map_err(|e| format!("Failed to decode error message: {}", e))?; + Ok(InboundFrame::Error { code, message }) + } + opcodes::LEAVE_ROOM => Ok(InboundFrame::LeaveRoom), + opcodes::ROOM_STATE => Ok(InboundFrame::RoomState(bytes[1..].to_vec())), + opcodes::ROOM_STATE_PATCH => Ok(InboundFrame::RoomStatePatch(bytes[1..].to_vec())), + opcodes::ROOM_DATA => { + let mut cursor = std::io::Cursor::new(&bytes[1..]); + let mut de = rmp_serde::Deserializer::new(&mut cursor); + let msg_type: String = Deserialize::deserialize(&mut de) + .map_err(|e| format!("Failed to decode message type: {}", e))?; + let current_pos = cursor.position() as usize; + let payload_bytes = &bytes[1 + current_pos..]; + + let payload = if !payload_bytes.is_empty() { + rmp_serde::from_slice(payload_bytes) + .map_err(|e| format!("Failed to decode payload: {}", e))? + } else { + Value::Null + }; + + Ok(InboundFrame::RoomData { msg_type, payload }) + } + opcodes::ROOM_DATA_BYTES => { + let mut cursor = std::io::Cursor::new(&bytes[1..]); + let mut de = rmp_serde::Deserializer::new(&mut cursor); + let msg_type: String = Deserialize::deserialize(&mut de) + .map_err(|e| format!("Failed to decode message type: {}", e))?; + let current_pos = cursor.position() as usize; + let payload = bytes[1 + current_pos..].to_vec(); + Ok(InboundFrame::RoomDataBytes { msg_type, payload }) + } + opcodes::PING => Ok(InboundFrame::Ping), + _ => Ok(InboundFrame::Unknown(code)), + } +} diff --git a/client-rust/source/client-proto/src/lib.rs b/client-rust/source/client-proto/src/lib.rs new file mode 100644 index 00000000..0f9482f9 --- /dev/null +++ b/client-rust/source/client-proto/src/lib.rs @@ -0,0 +1,9 @@ +//! Successor Rust client — wire protocol (Step 5). +//! Implements a minimal Colyseus 0.17 protocol and handles server packet structures. + +pub mod colyseus; +pub mod packets; +pub mod session; + +#[cfg(test)] +mod tests; diff --git a/client-rust/source/client-proto/src/packets.rs b/client-rust/source/client-proto/src/packets.rs new file mode 100644 index 00000000..9e2d93c7 --- /dev/null +++ b/client-rust/source/client-proto/src/packets.rs @@ -0,0 +1,751 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct GameHello { + #[serde(rename = "sessionId")] + pub session_id: String, + #[serde(rename = "playerActorId")] + pub player_actor_id: String, + pub snapshot: GameShardSnapshot, + #[serde(rename = "serverTime")] + pub server_time: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct GameShardSnapshot { + pub schema: String, + #[serde(rename = "shardId")] + pub shard_id: String, + pub tick: u64, + #[serde(rename = "playerActorId")] + pub player_actor_id: String, + pub actors: HashMap<String, GameActorSnapshot>, + pub inventory: Vec<serde_json::Value>, + pub reservations: Vec<serde_json::Value>, + pub bank: Option<serde_json::Value>, + #[serde(rename = "playerCorpses")] + pub player_corpses: Vec<serde_json::Value>, + #[serde(rename = "resourceSpawns")] + pub resource_spawns: Vec<serde_json::Value>, + #[serde(rename = "placedExtractors")] + pub placed_extractors: Vec<serde_json::Value>, + #[serde(rename = "placedCamps")] + pub placed_camps: Vec<serde_json::Value>, + #[serde(rename = "placedParcels")] + pub placed_parcels: Vec<serde_json::Value>, + pub building: Option<serde_json::Value>, + #[serde(rename = "farmPlots")] + pub farm_plots: Vec<serde_json::Value>, + #[serde(rename = "craftSession")] + pub craft_session: Option<serde_json::Value>, + #[serde(rename = "draftedSchematics")] + pub drafted_schematics: Vec<serde_json::Value>, + pub groups: Option<serde_json::Value>, + pub guilds: Option<serde_json::Value>, + pub duels: Option<serde_json::Value>, + #[serde(rename = "propStates")] + pub prop_states: HashMap<String, serde_json::Value>, + #[serde(rename = "worldClock")] + pub world_clock: Option<serde_json::Value>, + pub weather: Vec<serde_json::Value>, + #[serde(rename = "abilityQueue")] + pub ability_queue: Option<serde_json::Value>, + #[serde(rename = "sourceStateHash")] + pub source_state_hash: Option<String>, + #[serde(rename = "sourceActorCount")] + pub source_actor_count: Option<i64>, + pub counters: Option<GameCounters>, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct GameShardDelta { + pub schema: String, + #[serde(rename = "shardId")] + pub shard_id: String, + pub tick: u64, + #[serde(rename = "playerActorId")] + pub player_actor_id: String, + pub actors: HashMap<String, GameActorSnapshot>, + #[serde(rename = "actorPatches")] + pub actor_patches: HashMap<String, GameActorPatch>, + #[serde(rename = "actorRemovals")] + pub actor_removals: Vec<String>, + #[serde(rename = "compactActorMoves")] + pub compact_actor_moves: Vec<GameCompactActorMove>, + #[serde(rename = "actorRefs")] + pub actor_refs: Vec<GameActorNetRef>, + /// Compact tuples retain their wire order until the authority decoder + /// validates and projects them; never project malformed rows partially. + #[serde(rename = "compactActors")] + pub compact_actors: Vec<GameCompactActorSnapshot>, + #[serde(rename = "compactActorPatches")] + pub compact_actor_patches: Vec<GameCompactActorPatch>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inventory: Option<Vec<serde_json::Value>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reservations: Option<Vec<serde_json::Value>>, + pub bank: Option<Option<serde_json::Value>>, + #[serde( + rename = "playerCorpses", + default, + skip_serializing_if = "Option::is_none" + )] + pub player_corpses: Option<Vec<serde_json::Value>>, + #[serde( + rename = "resourceSpawns", + default, + skip_serializing_if = "Option::is_none" + )] + pub resource_spawns: Option<Vec<serde_json::Value>>, + #[serde( + rename = "placedExtractors", + default, + skip_serializing_if = "Option::is_none" + )] + pub placed_extractors: Option<Vec<serde_json::Value>>, + #[serde( + rename = "placedCamps", + default, + skip_serializing_if = "Option::is_none" + )] + pub placed_camps: Option<Vec<serde_json::Value>>, + #[serde( + rename = "placedParcels", + default, + skip_serializing_if = "Option::is_none" + )] + pub placed_parcels: Option<Vec<serde_json::Value>>, + pub building: Option<Option<serde_json::Value>>, + #[serde(rename = "farmPlots", default, skip_serializing_if = "Option::is_none")] + pub farm_plots: Option<Vec<serde_json::Value>>, + #[serde(rename = "craftSession")] + pub craft_session: Option<Option<serde_json::Value>>, + #[serde( + rename = "draftedSchematics", + default, + skip_serializing_if = "Option::is_none" + )] + pub drafted_schematics: Option<Vec<serde_json::Value>>, + pub groups: Option<Option<serde_json::Value>>, + pub guilds: Option<Option<serde_json::Value>>, + pub duels: Option<Option<serde_json::Value>>, + #[serde( + rename = "propStates", + default, + skip_serializing_if = "Option::is_none" + )] + pub prop_states: Option<HashMap<String, serde_json::Value>>, + #[serde(rename = "worldClock")] + pub world_clock: Option<Option<serde_json::Value>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub weather: Option<Vec<serde_json::Value>>, + #[serde(rename = "abilityQueue")] + pub ability_queue: Option<Option<serde_json::Value>>, + #[serde( + rename = "dialogueDeliveries", + default, + skip_serializing_if = "Option::is_none" + )] + pub dialogue_deliveries: Option<Vec<serde_json::Value>>, + #[serde(rename = "sourceStateHash")] + pub source_state_hash: Option<String>, + #[serde(rename = "sourceActorCount")] + pub source_actor_count: Option<i64>, + pub counters: Option<GameCounters>, +} + +/// Per-shard cumulative counters (snapshot/delta `counters`). +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct GameCounters { + #[serde(rename = "acceptedCommands")] + pub accepted_commands: u64, + #[serde(rename = "rejectedCommands")] + pub rejected_commands: u64, + #[serde(rename = "shotsFired")] + pub shots_fired: u64, + pub hits: u64, + pub deaths: u64, +} +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct GameActorVitals { + pub health: f32, + pub action: f32, + pub spirit: f32, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct GameActorSnapshot { + pub id: String, + pub label: String, + #[serde(rename = "displayName", alias = "display_name")] + pub display_name: String, + #[serde(rename = "areaId")] + pub area_id: String, + pub x: f32, + pub y: f32, + pub direction: String, + pub vitals: GameActorVitals, + #[serde(rename = "maxVitals")] + pub max_vitals: GameActorVitals, + #[serde(rename = "lifeState")] + pub life_state: String, + #[serde(rename = "lifecycleSeq")] + pub lifecycle_seq: i64, + pub bleed: Option<serde_json::Value>, + #[serde(rename = "bodyVanishAtTick")] + pub body_vanish_at_tick: Option<i64>, + #[serde(rename = "respawnAtTick")] + pub respawn_at_tick: Option<i64>, + #[serde(rename = "bodyVanishTick")] + pub body_vanish_tick: Option<i64>, + #[serde(rename = "incapRemainingMs")] + pub incap_remaining_ms: Option<i64>, + #[serde(rename = "incapCount")] + pub incap_count: Option<i64>, + #[serde(rename = "incapWindowMs")] + pub incap_window_ms: Option<i64>, + #[serde(rename = "nextSampleTick")] + pub next_sample_tick: Option<i64>, + #[serde(rename = "cloneSicknessRemainingMs")] + pub clone_sickness_remaining_ms: Option<i64>, + #[serde(rename = "skillPointsUsed")] + pub skill_points_used: Option<i64>, + #[serde(rename = "skillPointsCap")] + pub skill_points_cap: Option<i64>, + #[serde(rename = "activeTitle")] + pub active_title: Option<serde_json::Value>, + #[serde(rename = "combatQueue")] + pub combat_queue: Option<serde_json::Value>, + #[serde(rename = "inCombat")] + pub in_combat: Option<bool>, + #[serde(rename = "peaceRequested")] + pub peace_requested: Option<bool>, + #[serde(rename = "aiAttitude")] + pub ai_attitude: Option<String>, + pub lootable: Option<bool>, + pub has_loot: Option<bool>, + #[serde(rename = "lootRightsActorId")] + pub loot_rights_actor_id: Option<String>, + #[serde(rename = "playerOrganizationId")] + pub player_organization_id: Option<String>, + #[serde(rename = "playerOrganizationTag")] + pub player_organization_tag: Option<String>, + #[serde(rename = "willAutoAggro")] + pub will_auto_aggro: Option<bool>, + pub descriptor: Option<String>, + #[serde(rename = "linkDead")] + pub link_dead: bool, + #[serde(rename = "careerGoalId")] + pub career_goal_id: Option<String>, + pub stats: Option<serde_json::Value>, + pub mobility: Option<serde_json::Value>, + #[serde(rename = "shotSpreadDegreesMilli")] + pub shot_spread_degrees_milli: Option<i64>, + pub sprite: Option<String>, + pub role: Option<String>, + pub posture: Option<String>, + #[serde(rename = "postureUntilTick")] + pub posture_until_tick: Option<i64>, + pub appearance: Option<GameActorAppearance>, + #[serde(default)] + pub worn: Vec<GameActorWorn>, + pub weapon: Option<GameActorWeapon>, + #[serde(default)] + pub statuses: Vec<serde_json::Value>, + #[serde(default)] + pub professions: Vec<serde_json::Value>, + #[serde(rename = "personalShield")] + pub personal_shield: Option<serde_json::Value>, + pub credits: Option<i64>, + #[serde(rename = "factionId")] + pub faction_id: Option<String>, + #[serde(rename = "socialGroup")] + pub social_group: Option<String>, + #[serde(rename = "pvpStatus")] + pub pvp_status: Option<String>, + #[serde(rename = "engagementTargetId")] + pub engagement_target_id: Option<String>, + #[serde(rename = "sprintRecoveryLocked")] + pub sprint_recovery_locked: Option<bool>, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct GameActorPatch { + pub id: String, + pub label: Option<String>, + #[serde(rename = "displayName", alias = "display_name")] + pub display_name: Option<String>, + pub descriptor: Option<String>, + #[serde(rename = "linkDead")] + pub link_dead: Option<bool>, + #[serde(rename = "areaId")] + pub area_id: Option<String>, + pub x: Option<f32>, + pub y: Option<f32>, + pub direction: Option<String>, + pub vitals: Option<GameActorVitals>, + #[serde(rename = "maxVitals")] + pub max_vitals: Option<GameActorVitals>, + pub bleed: Option<serde_json::Value>, + #[serde(rename = "lifeState")] + pub life_state: Option<String>, + #[serde(rename = "lifecycleSeq")] + pub lifecycle_seq: Option<i64>, + #[serde(rename = "bodyVanishAtTick")] + pub body_vanish_at_tick: Option<i64>, + #[serde(rename = "respawnAtTick")] + pub respawn_at_tick: Option<i64>, + #[serde(rename = "bodyVanishTick")] + pub body_vanish_tick: Option<i64>, + #[serde(rename = "incapRemainingMs")] + pub incap_remaining_ms: Option<i64>, + #[serde(rename = "incapCount")] + pub incap_count: Option<i64>, + #[serde(rename = "incapWindowMs")] + pub incap_window_ms: Option<i64>, + #[serde(rename = "nextSampleTick")] + pub next_sample_tick: Option<i64>, + #[serde(rename = "cloneSicknessRemainingMs")] + pub clone_sickness_remaining_ms: Option<i64>, + #[serde(rename = "skillPointsUsed")] + pub skill_points_used: Option<i64>, + #[serde(rename = "skillPointsCap")] + pub skill_points_cap: Option<i64>, + #[serde(rename = "activeTitle")] + pub active_title: Option<serde_json::Value>, + #[serde(rename = "combatQueue")] + pub combat_queue: Option<serde_json::Value>, + #[serde(rename = "inCombat")] + pub in_combat: Option<bool>, + #[serde(rename = "peaceRequested")] + pub peace_requested: Option<bool>, + #[serde(rename = "aiAttitude")] + pub ai_attitude: Option<String>, + pub lootable: Option<bool>, + pub has_loot: Option<bool>, + #[serde(rename = "lootRightsActorId")] + pub loot_rights_actor_id: Option<String>, + #[serde(rename = "playerOrganizationId")] + pub player_organization_id: Option<String>, + #[serde(rename = "playerOrganizationTag")] + pub player_organization_tag: Option<String>, + #[serde(rename = "willAutoAggro")] + pub will_auto_aggro: Option<bool>, + pub sprite: Option<String>, + pub role: Option<String>, + pub posture: Option<String>, + #[serde(rename = "postureUntilTick")] + pub posture_until_tick: Option<i64>, + pub appearance: Option<GameActorAppearance>, + pub worn: Option<Vec<GameActorWorn>>, + pub weapon: Option<GameActorWeapon>, + pub statuses: Option<Vec<serde_json::Value>>, + pub professions: Option<Vec<serde_json::Value>>, + #[serde(rename = "personalShield")] + pub personal_shield: Option<serde_json::Value>, + pub credits: Option<i64>, + #[serde(rename = "factionId")] + pub faction_id: Option<String>, + #[serde(rename = "socialGroup")] + pub social_group: Option<String>, + #[serde(rename = "pvpStatus")] + pub pvp_status: Option<String>, + #[serde(rename = "engagementTargetId")] + pub engagement_target_id: Option<String>, + #[serde(rename = "sprintRecoveryLocked")] + pub sprint_recovery_locked: Option<bool>, + pub mobility: Option<serde_json::Value>, +} +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct GameActorAppearance { + #[serde(rename = "skin")] + pub skin_tone: Option<String>, + pub hair: Option<String>, + #[serde(rename = "hair_mat")] + pub hair_material: Option<String>, + pub face: Option<serde_json::Value>, +} + +/// One worn equipment piece. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct GameActorWorn { + pub slot: Option<String>, + #[serde(rename = "item")] + pub item_id: Option<String>, + #[serde(default)] + pub colors: Vec<String>, +} + +/// Equipped weapon presentation. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct GameActorWeapon { + #[serde(rename = "weaponId")] + pub weapon_id: Option<String>, + #[serde(rename = "weaponItemId")] + pub weapon_item_id: Option<i64>, + #[serde(rename = "weaponVariantId")] + pub weapon_variant_id: Option<i64>, + #[serde(rename = "ammoType")] + pub ammo_type: Option<String>, + #[serde(rename = "loadedRounds")] + pub loaded_rounds: Option<i64>, + #[serde(rename = "magazineSize")] + pub magazine_size: Option<i64>, + #[serde(rename = "reloadUntilTick")] + pub reload_until_tick: Option<i64>, + #[serde(rename = "reloadRemainingTicks")] + pub reload_remaining_ticks: Option<i64>, + #[serde(rename = "reloadTotalTicks")] + pub reload_total_ticks: Option<i64>, +} + +/// Compact per-tick move delta: `[netId, qx, qy, direction]` (positions are +/// milli-cell quantized / 100; direction 0..3). netId resolves via `actorRefs`. +#[derive(Debug, Clone, Copy, Serialize, PartialEq)] +pub struct GameCompactActorMove(pub u32, pub i64, pub i64, pub u8); + +impl<'de> Deserialize<'de> for GameCompactActorMove { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let row = Vec::<serde_json::Value>::deserialize(deserializer)?; + if row.len() != 4 { + return Err(serde::de::Error::invalid_length( + row.len(), + &"4-element compact actor move", + )); + } + let n = |i: usize| { + row[i] + .as_i64() + .ok_or_else(|| serde::de::Error::custom("compact move integer")) + }; + let net = u32::try_from(n(0)?).map_err(|_| serde::de::Error::custom("net id range"))?; + let dir = u8::try_from(n(3)?).map_err(|_| serde::de::Error::custom("direction range"))?; + if dir > 3 { + return Err(serde::de::Error::custom("direction range")); + } + Ok(Self(net, n(1)?, n(2)?, dir)) + } +} + +/// `actorRefs` entry mapping a net id to an actor id. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct GameActorNetRef(pub u32, pub String); +/// Compact full actor row. The wire tuple is retained losslessly and decoded +/// by the authority projection, which can therefore reject it transactionally. +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct GameCompactActorSnapshot(pub Vec<serde_json::Value>); + +impl<'de> Deserialize<'de> for GameCompactActorSnapshot { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let row = Vec::<serde_json::Value>::deserialize(deserializer)?; + if row.len() != 52 { + return Err(serde::de::Error::invalid_length( + row.len(), + &"52-element compact actor snapshot", + )); + } + Ok(Self(row)) + } +} + +/// Compact actor patch. `null` and `false` are intentionally retained. +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct GameCompactActorPatch(pub Vec<serde_json::Value>); + +impl<'de> Deserialize<'de> for GameCompactActorPatch { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let row = Vec::<serde_json::Value>::deserialize(deserializer)?; + if row.len() != 52 { + return Err(serde::de::Error::invalid_length( + row.len(), + &"52-element compact actor patch", + )); + } + Ok(Self(row)) + } +} +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct GameCommandReceipt { + #[serde(rename = "commandId")] + pub command_id: u64, + pub accepted: bool, + pub tick: u64, + #[serde(rename = "reasonCode")] + pub reason_code: Option<String>, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct GameCompactReceipt(pub u64, pub u8, pub u64, pub Option<String>); + +impl<'de> Deserialize<'de> for GameCompactReceipt { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + struct CompactReceiptVisitor; + impl<'de> serde::de::Visitor<'de> for CompactReceiptVisitor { + type Value = GameCompactReceipt; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a tuple of 3 or 4 elements") + } + + fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error> + where + A: serde::de::SeqAccess<'de>, + { + let command_id = seq + .next_element()? + .ok_or_else(|| serde::de::Error::invalid_length(0, &self))?; + let accepted = seq + .next_element()? + .ok_or_else(|| serde::de::Error::invalid_length(1, &self))?; + let tick = seq + .next_element()? + .ok_or_else(|| serde::de::Error::invalid_length(2, &self))?; + let reason_code = seq.next_element()?; + + Ok(GameCompactReceipt(command_id, accepted, tick, reason_code)) + } + } + + deserializer.deserialize_seq(CompactReceiptVisitor) + } +} + +impl Serialize for GameCompactReceipt { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + use serde::ser::SerializeTuple; + if let Some(reason) = &self.3 { + let mut tup = serializer.serialize_tuple(4)?; + tup.serialize_element(&self.0)?; + tup.serialize_element(&self.1)?; + tup.serialize_element(&self.2)?; + tup.serialize_element(reason)?; + tup.end() + } else { + let mut tup = serializer.serialize_tuple(3)?; + tup.serialize_element(&self.0)?; + tup.serialize_element(&self.1)?; + tup.serialize_element(&self.2)?; + tup.end() + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct GamePlayerPositionAck(pub f32, pub f32, #[serde(default)] pub Option<u64>); + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct GameMovementProfileAck { + #[serde(rename = "walkSpeedMilliPerSecond")] + pub walk_speed_milli_per_second: i32, + #[serde(rename = "sprintSpeedMilliPerSecond")] + pub sprint_speed_milli_per_second: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type")] +pub enum GameServerPacket { + #[serde(rename = "game.hello")] + Hello(GameHello), + + #[serde(rename = "game.snapshot")] + Snapshot { + snapshot: GameShardSnapshot, + receipts: Vec<GameCommandReceipt>, + events: Vec<serde_json::Value>, + #[serde(default)] + #[serde(rename = "compactEvents")] + compact_events: Option<Vec<serde_json::Value>>, + }, + + #[serde(rename = "game.delta")] + Delta { + delta: GameShardDelta, + receipts: Vec<GameCommandReceipt>, + events: Vec<serde_json::Value>, + #[serde(default)] + #[serde(rename = "compactEvents")] + compact_events: Option<Vec<serde_json::Value>>, + }, + + #[serde(rename = "game.receipts")] + Receipts { + receipts: Vec<GameCommandReceipt>, + events: Vec<serde_json::Value>, + #[serde(default)] + #[serde(rename = "compactEvents")] + compact_events: Option<Vec<serde_json::Value>>, + }, + + #[serde(rename = "game.acks")] + Acks { + acks: Vec<GameCompactReceipt>, + #[serde(default)] + #[serde(rename = "playerActor")] + player_actor: Option<GameActorSnapshot>, + #[serde(default)] + #[serde(rename = "playerPosition")] + player_position: Option<GamePlayerPositionAck>, + #[serde(default)] + #[serde(rename = "movementProfile")] + movement_profile: Option<GameMovementProfileAck>, + #[serde(default)] + events: Option<Vec<serde_json::Value>>, + #[serde(default)] + #[serde(rename = "compactEvents")] + compact_events: Option<Vec<serde_json::Value>>, + }, + + #[serde(rename = "game.error")] + Error { code: String, message: String }, + + #[serde(rename = "pong")] + Pong { + #[serde(default)] + #[serde(rename = "requestId")] + request_id: Option<String>, + at: f64, + }, +} + +// Client->server message name constants +pub const MSG_GAME_READY: &str = "game.ready"; +pub const MSG_GAME_COMMAND: &str = "game.command"; +pub const MSG_GAME_VIEW: &str = "game.view"; +pub const MSG_EXIT_WORLD: &str = "exit_world"; +pub const MSG_PING: &str = "ping"; + +#[cfg(test)] +mod actor_tests { + use super::*; + + #[test] + fn decodes_full_actor_snapshot() { + let json = r##"{ + "id":"npc-1","label":"Raider","display_name":"Raider", + "areaId":"open-desert-overworld","x":512.0,"y":505.0,"direction":"front", + "vitals":{"health":80,"action":40,"spirit":10}, + "maxVitals":{"health":100,"action":100,"spirit":100}, + "lifeState":"alive","lifecycleSeq":3, + "sprite":null,"role":"player","posture":"stand", + "appearance":{"skin":"#cc9978","hair":"hair_short","hair_mat":"hair_raven"}, + "worn":[{"slot":"chest","item":"jacket_1","colors":["#334455"]}], + "weapon":{"weaponId":"slugthrower","weaponItemId":3101,"weaponVariantId":2, + "ammoType":"slug_iron","loadedRounds":12,"magazineSize":30, + "reloadUntilTick":910,"reloadRemainingTicks":10,"reloadTotalTicks":30}, + "factionId":"raiders","pvpStatus":"overt","credits":250 + }"##; + let a: GameActorSnapshot = serde_json::from_str(json).expect("decode"); + assert_eq!(a.role.as_deref(), Some("player")); + assert_eq!( + a.appearance.as_ref().unwrap().skin_tone.as_deref(), + Some("#cc9978") + ); + assert_eq!( + a.appearance.as_ref().unwrap().hair.as_deref(), + Some("hair_short") + ); + assert_eq!( + a.appearance.as_ref().unwrap().hair_material.as_deref(), + Some("hair_raven") + ); + assert_eq!(a.worn.len(), 1); + assert_eq!(a.worn[0].item_id.as_deref(), Some("jacket_1")); + assert_eq!( + a.weapon.as_ref().unwrap().weapon_id.as_deref(), + Some("slugthrower") + ); + let weapon = a.weapon.as_ref().unwrap(); + assert_eq!(weapon.weapon_item_id, Some(3101)); + assert_eq!(weapon.weapon_variant_id, Some(2)); + assert_eq!(weapon.ammo_type.as_deref(), Some("slug_iron")); + assert_eq!(weapon.loaded_rounds, Some(12)); + assert_eq!(weapon.magazine_size, Some(30)); + assert_eq!(weapon.reload_until_tick, Some(910)); + assert_eq!(weapon.reload_remaining_ticks, Some(10)); + assert_eq!(weapon.reload_total_ticks, Some(30)); + assert_eq!(a.max_vitals.health, 100.0); + assert_eq!(a.credits, Some(250)); + assert_eq!(a.pvp_status.as_deref(), Some("overt")); + } + + #[test] + fn decodes_minimal_actor_permissively() { + // A sparse actor (only core fields) still decodes with defaults. + let a: GameActorSnapshot = + serde_json::from_str(r#"{"id":"x","x":1.0,"y":2.0}"#).expect("decode"); + assert_eq!(a.id, "x"); + assert!(a.appearance.is_none()); + assert!(a.worn.is_empty()); + } + + #[test] + fn decodes_compact_move_and_refs_in_delta() { + let json = r#"{ + "schema":"successor.authoritative-shard-delta.v1","shardId":"s","tick":10, + "playerActorId":"p","compactActorMoves":[[7,51200,50300,2]], + "actorRefs":[[7,"npc-1"]] + }"#; + let d: GameShardDelta = serde_json::from_str(json).expect("decode"); + assert_eq!(d.compact_actor_moves.len(), 1); + let m = d.compact_actor_moves[0]; + assert_eq!((m.0, m.1, m.2, m.3), (7, 51200, 50300, 2)); + assert_eq!(d.actor_refs[0].0, 7); + assert_eq!(d.actor_refs[0].1, "npc-1"); + } +} + +#[cfg(test)] +mod section_tests { + use super::*; + + #[test] + fn decodes_snapshot_sections() { + let json = r#"{ + "schema":"successor.authoritative-shard-snapshot.v1","shardId":"s","tick":5, + "playerActorId":"p","actors":{}, + "inventory":[{"itemId":1001,"quantity":3}], + "bank":{"credits":500}, + "propStates":{"door-1":{"open":true}}, + "worldClock":{"dayFraction":0.5}, + "weather":[{"kind":"clear"}], + "sourceStateHash":"abc","sourceActorCount":42, + "counters":{"acceptedCommands":10,"rejectedCommands":1,"shotsFired":4,"hits":2,"deaths":0} + }"#; + let s: GameShardSnapshot = serde_json::from_str(json).expect("decode snapshot"); + assert_eq!(s.inventory.len(), 1); + assert!(s.bank.is_some()); + assert_eq!(s.prop_states.len(), 1); + assert!(s.world_clock.is_some()); + assert_eq!(s.weather.len(), 1); + assert_eq!(s.source_actor_count, Some(42)); + let c = s.counters.unwrap(); + assert_eq!(c.accepted_commands, 10); + assert_eq!(c.shots_fired, 4); + } +} diff --git a/client-rust/source/client-proto/src/session.rs b/client-rust/source/client-proto/src/session.rs new file mode 100644 index 00000000..2a75fd2b --- /dev/null +++ b/client-rust/source/client-proto/src/session.rs @@ -0,0 +1,306 @@ +use crate::colyseus; +use crate::packets::{self, GameHello, GameServerPacket}; +use serde_json::Value; + +// Packet events stay inline: boxing every network packet adds an avoidable +// allocation on the connected receive path. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone, PartialEq)] +pub enum SessionEvent { + Hello(GameHello), + Packet(GameServerPacket), + /// A targeted room message that is not the world packet stream: + /// `surveyResult`, `craftSession`, `spliceSession`, `tradeSession`, + /// `genomeScan`, `duelOutcome`, `commandRejected`. These carry the + /// per-player results of gameplay commands and were previously dropped, + /// which left every one of those surfaces permanently empty. + Room { + msg_type: String, + payload: serde_json::Value, + }, + Error(String), + Closed, + ReconnectAttempt { + attempt: u32, + max_attempts: u32, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum WsInput<'a> { + Open, + Frame(&'a [u8]), + Closed, + Error(&'a str), +} + +#[derive(Debug, Clone, PartialEq)] +#[allow(clippy::large_enum_variant)] +pub enum SessionOut { + SendFrame(Vec<u8>), + Emit(SessionEvent), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionState { + Disconnected, + Connecting, + AwaitingJoinRoom, + ExpectingHello, + Ready, + Failed, +} + +pub struct Session { + state: SessionState, + reconnect_attempts: u32, + max_reconnect_attempts: u32, +} + +impl Session { + pub fn new() -> Self { + Self { + state: SessionState::Disconnected, + reconnect_attempts: 0, + max_reconnect_attempts: 5, + } + } + + pub fn state(&self) -> SessionState { + self.state + } + + pub fn start_connecting(&mut self) { + self.state = SessionState::Connecting; + } + + pub fn on_ws_event(&mut self, ev: WsInput<'_>) -> Vec<SessionOut> { + let mut outs = Vec::new(); + match &ev { + WsInput::Open => { + if self.state == SessionState::Connecting + || self.state == SessionState::Disconnected + { + self.state = SessionState::AwaitingJoinRoom; + } + } + WsInput::Frame(bytes) => { + match &self.state { + SessionState::AwaitingJoinRoom => { + match colyseus::decode_inbound_frame(bytes) { + Ok(colyseus::InboundFrame::JoinRoom { .. }) => { + // Acknowledge successful JOIN_ROOM, then send + // game.ready immediately — the server emits + // game.hello in response to game.ready (see + // server colyseusRoom `connectClient`), so we + // must NOT wait for hello before readying. + outs.push(SessionOut::SendFrame(vec![ + colyseus::opcodes::JOIN_ROOM, + ])); + if let Ok(ready_frame) = colyseus::encode_room_message( + packets::MSG_GAME_READY, + &serde_json::Value::Null, + ) { + outs.push(SessionOut::SendFrame(ready_frame)); + } + self.state = SessionState::ExpectingHello; + } + Ok(colyseus::InboundFrame::Error { code, message }) => { + self.state = SessionState::Disconnected; + outs.push(SessionOut::Emit(SessionEvent::Error(format!( + "JoinRoom error (code {}): {}", + code, message + )))); + self.handle_disconnect(&mut outs); + } + _ => { + // Unexpected frame in AwaitingJoinRoom + } + } + } + SessionState::ExpectingHello => match colyseus::decode_inbound_frame(bytes) { + Ok(colyseus::InboundFrame::RoomData { msg_type, payload }) => { + if msg_type == "game.packet" { + match serde_json::from_value::<GameServerPacket>(payload) { + Ok(GameServerPacket::Hello(hello)) => { + self.state = SessionState::Ready; + self.reconnect_attempts = 0; + outs.push(SessionOut::Emit(SessionEvent::Hello(hello))); + } + Ok(packet) => { + outs.push(SessionOut::Emit(SessionEvent::Packet(packet))); + } + Err(e) => { + outs.push(SessionOut::Emit(SessionEvent::Error(format!( + "Failed to deserialize Hello packet: {}", + e + )))); + } + } + } + } + Ok(colyseus::InboundFrame::Error { code, message }) => { + outs.push(SessionOut::Emit(SessionEvent::Error(format!( + "Error frame (code {}): {}", + code, message + )))); + self.handle_disconnect(&mut outs); + } + _ => {} + }, + SessionState::Ready => match colyseus::decode_inbound_frame(bytes) { + Ok(colyseus::InboundFrame::RoomData { msg_type, payload }) => { + if msg_type == "game.packet" { + match serde_json::from_value::<GameServerPacket>(payload) { + Ok(packet) => { + outs.push(SessionOut::Emit(SessionEvent::Packet(packet))); + } + Err(e) => { + outs.push(SessionOut::Emit(SessionEvent::Error(format!( + "Failed to deserialize server packet: {}", + e + )))); + } + } + } else { + outs.push(SessionOut::Emit(SessionEvent::Room { + msg_type: msg_type.to_string(), + payload, + })); + } + } + Ok(colyseus::InboundFrame::Error { code, message }) => { + outs.push(SessionOut::Emit(SessionEvent::Error(format!( + "Error frame (code {}): {}", + code, message + )))); + self.handle_disconnect(&mut outs); + } + Ok(colyseus::InboundFrame::LeaveRoom) => { + outs.push(SessionOut::Emit(SessionEvent::Closed)); + self.handle_disconnect(&mut outs); + } + _ => {} + }, + _ => {} + } + } + WsInput::Closed | WsInput::Error(_) => { + self.handle_disconnect(&mut outs); + } + } + outs + } + + fn handle_disconnect(&mut self, outs: &mut Vec<SessionOut>) { + if self.state == SessionState::Failed { + return; + } + if self.reconnect_attempts < self.max_reconnect_attempts { + self.reconnect_attempts += 1; + self.state = SessionState::Connecting; + outs.push(SessionOut::Emit(SessionEvent::ReconnectAttempt { + attempt: self.reconnect_attempts, + max_attempts: self.max_reconnect_attempts, + })); + } else { + self.state = SessionState::Failed; + outs.push(SessionOut::Emit(SessionEvent::Closed)); + } + } + + pub fn send_command( + &mut self, + envelope: &successor_net::ClientCommandEnvelope, + ) -> Result<SessionOut, rmp_serde::encode::Error> { + // Serialize via JSON then drop null-valued fields. `successor-net` + // serializes `Option::None` as `null` (e.g. `SetMoveIntent.facing`), + // but the server's zod schema treats those fields as `.optional()` + // (absent), and rejects an explicit `null`. Stripping nulls matches how + // the TS client omits `undefined` fields. + let mut value = + serde_json::to_value(envelope).expect("ClientCommandEnvelope serializes to JSON"); + strip_nulls(&mut value); + let frame = colyseus::encode_room_message(packets::MSG_GAME_COMMAND, &value)?; + Ok(SessionOut::SendFrame(frame)) + } + + pub fn send_view(&mut self, view: &Value) -> Result<SessionOut, rmp_serde::encode::Error> { + let frame = colyseus::encode_room_message(packets::MSG_GAME_VIEW, view)?; + Ok(SessionOut::SendFrame(frame)) + } + pub fn send_message( + &mut self, + name: &str, + value: &Value, + ) -> Result<SessionOut, rmp_serde::encode::Error> { + let frame = colyseus::encode_room_message(name, value)?; + Ok(SessionOut::SendFrame(frame)) + } + + pub fn exit_world(&mut self) -> Result<SessionOut, rmp_serde::encode::Error> { + let frame = colyseus::encode_room_message( + packets::MSG_EXIT_WORLD, + &Value::Object(serde_json::Map::new()), + )?; + Ok(SessionOut::SendFrame(frame)) + } +} + +/// Recursively remove object fields whose value is JSON `null`. The server's +/// command schema uses `.optional()` (absent), not `.nullable()`, so an +/// explicit `null` from `Option::None` would be rejected. +fn strip_nulls(value: &mut Value) { + match value { + Value::Object(map) => { + map.retain(|_, v| !v.is_null()); + for v in map.values_mut() { + strip_nulls(v); + } + } + Value::Array(items) => { + for v in items.iter_mut() { + strip_nulls(v); + } + } + _ => {} + } +} + +impl Default for Session { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use successor_net::{ClientCommand, ClientCommandEnvelope, PlayerId, SessionId}; + + #[test] + fn command_payload_omits_optional_null_fields() { + let envelope = ClientCommandEnvelope { + session: SessionId(7), + player: PlayerId(9), + command_id: 11, + issued_at_tick: 13, + command: ClientCommand::ReloadWeapon { + weapon_id: None, + ammo_type: None, + }, + }; + let mut value = serde_json::to_value(envelope).unwrap(); + strip_nulls(&mut value); + assert_eq!( + value, + serde_json::json!({ + "session": 7, + "player": 9, + "command_id": 11, + "issued_at_tick": 13, + "command": { "ReloadWeapon": {} } + }) + ); + } +} diff --git a/client-rust/source/client-proto/src/tests.rs b/client-rust/source/client-proto/src/tests.rs new file mode 100644 index 00000000..9fb677c0 --- /dev/null +++ b/client-rust/source/client-proto/src/tests.rs @@ -0,0 +1,343 @@ +#[cfg(test)] +#[allow(clippy::module_inception)] +mod tests { + use crate::colyseus; + use crate::packets::{self, GameServerPacket}; + use crate::session::{Session, SessionEvent, SessionOut, SessionState, WsInput}; + use serde_json::json; + + // Load JSON fixtures + const HELLO_JSON: &str = include_str!("../fixtures/game_hello.json"); + const SNAPSHOT_JSON: &str = include_str!("../fixtures/game_snapshot.json"); + const DELTA_JSON: &str = include_str!("../fixtures/game_delta.json"); + const RECEIPTS_JSON: &str = include_str!("../fixtures/game_receipts.json"); + const ACKS_JSON: &str = include_str!("../fixtures/game_acks.json"); + const ERROR_JSON: &str = include_str!("../fixtures/game_error.json"); + const PONG_JSON: &str = include_str!("../fixtures/pong.json"); + + #[test] + fn test_fixture_decoding() { + // game.hello + let packet: GameServerPacket = serde_json::from_str(HELLO_JSON).expect("decode game.hello"); + if let GameServerPacket::Hello(hello) = &packet { + assert_eq!(hello.session_id, "test-session-id-12345"); + assert_eq!(hello.player_actor_id, "actor-player-1"); + assert_eq!(hello.snapshot.tick, 42); + assert_eq!(hello.snapshot.shard_id, "open-desert-persistent"); + let player = hello + .snapshot + .actors + .get("actor-player-1") + .expect("player actor present"); + assert_eq!(player.display_name, "Dev Player"); + assert_eq!(player.vitals.health, 100.0); + } else { + panic!("Expected Hello packet"); + } + + // game.snapshot + let packet: GameServerPacket = + serde_json::from_str(SNAPSHOT_JSON).expect("decode game.snapshot"); + if let GameServerPacket::Snapshot { + snapshot, + receipts, + events, + .. + } = &packet + { + assert_eq!(snapshot.tick, 43); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].command_id, 101); + assert!(receipts[0].accepted); + assert!(events.is_empty()); + } else { + panic!("Expected Snapshot packet"); + } + + // game.delta + let packet: GameServerPacket = serde_json::from_str(DELTA_JSON).expect("decode game.delta"); + if let GameServerPacket::Delta { + delta, + receipts, + events, + .. + } = &packet + { + assert_eq!(delta.tick, 44); + let patch = delta + .actor_patches + .get("actor-player-1") + .expect("player patch present"); + assert_eq!(patch.x, Some(523.0)); + assert_eq!(receipts.len(), 1); + assert!(events.is_empty()); + } else { + panic!("Expected Delta packet"); + } + + // game.receipts + let packet: GameServerPacket = + serde_json::from_str(RECEIPTS_JSON).expect("decode game.receipts"); + if let GameServerPacket::Receipts { + receipts, events, .. + } = &packet + { + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].command_id, 103); + assert!(events.is_empty()); + } else { + panic!("Expected Receipts packet"); + } + + // game.acks + let packet: GameServerPacket = serde_json::from_str(ACKS_JSON).expect("decode game.acks"); + if let GameServerPacket::Acks { + acks, + player_actor, + player_position, + .. + } = &packet + { + assert_eq!(acks.len(), 1); + assert_eq!(acks[0].0, 104); + assert_eq!(acks[0].1, 1); + assert_eq!(acks[0].2, 45); + let player = player_actor.as_ref().expect("player actor present"); + assert_eq!(player.display_name, "Dev Player"); + let pos = player_position.as_ref().expect("player position present"); + assert_eq!(pos.0, 524.0); + assert_eq!(pos.1, 520.0); + } else { + panic!("Expected Acks packet"); + } + + // game.error + let packet: GameServerPacket = serde_json::from_str(ERROR_JSON).expect("decode game.error"); + if let GameServerPacket::Error { code, message } = &packet { + assert_eq!(code, "INVALID_COMMAND"); + assert_eq!(message, "The move intent parameters are invalid."); + } else { + panic!("Expected Error packet"); + } + + // pong + let packet: GameServerPacket = serde_json::from_str(PONG_JSON).expect("decode pong"); + if let GameServerPacket::Pong { request_id, at } = &packet { + assert_eq!(request_id.as_deref(), Some("ping-req-456")); + assert_eq!(*at, 1785293204217.0); + } else { + panic!("Expected Pong packet"); + } + } + + #[test] + fn test_colyseus_roundtrip() { + let payload = json!({"foo": "bar", "val": 42}); + let encoded = colyseus::encode_room_message("my_test_type", &payload).unwrap(); + let decoded = colyseus::decode_inbound_frame(&encoded).unwrap(); + if let colyseus::InboundFrame::RoomData { + msg_type, + payload: decoded_payload, + } = &decoded + { + assert_eq!(msg_type, "my_test_type"); + assert_eq!(decoded_payload, &payload); + } else { + panic!("Expected RoomData frame"); + } + } + + #[test] + fn test_matchmake_request_shape() { + let endpoint = "ws://127.0.0.1:28093"; + let options = json!({"playerId": "test"}); + let (url, body) = colyseus::build_matchmake_request(endpoint, &options).unwrap(); + assert_eq!(url, "http://127.0.0.1:28093/matchmake/joinOrCreate/game"); + let body_val: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body_val["playerId"], "test"); + } + + #[test] + fn test_session_state_machine() { + let mut session = Session::new(); + assert_eq!(session.state(), SessionState::Disconnected); + + session.start_connecting(); + assert_eq!(session.state(), SessionState::Connecting); + + // Open + let outs = session.on_ws_event(WsInput::Open); + assert_eq!(session.state(), SessionState::AwaitingJoinRoom); + assert!(outs.is_empty()); + + // JoinRoom frame + let join_frame = vec![10, 3, b't', b'o', b'k', 4, b'j', b's', b'o', b'n']; + let outs = session.on_ws_event(WsInput::Frame(&join_frame)); + assert_eq!(session.state(), SessionState::ExpectingHello); + assert_eq!(outs.len(), 2, "join acks JOIN_ROOM then sends game.ready"); + assert_eq!( + outs[0], + SessionOut::SendFrame(vec![colyseus::opcodes::JOIN_ROOM]) + ); + match &outs[1] { + SessionOut::SendFrame(ready_bytes) => { + let decoded = colyseus::decode_inbound_frame(ready_bytes).unwrap(); + if let colyseus::InboundFrame::RoomData { msg_type, payload } = decoded { + assert_eq!(msg_type, packets::MSG_GAME_READY); + assert_eq!(payload, serde_json::Value::Null); + } else { + panic!("Expected RoomData frame for game.ready on join"); + } + } + _ => panic!("Expected game.ready SendFrame on join"), + } + + // game.hello packet + let hello_packet = json!({ + "type": "game.hello", + "sessionId": "sess-123", + "playerActorId": "actor-1", + "snapshot": { + "schema": "successor.authoritative-shard-snapshot.v1", + "shardId": "desert", + "tick": 1, + "playerActorId": "actor-1", + "actors": {} + }, + "serverTime": "time" + }); + let hello_frame = colyseus::encode_room_message("game.packet", &hello_packet).unwrap(); + let outs = session.on_ws_event(WsInput::Frame(&hello_frame)); + assert_eq!(session.state(), SessionState::Ready); + assert_eq!(outs.len(), 1, "hello only emits the Hello event now"); + match &outs[0] { + SessionOut::Emit(SessionEvent::Hello(hello)) => { + assert_eq!(hello.session_id, "sess-123"); + } + _ => panic!("Expected Hello event"), + } + } + + #[test] + fn test_reconnect_policy() { + let mut session = Session::new(); + session.start_connecting(); + + // Closed/Error triggers reconnect up to 5 times + for i in 1..=5 { + let outs = session.on_ws_event(WsInput::Closed); + assert_eq!(session.state(), SessionState::Connecting); + assert_eq!(outs.len(), 1); + match &outs[0] { + SessionOut::Emit(SessionEvent::ReconnectAttempt { + attempt, + max_attempts, + }) => { + assert_eq!(*attempt, i); + assert_eq!(*max_attempts, 5); + } + _ => panic!("Expected ReconnectAttempt event"), + } + } + + // 6th event exhausts retries -> Failed state + let outs = session.on_ws_event(WsInput::Closed); + assert_eq!(session.state(), SessionState::Failed); + assert_eq!(outs.len(), 1); + assert_eq!(outs[0], SessionOut::Emit(SessionEvent::Closed)); + } + + fn http_post(url: &str, body: &[u8]) -> Result<Vec<u8>, Box<dyn std::error::Error>> { + use std::io::{Read, Write}; + use std::net::TcpStream; + let parsed_url = url.replace("http://", ""); + let mut parts = parsed_url.splitn(2, '/'); + let host_port = parts.next().ok_or("Invalid host")?; + let path = parts.next().unwrap_or(""); + + let mut stream = TcpStream::connect(host_port)?; + let request = format!( + "POST /{} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + path, host_port, body.len() + ); + stream.write_all(request.as_bytes())?; + stream.write_all(body)?; + + let mut response = Vec::new(); + stream.read_to_end(&mut response)?; + + if let Some(pos) = response.windows(4).position(|w| w == b"\r\n\r\n") { + Ok(response[pos + 4..].to_vec()) + } else { + Err("Invalid HTTP response".into()) + } + } + + #[test] + #[ignore] + fn test_live_integration() { + use tungstenite::{connect, Message}; + + let endpoint = "ws://127.0.0.1:28093"; + let options = json!({ + "playerId": "integration-test-player", + "actorId": "integration-test-player", + "displayName": "Rust Integration Test Agent", + "zoneId": "open-desert", + "spawnArea": "open-desert-overworld" + }); + + // 1. Matchmake + let (matchmake_url, body) = colyseus::build_matchmake_request(endpoint, &options).unwrap(); + let seat_res_bytes = + http_post(&matchmake_url, &body).expect("HTTP matchmaking request failed"); + let seat_res = colyseus::parse_seat_reservation(&seat_res_bytes) + .expect("Failed to parse seat reservation"); + let ws_url = colyseus::build_ws_url(endpoint, &seat_res); + + // 2. WebSocket connect + let (mut socket, _resp) = connect(ws_url).expect("WebSocket connection failed"); + let mut session = Session::new(); + session.start_connecting(); + + let outs = session.on_ws_event(WsInput::Open); + for out in outs { + if let SessionOut::SendFrame(frame) = out { + socket.send(Message::Binary(frame)).unwrap(); + } + } + + // 3. Message loop + loop { + let msg = socket.read().expect("WebSocket read failed"); + match msg { + Message::Binary(bytes) => { + let outs = session.on_ws_event(WsInput::Frame(&bytes)); + for out in outs { + match out { + SessionOut::SendFrame(frame) => { + socket.send(Message::Binary(frame)).unwrap(); + } + SessionOut::Emit(SessionEvent::Hello(_hello)) => { + // Success! Joined and authenticated. + socket.close(None).ok(); + return; + } + SessionOut::Emit(SessionEvent::Closed) => { + panic!("Session closed unexpectedly"); + } + SessionOut::Emit(SessionEvent::Error(err)) => { + panic!("Session error: {}", err); + } + _ => {} + } + } + } + Message::Close(_) => { + break; + } + _ => {} + } + } + } +} diff --git a/client-rust/source/engine-core/Cargo.toml b/client-rust/source/engine-core/Cargo.toml new file mode 100644 index 00000000..5de67e59 --- /dev/null +++ b/client-rust/source/engine-core/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "successor-engine-core" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "no_std ECS, math, and runtime for the Successor Rust client." + +[dependencies] +libm.workspace = true +miniz_oxide = { version = "0.8", default-features = false, features = ["with-alloc"] } +bevy_mikktspace = { version = "=1.0.0", default-features = false } +zune-core = { version = "=0.5.0", default-features = false } +zune-jpeg = { version = "=0.5.4", default-features = false } + +[features] +default = [] +# Enables std so the libtest harness links (unit tests / host tooling). Never +# enabled for shipping game builds. +std = [] +# Bare-metal panic handler (off by default). Our std artifacts never enable it; +# provided for a future genuine no_std binary embedding. See rt/panic.rs. +panic-handler = [] +# Wraps the global allocator with an atomic per-frame counter (see rt::alloc). +alloc-count = [] diff --git a/client-rust/source/engine-core/src/anim.rs b/client-rust/source/engine-core/src/anim.rs new file mode 100644 index 00000000..a6e018af --- /dev/null +++ b/client-rust/source/engine-core/src/anim.rs @@ -0,0 +1,373 @@ +//! Skeletal animation runtime: clip sampling, a layered pose mixer with +//! per-joint masks and cross-fade weights, and joint-palette computation for +//! GPU skinning. `no_std` + `alloc`. +//! +//! This is the substrate the pawn animator (Wave 3) layers gait/upper/grip/ +//! montage clips onto. Here we provide the primitives: sample a `GlbAnimation` +//! into a per-node local pose, blend poses, and flatten a node hierarchy into +//! the `joint * inverseBind` skin matrices a skinned vertex shader consumes. + +use alloc::vec::Vec; + +use crate::glb::{ChannelPath, GlbAnimation, GlbDocument, GlbSampler, Interp}; +use crate::math::{vec3, Mat4, Quat, Vec3}; + +/// One node's local transform (glTF TRS). +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct JointTransform { + pub t: Vec3, + pub r: Quat, + pub s: Vec3, +} + +impl Default for JointTransform { + fn default() -> Self { + JointTransform { + t: Vec3::ZERO, + r: Quat::IDENTITY, + s: Vec3::ONE, + } + } +} + +impl JointTransform { + pub fn matrix(&self) -> Mat4 { + Mat4::from_trs(self.t, self.r, self.s) + } +} + +/// Find the keyframe interval `[i, i+1]` containing `time` and the interpolation +/// factor `f` in `[0,1]`. Clamps to the ends. +fn locate(input: &[f32], time: f32) -> (usize, usize, f32) { + if input.len() <= 1 { + return (0, 0, 0.0); + } + if time <= input[0] { + return (0, 0, 0.0); + } + let last = input.len() - 1; + if time >= input[last] { + return (last, last, 0.0); + } + let mut i = 0; + while i + 1 < input.len() && input[i + 1] < time { + i += 1; + } + let t0 = input[i]; + let t1 = input[i + 1]; + let f = if t1 > t0 { + (time - t0) / (t1 - t0) + } else { + 0.0 + }; + (i, i + 1, f) +} + +fn sample_vec3(s: &GlbSampler, time: f32) -> Option<Vec3> { + if s.output.len() < 3 { + return None; + } + let (i0, i1, f) = locate(&s.input, time); + let a = vec3(s.output[i0 * 3], s.output[i0 * 3 + 1], s.output[i0 * 3 + 2]); + if s.interp == Interp::Step || i0 == i1 { + return Some(a); + } + let b = vec3(s.output[i1 * 3], s.output[i1 * 3 + 1], s.output[i1 * 3 + 2]); + Some(a.add(b.sub(a).scale(f))) +} + +fn sample_quat(s: &GlbSampler, time: f32) -> Option<Quat> { + if s.output.len() < 4 { + return None; + } + let (i0, i1, f) = locate(&s.input, time); + let a = Quat { + x: s.output[i0 * 4], + y: s.output[i0 * 4 + 1], + z: s.output[i0 * 4 + 2], + w: s.output[i0 * 4 + 3], + }; + if s.interp == Interp::Step || i0 == i1 { + return Some(a); + } + let b = Quat { + x: s.output[i1 * 4], + y: s.output[i1 * 4 + 1], + z: s.output[i1 * 4 + 2], + w: s.output[i1 * 4 + 3], + }; + Some(nlerp(a, b, f)) +} + +/// Normalized lerp with shortest-arc correction — cheaper than slerp and +/// visually equivalent for animation keyframe density. +pub fn nlerp(a: Quat, mut b: Quat, f: f32) -> Quat { + let dot = a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w; + if dot < 0.0 { + b = Quat { + x: -b.x, + y: -b.y, + z: -b.z, + w: -b.w, + }; + } + Quat { + x: a.x + (b.x - a.x) * f, + y: a.y + (b.y - a.y) * f, + z: a.z + (b.z - a.z) * f, + w: a.w + (b.w - a.w) * f, + } + .normalize() +} + +/// Overlay one animation's channels onto an existing per-node pose at `time`. +/// Channels only touch the nodes they target, so unanimated joints keep their +/// current (rest or previously-blended) value. +pub fn apply_animation(anim: &GlbAnimation, time: f32, pose: &mut [JointTransform]) { + for ch in &anim.channels { + let Some(sampler) = anim.samplers.get(ch.sampler) else { + continue; + }; + if ch.target_node >= pose.len() { + continue; + } + match ch.path { + ChannelPath::Translation => { + if let Some(v) = sample_vec3(sampler, time) { + pose[ch.target_node].t = v; + } + } + ChannelPath::Rotation => { + if let Some(q) = sample_quat(sampler, time) { + pose[ch.target_node].r = q; + } + } + ChannelPath::Scale => { + if let Some(v) = sample_vec3(sampler, time) { + pose[ch.target_node].s = v; + } + } + } + } +} + +/// Blend `overlay` into `base` per joint by `weight` (0 = keep base, 1 = take +/// overlay). `mask`, when present, gates which joints the overlay may touch +/// (`true` = affected) — the mechanism the pawn animator uses for upper-body / +/// montage layers. +pub fn blend_into( + base: &mut [JointTransform], + overlay: &[JointTransform], + weight: f32, + mask: Option<&[bool]>, +) { + if weight <= 0.0 { + return; + } + let w = weight.min(1.0); + let n = base.len().min(overlay.len()); + for i in 0..n { + if let Some(m) = mask { + if !m.get(i).copied().unwrap_or(false) { + continue; + } + } + let a = base[i]; + let b = overlay[i]; + base[i] = JointTransform { + t: a.t.add(b.t.sub(a.t).scale(w)), + r: nlerp(a.r, b.r, w), + s: a.s.add(b.s.sub(a.s).scale(w)), + }; + } +} + +/// A flattened skeleton: node hierarchy in parent-first order plus the skin's +/// joint list and inverse bind matrices. Built once per body template. +#[derive(Clone, Debug)] +pub struct Skeleton { + /// Per-node parent (`None` for roots). + pub parent: Vec<Option<usize>>, + /// Per-node rest local transform. + pub rest: Vec<JointTransform>, + /// Node indices, topologically ordered so a parent precedes its children. + pub order: Vec<usize>, + /// Skin joint node indices (palette order). + pub joints: Vec<usize>, + /// Inverse bind matrix per joint. + pub inverse_bind: Vec<Mat4>, + /// Per-node name (for socket/bone lookup by name). + pub names: Vec<Option<alloc::string::String>>, + /// Scratch global matrices per node (reused; no per-frame alloc). + globals: Vec<Mat4>, +} + +impl Skeleton { + /// Build from a parsed document and a skin index. + pub fn from_document(doc: &GlbDocument, skin_index: usize) -> Option<Skeleton> { + let skin = doc.skins.get(skin_index)?; + let node_count = doc.nodes.len(); + let mut parent = alloc::vec![None; node_count]; + for (i, node) in doc.nodes.iter().enumerate() { + for &c in &node.children { + if c < node_count { + parent[c] = Some(i); + } + } + } + let rest: Vec<JointTransform> = doc + .nodes + .iter() + .map(|n| JointTransform { + t: n.translation, + r: n.rotation, + s: n.scale, + }) + .collect(); + // Topological order: roots first, then breadth-first via children. + let mut order = Vec::with_capacity(node_count); + let mut visited = alloc::vec![false; node_count]; + let mut stack: Vec<usize> = (0..node_count).filter(|&i| parent[i].is_none()).collect(); + // Process as a queue preserving parent-before-child. + let mut qi = 0; + while qi < stack.len() { + let n = stack[qi]; + qi += 1; + if visited[n] { + continue; + } + visited[n] = true; + order.push(n); + for &c in &doc.nodes[n].children { + if c < node_count && !visited[c] { + stack.push(c); + } + } + } + Some(Skeleton { + parent, + rest, + order, + joints: skin.joints.clone(), + inverse_bind: skin.inverse_bind.clone(), + names: doc.nodes.iter().map(|n| n.name.clone()).collect(), + globals: alloc::vec![Mat4::IDENTITY; node_count], + }) + } + + /// A fresh pose initialized to the rest transforms. + pub fn rest_pose(&self) -> Vec<JointTransform> { + self.rest.clone() + } + + /// Compute the skinning palette (`global[joint] * inverseBind[joint]`) for a + /// pose, writing `joints.len()` matrices into `out` (cleared first). + pub fn compute_palette(&mut self, pose: &[JointTransform], out: &mut Vec<[f32; 16]>) { + for &n in &self.order { + let local = pose.get(n).copied().unwrap_or_default().matrix(); + self.globals[n] = match self.parent[n] { + Some(p) => self.globals[p].mul(local), + None => local, + }; + } + out.clear(); + for (j, &node) in self.joints.iter().enumerate() { + let g = self.globals.get(node).copied().unwrap_or(Mat4::IDENTITY); + let ibm = self.inverse_bind.get(j).copied().unwrap_or(Mat4::IDENTITY); + out.push(g.mul(ibm).to_cols_array()); + } + } + + pub fn joint_count(&self) -> usize { + self.joints.len() + } + + /// First node whose name contains `substr` (case-sensitive) — socket lookup. + pub fn find_bone(&self, substr: &str) -> Option<usize> { + self.names + .iter() + .position(|n| n.as_deref().map(|s| s.contains(substr)).unwrap_or(false)) + } + + /// World matrix of a node from the last `compute_palette` call. + pub fn bone_global(&self, node: usize) -> Mat4 { + self.globals.get(node).copied().unwrap_or(Mat4::IDENTITY) + } +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + use crate::glb::{GlbChannel, GlbSampler}; + + fn lin_sampler(input: Vec<f32>, output: Vec<f32>) -> GlbSampler { + GlbSampler { + input, + output, + interp: Interp::Linear, + } + } + + #[test] + fn samples_translation_midpoint() { + let anim = GlbAnimation { + name: None, + samplers: alloc::vec![lin_sampler( + alloc::vec![0.0, 1.0], + alloc::vec![0.0, 0.0, 0.0, 4.0, 0.0, 0.0] + )], + channels: alloc::vec![GlbChannel { + sampler: 0, + target_node: 0, + path: ChannelPath::Translation, + }], + duration: 1.0, + }; + let mut pose = alloc::vec![JointTransform::default()]; + apply_animation(&anim, 0.5, &mut pose); + assert!((pose[0].t.x - 2.0).abs() < 1e-5); + } + + #[test] + fn step_holds_left_key() { + let mut s = lin_sampler( + alloc::vec![0.0, 1.0], + alloc::vec![0.0, 0.0, 0.0, 9.0, 0.0, 0.0], + ); + s.interp = Interp::Step; + assert_eq!(sample_vec3(&s, 0.9).unwrap().x, 0.0); + assert_eq!(sample_vec3(&s, 1.0).unwrap().x, 9.0); + } + + #[test] + fn blend_weight_zero_and_one() { + let mut base = alloc::vec![JointTransform::default()]; + let overlay = alloc::vec![JointTransform { + t: vec3(10.0, 0.0, 0.0), + ..Default::default() + }]; + let mut b0 = base.clone(); + blend_into(&mut b0, &overlay, 0.0, None); + assert_eq!(b0[0].t.x, 0.0); + blend_into(&mut base, &overlay, 1.0, None); + assert!((base[0].t.x - 10.0).abs() < 1e-5); + } + + #[test] + fn mask_gates_joints() { + let mut base = alloc::vec![JointTransform::default(), JointTransform::default()]; + let overlay = alloc::vec![ + JointTransform { + t: vec3(5.0, 0.0, 0.0), + ..Default::default() + }, + JointTransform { + t: vec3(5.0, 0.0, 0.0), + ..Default::default() + }, + ]; + blend_into(&mut base, &overlay, 1.0, Some(&[true, false])); + assert!((base[0].t.x - 5.0).abs() < 1e-5); + assert_eq!(base[1].t.x, 0.0); + } +} diff --git a/client-rust/source/engine-core/src/assets.rs b/client-rust/source/engine-core/src/assets.rs new file mode 100644 index 00000000..597b3422 --- /dev/null +++ b/client-rust/source/engine-core/src/assets.rs @@ -0,0 +1,327 @@ +//! Asset resolution tailored to this repository's conventions (NOT the voxel +//! VPAK model). +//! +//! This repo describes runtime assets with small manifest JSON files that carry +//! an `assetBase` plus `entries` keyed by a stable id, and resolves the final +//! public URL through `client/src/slice-core/runtimePublicPaths.ts` +//! (`resolveRuntimePublicPath`). Two manifest shapes exist and both are +//! accepted here: +//! +//! * object-keyed (e.g. `client-3d/src/render/props-mapping.json`): +//! `{ "format": "...", "assetBase": "/assets/world-items/", +//! "entries": { "road_barrier": { "glb": "barricade_concrete.glb" }, ... } }` +//! * array-with-id (e.g. `client-3d/public/assets/wave-props/manifest.json`): +//! `{ "format": "...", "assetBase": "/assets/wave-props/", +//! "entries": [ { "id": "ammo_001", "glb": "...", "kind": "..." }, ... ] }` +//! +//! `engine-core` performs no I/O: the platform fetches the manifest bytes +//! (`fetch` on web, filesystem on native), and this module parses + indexes the +//! already-decoded [`Json`]. URL resolution mirrors `props.ts`: an entry `glb` +//! that itself starts with `/` is treated as an absolute public path; otherwise +//! it is joined onto `assetBase`. + +use alloc::collections::BTreeMap; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use crate::json::Json; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AssetError { + /// Manifest was not a JSON object, or `entries` had an unusable shape. + Malformed, + /// An array entry was missing its required `id`. + MissingId, + /// `expect_format`/`expect_schema` discriminator did not match. + WrongSchema, +} + +#[derive(Clone, PartialEq, Debug)] +pub struct AssetEntry { + pub key: String, + /// The `glb` (or `path`) field, relative to `assetBase` unless it is itself + /// an absolute `/…` public path. + pub glb: String, + pub kind: Option<String>, + /// The full original entry object, so callers can read extra fields + /// (`interactable`, `assetRotationDegrees`, `animatedScreen`, …). + pub extra: Json, +} + +pub struct AssetManifest { + pub format: Option<String>, + pub asset_base: String, + entries: Vec<AssetEntry>, + index: BTreeMap<String, usize>, +} + +impl AssetManifest { + /// Parse an already-decoded manifest `Json`. + pub fn from_json(root: &Json) -> Result<AssetManifest, AssetError> { + if root.as_object().is_none() { + return Err(AssetError::Malformed); + } + let format = root + .get("format") + .or_else(|| root.get("schema")) + .and_then(Json::as_str) + .map(|s| s.to_string()); + let asset_base = root + .get("assetBase") + .and_then(Json::as_str) + .unwrap_or("") + .to_string(); + + let mut entries = Vec::new(); + match root.get("entries") { + Some(Json::Obj(fields)) => { + for (key, val) in fields { + entries.push(entry_from(key.clone(), val)); + } + } + Some(Json::Arr(items)) => { + for item in items { + let key = item + .get("id") + .and_then(Json::as_str) + .ok_or(AssetError::MissingId)? + .to_string(); + entries.push(entry_from(key, item)); + } + } + _ => return Err(AssetError::Malformed), + } + + let mut index = BTreeMap::new(); + for (i, e) in entries.iter().enumerate() { + index.insert(e.key.clone(), i); + } + Ok(AssetManifest { + format, + asset_base, + entries, + index, + }) + } + + pub fn expect_format(&self, want: &str) -> Result<(), AssetError> { + match self.format.as_deref() { + Some(f) if f == want => Ok(()), + _ => Err(AssetError::WrongSchema), + } + } + + /// Alias for manifests that use the `"schema"` discriminator convention. + pub fn expect_schema(&self, want: &str) -> Result<(), AssetError> { + self.expect_format(want) + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn get(&self, key: &str) -> Option<&AssetEntry> { + self.index.get(key).map(|&i| &self.entries[i]) + } + + pub fn entries(&self) -> &[AssetEntry] { + &self.entries + } + + /// Resolve a stable asset key to its final public URL through `resolver`. + /// Mirrors `props.ts`: an absolute `glb` is used directly; otherwise it is + /// joined onto `assetBase`. + pub fn resolve_url(&self, key: &str, resolver: &PublicPathResolver) -> Option<String> { + let entry = self.get(key)?; + let joined = if entry.glb.starts_with('/') { + entry.glb.clone() + } else { + let mut s = self.asset_base.clone(); + s.push_str(&entry.glb); + s + }; + resolver.resolve(&joined) + } +} + +fn entry_from(key: String, val: &Json) -> AssetEntry { + let glb = val + .get("glb") + .or_else(|| val.get("path")) + .and_then(Json::as_str) + .unwrap_or("") + .to_string(); + let kind = val + .get("kind") + .and_then(Json::as_str) + .map(|s| s.to_string()); + AssetEntry { + key, + glb, + kind, + extra: val.clone(), + } +} + +/// Read the `"schema"` discriminator of any decoded JSON, if present. +pub fn schema_of(root: &Json) -> Option<&str> { + root.get("schema").and_then(Json::as_str) +} + +/// Read the `"format"` discriminator of any decoded JSON, if present. +pub fn format_of(root: &Json) -> Option<&str> { + root.get("format").and_then(Json::as_str) +} + +/// Resolves manifest-relative public paths to servable URLs, porting the +/// fail-closed rules of `resolveRuntimePublicPath`. `base_dir` is the directory +/// the immutable client is served from (e.g. `/releases/<id>/`); empty or `/` +/// means root-relative (headless / native). +#[derive(Clone, Debug, Default)] +pub struct PublicPathResolver { + pub base_dir: String, +} + +impl PublicPathResolver { + pub fn new(base_dir: impl Into<String>) -> Self { + Self { + base_dir: base_dir.into(), + } + } + + /// Root-relative resolver (native / headless). + pub fn root() -> Self { + Self { + base_dir: String::new(), + } + } + + /// Returns the servable path, or `None` if the input is unsafe: a + /// protocol-relative `//…`, an absolute-scheme `foo:…`, or any `..` + /// traversal. Relative inputs are returned unchanged; absolute `/…` inputs + /// are prefixed with `base_dir` unless already under it. + pub fn resolve(&self, path: &str) -> Option<String> { + if path.starts_with("//") || has_scheme(path) { + return None; + } + if path.contains("..") { + return None; + } + if !path.starts_with('/') { + return Some(path.to_string()); + } + let base = self.base_dir.trim_end_matches('/'); + if base.is_empty() || path.starts_with(base) { + return Some(path.to_string()); + } + let mut s = String::from(base); + s.push_str(path); + Some(s) + } +} + +/// True if `s` begins with a URL scheme (`[a-z][a-z0-9+.-]*:`) before any `/`. +fn has_scheme(s: &str) -> bool { + let b = s.as_bytes(); + if b.is_empty() || !b[0].is_ascii_alphabetic() { + return false; + } + for &c in &b[1..] { + if c == b':' { + return true; + } + if !(c.is_ascii_alphanumeric() || c == b'+' || c == b'-' || c == b'.') { + return false; + } + } + false +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + + #[test] + fn object_keyed_manifest() { + let j = Json::parse( + r#"{ "format": "successor/props-mapping/1", "assetBase": "/assets/world-items/", + "entries": { "road_barrier": { "glb": "barricade_concrete.glb" }, + "chest": { "glb": "supply_cache.glb", "interactable": true } } }"#, + ) + .unwrap(); + let m = AssetManifest::from_json(&j).unwrap(); + assert!(m.expect_format("successor/props-mapping/1").is_ok()); + assert_eq!(m.len(), 2); + let e = m.get("chest").unwrap(); + assert_eq!(e.glb, "supply_cache.glb"); + assert_eq!( + e.extra.get("interactable").and_then(Json::as_bool), + Some(true) + ); + assert_eq!( + m.resolve_url("road_barrier", &PublicPathResolver::root()) + .as_deref(), + Some("/assets/world-items/barricade_concrete.glb") + ); + } + + #[test] + fn array_with_id_manifest() { + let j = Json::parse( + r#"{ "format": "successor/trial-props/1", "assetBase": "/assets/wave-props/", + "entries": [ { "id": "ammo_001", "glb": "a/ammo_001.glb", "kind": "ammo" } ] }"#, + ) + .unwrap(); + let m = AssetManifest::from_json(&j).unwrap(); + let e = m.get("ammo_001").unwrap(); + assert_eq!(e.kind.as_deref(), Some("ammo")); + assert_eq!( + m.resolve_url("ammo_001", &PublicPathResolver::root()) + .as_deref(), + Some("/assets/wave-props/a/ammo_001.glb") + ); + } + + #[test] + fn absolute_glb_bypasses_asset_base() { + let j = Json::parse( + r#"{ "assetBase": "/assets/world-items/", + "entries": { "k": { "glb": "/assets/wave-props/x.glb" } } }"#, + ) + .unwrap(); + let m = AssetManifest::from_json(&j).unwrap(); + assert_eq!( + m.resolve_url("k", &PublicPathResolver::root()).as_deref(), + Some("/assets/wave-props/x.glb") + ); + } + + #[test] + fn release_dir_prefixing() { + let r = PublicPathResolver::new("/releases/abc/"); + assert_eq!( + r.resolve("/assets/x.glb").as_deref(), + Some("/releases/abc/assets/x.glb") + ); + assert_eq!( + r.resolve("/releases/abc/assets/x.glb").as_deref(), + Some("/releases/abc/assets/x.glb") + ); + } + + #[test] + fn resolver_fails_closed() { + let r = PublicPathResolver::root(); + assert_eq!(r.resolve("//evil.example/x"), None); + assert_eq!(r.resolve("https://evil/x"), None); + assert_eq!(r.resolve("/a/../../etc/passwd"), None); + assert_eq!( + r.resolve("relative/x.glb").as_deref(), + Some("relative/x.glb") + ); + } +} diff --git a/client-rust/source/engine-core/src/audio.rs b/client-rust/source/engine-core/src/audio.rs new file mode 100644 index 00000000..6f7933df --- /dev/null +++ b/client-rust/source/engine-core/src/audio.rs @@ -0,0 +1,402 @@ +//! Software audio mixer + spatialization — a `no_std` port of the mixing math +//! in `client/src/audio/sfx.ts` (Web Audio there, a CPU voice mixer here). +//! +//! [`spatial_mix`] reproduces `computeSfxSpatialMix` exactly (distance rolloff +//! with a smooth far cutoff + horizontal pan). [`Mixer`] owns a fixed voice pool +//! that mixes mono PCM sources into an interleaved stereo buffer with per-voice +//! gain, equal-power pan, and pitch (resampling), honoring per-clip polyphony +//! and a concurrency-overload attenuation. All storage is allocated up front; +//! `mix_into` never allocates. + +use alloc::vec; +use alloc::vec::Vec; + +/// A point in the sim plane (cells). Sim (x,y); the listener/source live here. +#[derive(Clone, Copy, Debug)] +pub struct Point { + pub x: f32, + pub y: f32, +} + +/// Spatial options (mirror `SfxPlayOptions` spatial fields, with defaults). +#[derive(Clone, Copy, Debug)] +pub struct SpatialOpts { + pub min_distance: f32, + pub max_distance: f32, + pub rolloff: f32, + pub far_gain_floor: f32, + pub max_pan: f32, + pub pan_distance: f32, +} + +impl Default for SpatialOpts { + fn default() -> Self { + Self { + min_distance: 3.5, + max_distance: 34.0, + rolloff: 1.35, + far_gain_floor: 0.0, + max_pan: 0.85, + pan_distance: 13.0, + } + } +} + +/// Result of [`spatial_mix`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SpatialMix { + pub distance: f32, + pub gain: f32, + pub pan: f32, +} + +#[inline] +fn clampf(v: f32, lo: f32, hi: f32) -> f32 { + if v < lo { + lo + } else if v > hi { + hi + } else { + v + } +} + +/// Port of `computeSfxSpatialMix`: distance-based gain with a smoothstep far +/// cutoff (silent at/after `max_distance`) + clamped horizontal pan. +pub fn spatial_mix(listener: Point, position: Point, o: SpatialOpts) -> SpatialMix { + let dx = position.x - listener.x; + let dy = position.y - listener.y; + let distance = libm::sqrtf(dx * dx + dy * dy); + let min_d = o.min_distance; + let max_d = (min_d + 0.1).max(o.max_distance); + let t = clampf((distance - min_d) / (max_d - min_d), 0.0, 1.0); + let far_floor = clampf(o.far_gain_floor, 0.0, 0.5); + let shaped = far_floor + libm::powf(1.0 - t, o.rolloff) * (1.0 - far_floor); + // Smoothly take every curve to zero over the final 18% of the range. + let cutoff_t = clampf((t - 0.82) / 0.18, 0.0, 1.0); + let cutoff_gain = 1.0 - cutoff_t * cutoff_t * (3.0 - 2.0 * cutoff_t); + let gain = clampf(shaped * cutoff_gain, 0.0, 1.0); + let max_pan = clampf(o.max_pan, 0.0, 1.0); + let pan = clampf(dx / o.pan_distance, -max_pan, max_pan); + SpatialMix { + distance, + gain, + pan, + } +} + +/// Hard polyphony ceiling for a clip (port of `hardPolyphonyLimit`). +pub fn hard_polyphony_limit(polyphony: u32, looped: bool) -> u32 { + if looped { + return polyphony; + } + let a = polyphony; + let b = (64u32).min(libm::ceilf((polyphony as f32) * 2.5) as u32); + let c = polyphony + 12; + a.max(b.min(c)).max(a) // max(polyphony, min(64, ceil(*2.5), +12)) +} + +/// Concurrency-overload attenuation (port of `voiceConcurrencyGain`). +pub fn voice_concurrency_gain(overload_voices: i32) -> f32 { + if overload_voices <= 0 { + return 1.0; + } + clampf( + 1.0 / libm::sqrtf(1.0 + overload_voices as f32 * 0.72), + 0.38, + 1.0, + ) +} + +/// Mono PCM sample (normalized f32) at a given sample rate. +#[derive(Clone, Debug)] +pub struct Pcm { + pub samples: Vec<f32>, + pub sample_rate: u32, +} + +impl Pcm { + pub fn new(samples: Vec<f32>, sample_rate: u32) -> Self { + Self { + samples, + sample_rate, + } + } + pub fn duration_secs(&self) -> f32 { + if self.sample_rate == 0 { + 0.0 + } else { + self.samples.len() as f32 / self.sample_rate as f32 + } + } +} + +#[derive(Clone, Copy)] +struct Voice { + clip: usize, // index into the clip bank + cursor: f32, // fractional read position (samples) + step: f32, // per-output-sample advance (pitch × sr ratio) + gain: f32, + pan: f32, // -1..1 + looped: bool, + active: bool, + key: u32, // clip/source key for polyphony accounting +} + +/// A fixed-voice CPU mixer producing interleaved stereo f32. +pub struct Mixer { + out_rate: u32, + clips: Vec<Pcm>, + voices: Vec<Voice>, + master: f32, +} + +impl Mixer { + pub fn new(out_rate: u32, max_voices: usize) -> Self { + Self { + out_rate, + clips: Vec::new(), + voices: vec![ + Voice { + clip: 0, + cursor: 0.0, + step: 0.0, + gain: 0.0, + pan: 0.0, + looped: false, + active: false, + key: 0 + }; + max_voices + ], + master: 1.0, + } + } + + pub fn set_master(&mut self, g: f32) { + self.master = clampf(g, 0.0, 1.0); + } + + /// Register a clip; returns its bank index (a handle for `play`). + pub fn add_clip(&mut self, pcm: Pcm) -> usize { + self.clips.push(pcm); + self.clips.len() - 1 + } + + pub fn active_voices(&self) -> usize { + self.voices.iter().filter(|v| v.active).count() + } + + fn voices_for_key(&self, key: u32) -> u32 { + self.voices + .iter() + .filter(|v| v.active && v.key == key) + .count() as u32 + } + + /// Start a voice. `pitch` scales playback speed (1.0 = native). Enforces + /// `polyphony` per `key` (steals the oldest-cursor voice of that key when + /// full). Returns false if the clip index is invalid. + #[allow(clippy::too_many_arguments)] + pub fn play( + &mut self, + clip: usize, + key: u32, + gain: f32, + pan: f32, + pitch: f32, + looped: bool, + polyphony: u32, + ) -> bool { + if clip >= self.clips.len() { + return false; + } + let limit = hard_polyphony_limit(polyphony.max(1), looped); + if self.voices_for_key(key) >= limit { + // Steal the most-advanced (nearest-finished) voice of this key. + let mut steal = None; + let mut best = f32::MIN; + for (i, v) in self.voices.iter().enumerate() { + if v.active && v.key == key && v.cursor > best { + best = v.cursor; + steal = Some(i); + } + } + if let Some(i) = steal { + self.voices[i].active = false; + } + } + let ratio = self.clips[clip].sample_rate as f32 / self.out_rate as f32; + let slot = self.voices.iter().position(|v| !v.active); + let slot = match slot { + Some(s) => s, + None => { + // Pool full: steal the most-advanced voice overall. + let mut best = f32::MIN; + let mut idx = 0; + for (i, v) in self.voices.iter().enumerate() { + if v.cursor > best { + best = v.cursor; + idx = i; + } + } + idx + } + }; + self.voices[slot] = Voice { + clip, + cursor: 0.0, + step: ratio * pitch.max(0.01), + gain: clampf(gain, 0.0, 4.0), + pan: clampf(pan, -1.0, 1.0), + looped, + active: true, + key, + }; + true + } + + pub fn stop_key(&mut self, key: u32) { + for v in self.voices.iter_mut() { + if v.key == key { + v.active = false; + } + } + } + + /// Mix all active voices into `out` (interleaved stereo L,R). `out.len()` + /// must be even. Applies equal-power pan, per-voice gain, a concurrency + /// attenuation, and the master gain. Voices that reach the end deactivate + /// (or loop). No allocation. + pub fn mix_into(&mut self, out: &mut [f32]) { + for s in out.iter_mut() { + *s = 0.0; + } + let overload = self.active_voices() as i32 - 8; + let conc = voice_concurrency_gain(overload) * self.master; + let frames = out.len() / 2; + for v in self.voices.iter_mut() { + if !v.active { + continue; + } + let clip = &self.clips[v.clip]; + let n = clip.samples.len(); + if n == 0 { + v.active = false; + continue; + } + // Equal-power pan. + let p = (v.pan + 1.0) * 0.5; // 0..1 + let l_gain = libm::sqrtf(1.0 - p) * v.gain * conc; + let r_gain = libm::sqrtf(p) * v.gain * conc; + for f in 0..frames { + let idx = v.cursor as usize; + if idx >= n { + if v.looped { + v.cursor -= n as f32; + } else { + v.active = false; + break; + } + } + let idx = v.cursor as usize; + // Linear interpolation between idx and idx+1. + let frac = v.cursor - idx as f32; + let s0 = clip.samples[idx.min(n - 1)]; + let s1 = clip.samples[(idx + 1).min(n - 1)]; + let s = s0 + (s1 - s0) * frac; + out[f * 2] += s * l_gain; + out[f * 2 + 1] += s * r_gain; + v.cursor += v.step; + } + } + // Soft clip to [-1,1]. + for s in out.iter_mut() { + *s = clampf(*s, -1.0, 1.0); + } + } +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + + #[test] + fn spatial_close_is_loud_centered() { + let m = spatial_mix( + Point { x: 0.0, y: 0.0 }, + Point { x: 0.0, y: 0.0 }, + SpatialOpts::default(), + ); + assert!(m.gain > 0.99, "at listener → full gain"); + assert!(m.pan.abs() < 1e-6, "centered"); + } + + #[test] + fn spatial_far_is_silent() { + let m = spatial_mix( + Point { x: 0.0, y: 0.0 }, + Point { x: 0.0, y: 40.0 }, + SpatialOpts::default(), + ); + assert_eq!(m.gain, 0.0, "beyond max distance → silent"); + } + + #[test] + fn spatial_pan_follows_side() { + let o = SpatialOpts::default(); + let right = spatial_mix(Point { x: 0.0, y: 0.0 }, Point { x: 10.0, y: 2.0 }, o); + let left = spatial_mix(Point { x: 0.0, y: 0.0 }, Point { x: -10.0, y: 2.0 }, o); + assert!(right.pan > 0.0 && left.pan < 0.0, "pan sign tracks dx"); + assert!(right.pan <= 0.85 && left.pan >= -0.85, "clamped to max pan"); + } + + #[test] + fn concurrency_gain_drops_with_overload() { + assert_eq!(voice_concurrency_gain(0), 1.0); + assert!(voice_concurrency_gain(8) < 1.0); + assert!(voice_concurrency_gain(1000) >= 0.38, "floored"); + } + + #[test] + fn polyphony_limit_matches_reference() { + // non-looped: max(p, min(64, ceil(p*2.5), p+12)) + assert_eq!(hard_polyphony_limit(4, false), 10); // min(64, ceil(10), 16)=10 → max(4,10)=10 + assert_eq!(hard_polyphony_limit(1, true), 1); // looped keeps polyphony + } + + #[test] + fn mixer_sums_voice_into_stereo() { + let mut mx = Mixer::new(48_000, 8); + // A constant DC sample at native rate. + let clip = mx.add_clip(Pcm::new(vec![0.5; 48_000], 48_000)); + assert!(mx.play(clip, 1, 1.0, 0.0, 1.0, false, 4)); + assert_eq!(mx.active_voices(), 1); + let mut buf = vec![0.0f32; 512]; // 256 stereo frames + mx.mix_into(&mut buf); + // Centered pan → both channels ~ 0.5 * sqrt(0.5) = 0.3535. + assert!((buf[0] - 0.3535).abs() < 0.02, "L ~0.354, got {}", buf[0]); + assert!((buf[1] - 0.3535).abs() < 0.02, "R ~0.354, got {}", buf[1]); + } + + #[test] + fn non_looped_voice_finishes_and_frees() { + let mut mx = Mixer::new(48_000, 4); + let clip = mx.add_clip(Pcm::new(vec![1.0; 4], 48_000)); + mx.play(clip, 1, 1.0, 0.0, 1.0, false, 4); + let mut buf = vec![0.0f32; 64]; + mx.mix_into(&mut buf); // 32 frames >> 4 samples → finishes + assert_eq!(mx.active_voices(), 0, "short clip finished and freed"); + } + + #[test] + fn hard_pan_left_silences_right() { + let mut mx = Mixer::new(48_000, 4); + let clip = mx.add_clip(Pcm::new(vec![1.0; 4800], 48_000)); + mx.play(clip, 1, 1.0, -1.0, 1.0, false, 4); + let mut buf = vec![0.0f32; 8]; + mx.mix_into(&mut buf); + assert!(buf[0] > 0.5, "left has signal"); + assert!(buf[1].abs() < 1e-3, "right silent on hard-left pan"); + } +} diff --git a/client-rust/source/engine-core/src/ecs.rs b/client-rust/source/engine-core/src/ecs.rs new file mode 100644 index 00000000..793154f0 --- /dev/null +++ b/client-rust/source/engine-core/src/ecs.rs @@ -0,0 +1,658 @@ +//! Minimal archetype-free ECS. +//! +//! Design ported from `~/code/sandbox/voxel_engine/source/engine/src/ecs.rs` +//! (itself a port of the Zig `vibe-jam-2026` ECS). Reimplemented fresh here, +//! with the prefab/JSON coupling removed — this engine loads no prefab files. +//! +//! Semantics preserved exactly: +//! - `Entity { index, generation }`, recycled indices, bumped generations. +//! - Dense storage: packed arrays + lookup map, swap-remove. +//! - Sparse storage (opt-in per component): map only. +//! - Deferred destruction: `destroy()` queues, `flush()` applies. +//! - Queries driven by the first component; other components checked via +//! `has()`; dead entities skipped. +//! +//! Query invariants (same as the reference): never mutate the driving +//! component's storage during iteration; refs yielded by `next()` are +//! invalidated by inserting into any queried storage — don't hold them across +//! mutations. + +use alloc::collections::BTreeMap; +use alloc::vec::Vec; +use core::marker::PhantomData; +use core::ops::Bound; + +// ============================================================================ +// Entity +// ============================================================================ + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Entity { + pub index: u64, + pub generation: u64, +} + +impl Entity { + pub const NIL: Entity = Entity { + index: u64::MAX, + generation: u64::MAX, + }; + + pub fn is_nil(self) -> bool { + self.index == u64::MAX + } +} + +impl Default for Entity { + fn default() -> Self { + Entity::NIL + } +} + +// ============================================================================ +// Storage +// ============================================================================ + +/// Iteration cursor for the driving storage of a query. +pub enum Cursor { + Dense(usize), + /// Last yielded key (None = before first). + Sparse(Option<u64>), +} + +pub trait Storage<T>: Default { + fn has(&self, ei: u64) -> bool; + fn get(&mut self, ei: u64) -> Option<&mut T>; + fn set(&mut self, ei: u64, value: T); + fn remove(&mut self, ei: u64); + fn count(&self) -> usize; + fn start(&self) -> Cursor; + /// Advances the cursor; yields (entity index, component ptr). + fn drive(&mut self, cursor: &mut Cursor) -> Option<(u64, *mut T)>; +} + +/// Packed-array storage for cache-friendly iteration. +pub struct DenseStorage<T> { + pub data: Vec<T>, + pub entities: Vec<u64>, + lookup: BTreeMap<u64, usize>, +} + +impl<T> Default for DenseStorage<T> { + fn default() -> Self { + Self { + data: Vec::new(), + entities: Vec::new(), + lookup: BTreeMap::new(), + } + } +} + +impl<T> Storage<T> for DenseStorage<T> { + fn has(&self, ei: u64) -> bool { + self.lookup.contains_key(&ei) + } + + fn get(&mut self, ei: u64) -> Option<&mut T> { + let di = *self.lookup.get(&ei)?; + Some(&mut self.data[di]) + } + + fn set(&mut self, ei: u64, value: T) { + if let Some(&di) = self.lookup.get(&ei) { + self.data[di] = value; + } else { + let di = self.data.len(); + self.data.push(value); + self.entities.push(ei); + self.lookup.insert(ei, di); + } + } + + fn remove(&mut self, ei: u64) { + let Some(di) = self.lookup.remove(&ei) else { + return; + }; + let last = self.data.len() - 1; + if di != last { + self.data.swap(di, last); + let moved_ei = self.entities[last]; + self.entities[di] = moved_ei; + *self.lookup.get_mut(&moved_ei).unwrap() = di; + } + self.data.pop(); + self.entities.pop(); + } + + fn count(&self) -> usize { + self.data.len() + } + + fn start(&self) -> Cursor { + Cursor::Dense(0) + } + + fn drive(&mut self, cursor: &mut Cursor) -> Option<(u64, *mut T)> { + let Cursor::Dense(i) = cursor else { + return None; + }; + if *i >= self.entities.len() { + return None; + } + let ei = self.entities[*i]; + let ptr = &mut self.data[*i] as *mut T; + *i += 1; + Some((ei, ptr)) + } +} + +/// Map-only storage for rare components. +pub struct SparseStorage<T> { + pub map: BTreeMap<u64, T>, +} + +impl<T> Default for SparseStorage<T> { + fn default() -> Self { + Self { + map: BTreeMap::new(), + } + } +} + +impl<T> Storage<T> for SparseStorage<T> { + fn has(&self, ei: u64) -> bool { + self.map.contains_key(&ei) + } + + fn get(&mut self, ei: u64) -> Option<&mut T> { + self.map.get_mut(&ei) + } + + fn set(&mut self, ei: u64, value: T) { + self.map.insert(ei, value); + } + + fn remove(&mut self, ei: u64) { + self.map.remove(&ei); + } + + fn count(&self) -> usize { + self.map.len() + } + + fn start(&self) -> Cursor { + Cursor::Sparse(None) + } + + fn drive(&mut self, cursor: &mut Cursor) -> Option<(u64, *mut T)> { + let Cursor::Sparse(last) = cursor else { + return None; + }; + let lower = match *last { + None => Bound::Unbounded, + Some(k) => Bound::Excluded(k), + }; + let (&k, v) = self.map.range_mut((lower, Bound::Unbounded)).next()?; + *last = Some(k); + Some((k, v as *mut T)) + } +} + +// ============================================================================ +// Component registration +// ============================================================================ + +pub trait Component: 'static + Sized { + type Storage: Storage<Self>; +} + +/// `impl_component!(Transform: dense);` / `impl_component!(Camera: sparse);` +#[macro_export] +macro_rules! impl_component { + ($T:ty : dense) => { + impl $crate::ecs::Component for $T { + type Storage = $crate::ecs::DenseStorage<$T>; + } + }; + ($T:ty : sparse) => { + impl $crate::ecs::Component for $T { + type Storage = $crate::ecs::SparseStorage<$T>; + } + }; +} + +// ============================================================================ +// Entity pool + world traits +// ============================================================================ + +pub struct EntityPool { + pub generations: Vec<u64>, + pub alive: Vec<bool>, + pub free_list: Vec<u64>, + pub entity_count: u64, + pub pending_destroy: Vec<Entity>, +} + +impl Default for EntityPool { + fn default() -> Self { + Self::new() + } +} + +impl EntityPool { + pub fn new() -> Self { + Self { + generations: Vec::new(), + alive: Vec::new(), + free_list: Vec::new(), + entity_count: 0, + pending_destroy: Vec::new(), + } + } + + pub fn is_alive(&self, entity: Entity) -> bool { + if entity.is_nil() { + return false; + } + let i = entity.index as usize; + if i >= self.generations.len() { + return false; + } + self.alive[i] && self.generations[i] == entity.generation + } +} + +pub trait WorldCore { + fn pool(&self) -> &EntityPool; + fn pool_mut(&mut self) -> &mut EntityPool; +} + +pub trait HasStorage<T: Component>: WorldCore { + fn storage(&mut self) -> &mut T::Storage; + fn storage_ref(&self) -> &T::Storage; +} + +/// Blanket entity/component operations. +pub trait WorldOps: WorldCore + Sized { + fn spawn(&mut self) -> Entity { + let pool = self.pool_mut(); + if let Some(index) = pool.free_list.pop() { + pool.alive[index as usize] = true; + pool.entity_count += 1; + return Entity { + index, + generation: pool.generations[index as usize], + }; + } + let index = pool.generations.len() as u64; + pool.generations.push(0); + pool.alive.push(true); + pool.entity_count += 1; + Entity { + index, + generation: 0, + } + } + + /// Queue an entity for destruction; components stay live until `flush()`. + fn destroy(&mut self, entity: Entity) { + if !self.is_alive(entity) { + return; + } + self.pool_mut().pending_destroy.push(entity); + } + + fn is_alive(&self, entity: Entity) -> bool { + self.pool().is_alive(entity) + } + + fn entity_count(&self) -> u64 { + self.pool().entity_count + } + + fn set_component<T: Component>(&mut self, entity: Entity, value: T) + where + Self: HasStorage<T>, + { + if !self.is_alive(entity) { + return; + } + HasStorage::<T>::storage(self).set(entity.index, value); + } + + fn get_component<T: Component>(&mut self, entity: Entity) -> Option<&mut T> + where + Self: HasStorage<T>, + { + if !self.is_alive(entity) { + return None; + } + HasStorage::<T>::storage(self).get(entity.index) + } + + fn has_component<T: Component>(&self, entity: Entity) -> bool + where + Self: HasStorage<T>, + { + if !self.is_alive(entity) { + return false; + } + HasStorage::<T>::storage_ref(self).has(entity.index) + } + + fn remove_component<T: Component>(&mut self, entity: Entity) + where + Self: HasStorage<T>, + { + if !self.is_alive(entity) { + return; + } + HasStorage::<T>::storage(self).remove(entity.index); + } + + /// Iterate entities that have `A`, skipping dead ones. + fn query1<A: Component>(&mut self) -> Query1<A, Self> + where + Self: HasStorage<A>, + { + Query1 { + world: self as *mut Self, + cursor: HasStorage::<A>::storage_ref(self).start(), + _m: PhantomData, + } + } + + /// Iterate entities that have both `A` and `B`; `A` drives iteration — + /// put the most selective component first. + fn query2<A: Component, B: Component>(&mut self) -> Query2<A, B, Self> + where + Self: HasStorage<A> + HasStorage<B>, + { + Query2 { + world: self as *mut Self, + cursor: HasStorage::<A>::storage_ref(self).start(), + _m: PhantomData, + } + } +} + +impl<W: WorldCore> WorldOps for W {} + +// ============================================================================ +// Queries +// ============================================================================ + +pub struct Query1<A: Component, W> { + world: *mut W, + cursor: Cursor, + _m: PhantomData<A>, +} + +impl<A: Component, W: WorldCore + HasStorage<A>> Query1<A, W> { + #[allow(clippy::should_implement_trait)] + pub fn next(&mut self) -> Option<(Entity, &mut A)> { + // SAFETY: the query holds a unique `*mut W` for its lifetime; the caller + // must not mutate the driving storage during iteration (documented + // invariant). We reborrow per step and never alias the yielded ref. + unsafe { + loop { + let (ei, ptr) = + HasStorage::<A>::storage(&mut *self.world).drive(&mut self.cursor)?; + let i = ei as usize; + if !(*self.world).pool().alive[i] { + continue; + } + let generation = (*self.world).pool().generations[i]; + return Some(( + Entity { + index: ei, + generation, + }, + &mut *ptr, + )); + } + } + } +} + +pub struct Query2<A: Component, B: Component, W> { + world: *mut W, + cursor: Cursor, + _m: PhantomData<(A, B)>, +} + +impl<A: Component, B: Component, W: WorldCore + HasStorage<A> + HasStorage<B>> Query2<A, B, W> { + #[allow(clippy::should_implement_trait)] + pub fn next(&mut self) -> Option<(Entity, &mut A, &mut B)> { + // SAFETY: see Query1::next. `A` and `B` are distinct component types, so + // their storages are distinct fields — the two `&mut` never alias. + unsafe { + loop { + let (ei, a_ptr) = + HasStorage::<A>::storage(&mut *self.world).drive(&mut self.cursor)?; + let i = ei as usize; + if !(*self.world).pool().alive[i] { + continue; + } + if !HasStorage::<B>::storage_ref(&*self.world).has(ei) { + continue; + } + let b_ptr = HasStorage::<B>::storage(&mut *self.world).get(ei).unwrap() as *mut B; + let generation = (*self.world).pool().generations[i]; + return Some(( + Entity { + index: ei, + generation, + }, + &mut *a_ptr, + &mut *b_ptr, + )); + } + } + } +} + +// ============================================================================ +// world! macro +// ============================================================================ + +/// Generates the concrete World struct. +/// +/// ```ignore +/// successor_engine_core::world! { pub struct GameWorld { +/// transform: Transform, +/// camera: Camera, +/// } } +/// ``` +/// +/// Every listed component must implement `Component` (via `impl_component!`). +#[macro_export] +macro_rules! world { + (pub struct $W:ident { $($field:ident : $C:ty),+ $(,)? }) => { + pub struct $W { + pool: $crate::ecs::EntityPool, + $( $field: <$C as $crate::ecs::Component>::Storage, )+ + } + + impl $W { + pub fn new() -> Self { + Self { + pool: $crate::ecs::EntityPool::new(), + $( $field: core::default::Default::default(), )+ + } + } + + /// Process all deferred destructions. + pub fn flush(&mut self) { + let mut pending = core::mem::take(&mut self.pool.pending_destroy); + for entity in pending.iter().copied() { + self.destroy_immediate(entity); + } + pending.clear(); // retains capacity + self.pool.pending_destroy = pending; + } + + fn destroy_immediate(&mut self, entity: $crate::ecs::Entity) { + if !self.pool.is_alive(entity) { + return; + } + let i = entity.index as usize; + $( $crate::ecs::Storage::remove(&mut self.$field, entity.index); )+ + self.pool.alive[i] = false; + self.pool.generations[i] += 1; + self.pool.free_list.push(entity.index); + self.pool.entity_count -= 1; + } + } + + impl core::default::Default for $W { + fn default() -> Self { + Self::new() + } + } + + impl $crate::ecs::WorldCore for $W { + fn pool(&self) -> &$crate::ecs::EntityPool { + &self.pool + } + fn pool_mut(&mut self) -> &mut $crate::ecs::EntityPool { + &mut self.pool + } + } + + $( + impl $crate::ecs::HasStorage<$C> for $W { + fn storage(&mut self) -> &mut <$C as $crate::ecs::Component>::Storage { + &mut self.$field + } + fn storage_ref(&self) -> &<$C as $crate::ecs::Component>::Storage { + &self.$field + } + } + )+ + }; +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + + #[derive(Clone, Copy, PartialEq, Debug)] + struct Pos(u32); + #[derive(Clone, Copy, PartialEq, Debug)] + struct Vel(u32); + #[derive(Clone, Copy, PartialEq, Debug)] + struct Tag(u32); + + impl_component!(Pos: dense); + impl_component!(Vel: dense); + impl_component!(Tag: sparse); + + crate::world! { pub struct W { pos: Pos, vel: Vel, tag: Tag } } + + #[test] + fn entity_recycle_bumps_generation() { + let mut w = W::new(); + let a = w.spawn(); + assert_eq!(a.generation, 0); + w.destroy(a); + w.flush(); + assert!(!w.is_alive(a)); + let b = w.spawn(); + assert_eq!(b.index, a.index, "index recycled"); + assert_eq!(b.generation, 1, "generation bumped"); + assert!(w.is_alive(b)); + assert!(!w.is_alive(a), "stale handle stays dead"); + } + + #[test] + fn dense_swap_remove_preserves_others() { + let mut w = W::new(); + let e: Vec<_> = (0..4) + .map(|i| { + let x = w.spawn(); + w.set_component(x, Pos(i)); + x + }) + .collect(); + w.destroy(e[1]); + w.flush(); + assert!(!w.has_component::<Pos>(e[1])); + assert_eq!(w.get_component::<Pos>(e[0]), Some(&mut Pos(0))); + assert_eq!(w.get_component::<Pos>(e[2]), Some(&mut Pos(2))); + assert_eq!(w.get_component::<Pos>(e[3]), Some(&mut Pos(3))); + } + + #[test] + fn query1_visits_live_only() { + let mut w = W::new(); + for i in 0..5 { + let x = w.spawn(); + w.set_component(x, Pos(i)); + } + let dead = w.spawn(); + w.set_component(dead, Pos(99)); + w.destroy(dead); + w.flush(); + let mut sum = 0u32; + let mut q = w.query1::<Pos>(); + while let Some((_, p)) = q.next() { + sum += p.0; + } + assert_eq!(sum, 10); + } + + #[test] + fn query1_mid_iteration_insert_other_storage() { + // The documented capability: inserting a DIFFERENT-typed component while + // iterating the driving storage is sound. + let mut w = W::new(); + let ids: Vec<_> = (0..3) + .map(|i| { + let x = w.spawn(); + w.set_component(x, Pos(i)); + x + }) + .collect(); + let mut seen = 0; + let mut q = w.query1::<Pos>(); + while let Some((e, _)) = q.next() { + // Insert Vel into a component storage that is NOT the driver. + unsafe { + (*(&mut w as *mut W)).set_component(e, Vel(7)); + } + seen += 1; + } + assert_eq!(seen, 3); + for id in ids { + assert_eq!(w.get_component::<Vel>(id), Some(&mut Vel(7))); + } + } + + #[test] + fn query2_intersects() { + let mut w = W::new(); + let both = w.spawn(); + w.set_component(both, Pos(1)); + w.set_component(both, Vel(2)); + let pos_only = w.spawn(); + w.set_component(pos_only, Pos(3)); + let mut count = 0; + let mut q = w.query2::<Pos, Vel>(); + while let Some((_, p, v)) = q.next() { + assert_eq!((p.0, v.0), (1, 2)); + count += 1; + } + assert_eq!(count, 1, "only the entity with both components"); + } + + #[test] + fn sparse_component_roundtrip() { + let mut w = W::new(); + let e = w.spawn(); + w.set_component(e, Tag(42)); + assert_eq!(w.get_component::<Tag>(e), Some(&mut Tag(42))); + w.remove_component::<Tag>(e); + assert!(!w.has_component::<Tag>(e)); + } +} diff --git a/client-rust/source/engine-core/src/glb.rs b/client-rust/source/engine-core/src/glb.rs new file mode 100644 index 00000000..169b9311 --- /dev/null +++ b/client-rust/source/engine-core/src/glb.rs @@ -0,0 +1,1391 @@ +//! Hand-rolled binary glTF (`.glb`) reader for the checked-in Successor corpus. +//! +//! The reader is intentionally fail-closed: unsupported compression, sparse +//! accessors, external buffers/images, non-triangle primitives, cubic splines, +//! and animated morph weights are rejected instead of rendered incorrectly. +//! `no_std` + `alloc`, no `core::fmt`. + +use alloc::string::String; +use alloc::vec::Vec; + +use crate::json::{Json, JsonError}; +use crate::math::{vec3, Mat4, Quat, Vec3}; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum GlbError { + BadMagic, + BadVersion, + BadChunk, + MissingBin, + Json(JsonError), + /// A feature outside the supported subset (named for diagnostics). + Unsupported(&'static str), + /// An index or byte range pointed outside the file. + OutOfRange, + /// Accessor component/type combination we do not read. + BadAccessor, +} + +impl From<JsonError> for GlbError { + fn from(e: JsonError) -> Self { + GlbError::Json(e) + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AlphaMode { + Opaque, + Mask, + Blend, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum MagFilter { + Nearest, + Linear, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum MinFilter { + NearestMipmapNearest, + LinearMipmapLinear, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum WrapMode { + ClampToEdge, + Repeat, +} + +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct TextureRef { + pub texture: usize, + pub tex_coord: u8, + pub scale: f32, +} + +#[derive(Clone, Debug)] +pub struct GlbImage { + pub name: Option<String>, + pub mime_type: String, + pub bytes: Vec<u8>, +} + +#[derive(Clone, Copy, Debug)] +pub struct GlbTexture { + pub source: usize, + pub sampler: Option<usize>, +} + +#[derive(Clone, Copy, Debug)] +pub struct GlbTextureSampler { + pub mag_filter: MagFilter, + pub min_filter: MinFilter, + pub wrap_s: WrapMode, + pub wrap_t: WrapMode, +} +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Interp { + Linear, + Step, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ChannelPath { + Translation, + Rotation, + Scale, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum GlbLightKind { + Point, + Spot, + Directional, +} + +#[derive(Clone, Debug)] +pub struct GlbPunctualLight { + pub name: Option<String>, + pub kind: GlbLightKind, + pub color: [f32; 3], + pub intensity: f32, + pub range: Option<f32>, +} + +#[derive(Clone, Debug)] +pub struct GlbNode { + pub name: Option<String>, + pub translation: Vec3, + pub rotation: Quat, + pub scale: Vec3, + pub children: Vec<usize>, + pub mesh: Option<usize>, + pub skin: Option<usize>, + pub weights: Vec<f32>, + pub light: Option<usize>, +} + +impl GlbNode { + /// Local TRS as a column-major matrix. + pub fn local_matrix(&self) -> Mat4 { + Mat4::from_trs(self.translation, self.rotation, self.scale) + } +} + +#[derive(Clone, Debug, Default)] +pub struct GlbPrimitive { + pub positions: Vec<[f32; 3]>, + pub normals: Vec<[f32; 3]>, + pub uvs: Vec<[f32; 2]>, + pub joints: Vec<[u16; 4]>, + pub weights: Vec<[f32; 4]>, + pub tangents: Vec<[f32; 4]>, + pub colors: Vec<[u8; 4]>, + pub uvs1: Vec<[f32; 2]>, + pub color1: Vec<[u8; 4]>, + pub morph_targets: Vec<GlbMorphTarget>, + pub morph_weights: Vec<f32>, + pub indices: Vec<u32>, + pub material: Option<usize>, +} + +impl GlbPrimitive { + /// Vertex positions after applying the primitive's authored default morph + /// weights. Import-time consumers must use the same geometry the renderer + /// uploads when deriving bounds, emitters, or other spatial metadata. + pub fn morphed_positions(&self) -> Vec<[f32; 3]> { + let mut positions = self.positions.clone(); + for (target_index, target) in self.morph_targets.iter().enumerate() { + let weight = self.morph_weights.get(target_index).copied().unwrap_or(0.0); + if weight == 0.0 { + continue; + } + for (value, delta) in positions.iter_mut().zip(&target.positions) { + for axis in 0..3 { + value[axis] += delta[axis] * weight; + } + } + } + positions + } +} + +#[derive(Clone, Debug, Default)] +pub struct GlbMorphTarget { + pub positions: Vec<[f32; 3]>, + pub normals: Vec<[f32; 3]>, + pub tangents: Vec<[f32; 3]>, +} + +#[derive(Clone, Debug, Default)] +pub struct GlbMesh { + pub name: Option<String>, + pub primitives: Vec<GlbPrimitive>, + pub weights: Vec<f32>, +} + +#[derive(Clone, Debug)] +pub struct GlbMaterial { + pub name: Option<String>, + pub base_color: [f32; 4], + pub metallic: f32, + pub roughness: f32, + pub double_sided: bool, + pub alpha_mode: AlphaMode, + pub alpha_cutoff: f32, + pub base_color_texture: Option<TextureRef>, + pub metallic_roughness_texture: Option<TextureRef>, + pub normal_texture: Option<TextureRef>, + pub occlusion_texture: Option<TextureRef>, + pub emissive_texture: Option<TextureRef>, + pub normal_scale: f32, + pub occlusion_strength: f32, + pub emissive_factor: [f32; 3], + pub emissive_strength: f32, + pub clearcoat: f32, + pub clearcoat_roughness: f32, + pub ior: f32, + pub specular: f32, + pub transmission: f32, +} + +impl Default for GlbMaterial { + fn default() -> Self { + GlbMaterial { + name: None, + base_color: [1.0, 1.0, 1.0, 1.0], + metallic: 1.0, + roughness: 1.0, + double_sided: false, + alpha_mode: AlphaMode::Opaque, + alpha_cutoff: 0.5, + base_color_texture: None, + metallic_roughness_texture: None, + normal_texture: None, + occlusion_texture: None, + emissive_texture: None, + normal_scale: 1.0, + occlusion_strength: 1.0, + emissive_factor: [0.0; 3], + emissive_strength: 1.0, + clearcoat: 0.0, + clearcoat_roughness: 0.0, + ior: 1.5, + specular: 1.0, + transmission: 0.0, + } + } +} + +#[derive(Clone, Debug)] +pub struct GlbSkin { + pub joints: Vec<usize>, + pub inverse_bind: Vec<Mat4>, + pub skeleton_root: Option<usize>, +} + +#[derive(Clone, Debug)] +pub struct GlbSampler { + /// Keyframe times, seconds, ascending. + pub input: Vec<f32>, + /// Flat output values (3 per T/S key, 4 per R key). + pub output: Vec<f32>, + pub interp: Interp, +} + +#[derive(Clone, Copy, Debug)] +pub struct GlbChannel { + pub sampler: usize, + pub target_node: usize, + pub path: ChannelPath, +} + +#[derive(Clone, Debug, Default)] +pub struct GlbAnimation { + pub name: Option<String>, + pub channels: Vec<GlbChannel>, + pub samplers: Vec<GlbSampler>, + pub duration: f32, +} + +#[derive(Clone, Debug, Default)] +pub struct GlbDocument { + pub nodes: Vec<GlbNode>, + pub meshes: Vec<GlbMesh>, + pub materials: Vec<GlbMaterial>, + pub skins: Vec<GlbSkin>, + pub animations: Vec<GlbAnimation>, + pub images: Vec<GlbImage>, + pub textures: Vec<GlbTexture>, + pub texture_samplers: Vec<GlbTextureSampler>, + pub lights: Vec<GlbPunctualLight>, + /// Root node indices of the default scene (falls back to scene 0). + pub scene_roots: Vec<usize>, +} + +impl GlbDocument { + pub fn animation_by_name(&self, name: &str) -> Option<&GlbAnimation> { + self.animations + .iter() + .find(|a| a.name.as_deref() == Some(name)) + } + + /// World matrices in deterministic scene traversal order. Nodes not + /// reachable from the selected scene are treated as additional roots. + pub fn node_globals(&self) -> Vec<Mat4> { + fn visit( + doc: &GlbDocument, + index: usize, + parent: Mat4, + done: &mut [bool], + out: &mut [Mat4], + ) { + if index >= doc.nodes.len() || done[index] { + return; + } + done[index] = true; + let global = parent.mul(doc.nodes[index].local_matrix()); + out[index] = global; + for &child in &doc.nodes[index].children { + visit(doc, child, global, done, out); + } + } + + let mut out = alloc::vec![Mat4::IDENTITY; self.nodes.len()]; + let mut done = alloc::vec![false; self.nodes.len()]; + let mut has_parent = alloc::vec![false; self.nodes.len()]; + for node in &self.nodes { + for &child in &node.children { + if let Some(has_parent) = has_parent.get_mut(child) { + *has_parent = true; + } + } + } + for &root in &self.scene_roots { + visit(self, root, Mat4::IDENTITY, &mut done, &mut out); + } + for index in 0..self.nodes.len() { + if !done[index] && !has_parent[index] { + visit(self, index, Mat4::IDENTITY, &mut done, &mut out); + } + } + for index in 0..self.nodes.len() { + visit(self, index, Mat4::IDENTITY, &mut done, &mut out); + } + out + } +} + +// --------------------------------------------------------------------------- +// Little-endian primitives +// --------------------------------------------------------------------------- + +fn rd_u32(b: &[u8], o: usize) -> Result<u32, GlbError> { + if o + 4 > b.len() { + return Err(GlbError::OutOfRange); + } + Ok(b[o] as u32 | (b[o + 1] as u32) << 8 | (b[o + 2] as u32) << 16 | (b[o + 3] as u32) << 24) +} + +fn rd_u16(b: &[u8], o: usize) -> Result<u16, GlbError> { + if o + 2 > b.len() { + return Err(GlbError::OutOfRange); + } + Ok(b[o] as u16 | (b[o + 1] as u16) << 8) +} + +fn rd_f32(b: &[u8], o: usize) -> Result<f32, GlbError> { + Ok(f32::from_bits(rd_u32(b, o)?)) +} + +// --------------------------------------------------------------------------- +// Accessor plumbing +// --------------------------------------------------------------------------- + +const CT_I8: u32 = 5120; +const CT_U8: u32 = 5121; +const CT_I16: u32 = 5122; +const CT_U16: u32 = 5123; +const CT_U32: u32 = 5125; +const CT_F32: u32 = 5126; + +fn comp_size(ct: u32) -> Result<usize, GlbError> { + match ct { + CT_I8 | CT_U8 => Ok(1), + CT_I16 | CT_U16 => Ok(2), + CT_U32 | CT_F32 => Ok(4), + _ => Err(GlbError::BadAccessor), + } +} + +fn type_comps(t: &str) -> Result<usize, GlbError> { + match t { + "SCALAR" => Ok(1), + "VEC2" => Ok(2), + "VEC3" => Ok(3), + "VEC4" => Ok(4), + "MAT4" => Ok(16), + _ => Err(GlbError::BadAccessor), + } +} + +/// A resolved accessor: where each element sits in `bin`, and how big it is. +struct AccessorView { + offset: usize, + stride: usize, + count: usize, + comp_type: u32, + num_comps: usize, + normalized: bool, +} + +fn u(v: &Json, key: &str) -> Option<usize> { + v.get(key).and_then(Json::as_i64).map(|n| n as usize) +} + +fn resolve_accessor(gltf: &Json, idx: usize, bin_len: usize) -> Result<AccessorView, GlbError> { + let accessors = gltf + .get("accessors") + .and_then(Json::as_array) + .ok_or(GlbError::BadAccessor)?; + let acc = accessors.get(idx).ok_or(GlbError::OutOfRange)?; + if acc.get("sparse").is_some() { + return Err(GlbError::Unsupported("sparse accessor")); + } + let comp_type = u(acc, "componentType").ok_or(GlbError::BadAccessor)? as u32; + let count = u(acc, "count").ok_or(GlbError::BadAccessor)?; + let num_comps = type_comps( + acc.get("type") + .and_then(Json::as_str) + .ok_or(GlbError::BadAccessor)?, + )?; + let normalized = acc + .get("normalized") + .and_then(Json::as_bool) + .unwrap_or(false); + let acc_off = u(acc, "byteOffset").unwrap_or(0); + + let bv_idx = + u(acc, "bufferView").ok_or(GlbError::Unsupported("accessor without bufferView"))?; + let views = gltf + .get("bufferViews") + .and_then(Json::as_array) + .ok_or(GlbError::BadAccessor)?; + let bv = views.get(bv_idx).ok_or(GlbError::OutOfRange)?; + // Single-buffer contract: every bufferView must target buffer 0 (the BIN chunk). + if u(bv, "buffer").unwrap_or(0) != 0 { + return Err(GlbError::Unsupported("multi-buffer glb")); + } + let bv_off = u(bv, "byteOffset").unwrap_or(0); + let elem = num_comps * comp_size(comp_type)?; + let stride = u(bv, "byteStride").unwrap_or(elem); + let offset = bv_off + acc_off; + if count > 0 && offset + (count - 1) * stride + elem > bin_len { + return Err(GlbError::OutOfRange); + } + Ok(AccessorView { + offset, + stride, + count, + comp_type, + num_comps, + normalized, + }) +} + +/// Read one scalar component as f32 (only valid for float accessors). +fn read_floats(bin: &[u8], av: &AccessorView) -> Result<Vec<f32>, GlbError> { + if av.comp_type != CT_F32 { + return Err(GlbError::BadAccessor); + } + let mut out = Vec::with_capacity(av.count * av.num_comps); + for i in 0..av.count { + let base = av.offset + i * av.stride; + for c in 0..av.num_comps { + out.push(rd_f32(bin, base + c * 4)?); + } + } + Ok(out) +} + +fn read_weights(bin: &[u8], av: &AccessorView) -> Result<Vec<f32>, GlbError> { + if av.num_comps != 4 { + return Err(GlbError::BadAccessor); + } + let mut out = Vec::with_capacity(av.count * av.num_comps); + for i in 0..av.count { + let base = av.offset + i * av.stride; + for c in 0..av.num_comps { + let value = match av.comp_type { + CT_F32 if !av.normalized => rd_f32(bin, base + c * 4)?, + CT_U8 if av.normalized => { + f32::from(*bin.get(base + c).ok_or(GlbError::OutOfRange)?) / 255.0 + } + CT_U16 if av.normalized => f32::from(rd_u16(bin, base + c * 2)?) / 65_535.0, + _ => return Err(GlbError::BadAccessor), + }; + out.push(value); + } + } + Ok(out) +} + +fn chunk3(flat: &[f32]) -> Vec<[f32; 3]> { + flat.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect() +} + +fn chunk2(flat: &[f32]) -> Vec<[f32; 2]> { + flat.chunks_exact(2).map(|c| [c[0], c[1]]).collect() +} + +fn chunk4(flat: &[f32]) -> Vec<[f32; 4]> { + flat.chunks_exact(4) + .map(|c| [c[0], c[1], c[2], c[3]]) + .collect() +} + +fn read_colors(bin: &[u8], av: &AccessorView) -> Result<Vec<[u8; 4]>, GlbError> { + if !matches!(av.num_comps, 3 | 4) { + return Err(GlbError::BadAccessor); + } + let integer_color = av.normalized && matches!(av.comp_type, CT_U8 | CT_U16); + let float_color = !av.normalized && av.comp_type == CT_F32; + if !integer_color && !float_color { + return Err(GlbError::BadAccessor); + } + let component_size = comp_size(av.comp_type)?; + let mut out = Vec::with_capacity(av.count); + for i in 0..av.count { + let base = av.offset + i * av.stride; + let mut color = [255u8; 4]; + for (component, slot) in color.iter_mut().enumerate().take(av.num_comps) { + *slot = match av.comp_type { + CT_U8 => *bin.get(base + component).ok_or(GlbError::OutOfRange)?, + CT_U16 => { + let value = rd_u16(bin, base + component * component_size)?; + ((u32::from(value) * 255 + 32_767) / 65_535) as u8 + } + CT_F32 => { + let value = rd_f32(bin, base + component * component_size)?; + libm::roundf(value.clamp(0.0, 1.0) * 255.0) as u8 + } + _ => return Err(GlbError::BadAccessor), + }; + } + out.push(color); + } + Ok(out) +} + +fn read_indices(bin: &[u8], av: &AccessorView) -> Result<Vec<u32>, GlbError> { + let mut out = Vec::with_capacity(av.count); + for i in 0..av.count { + let o = av.offset + i * av.stride; + let v = match av.comp_type { + CT_U8 => bin.get(o).copied().ok_or(GlbError::OutOfRange)? as u32, + CT_U16 => rd_u16(bin, o)? as u32, + CT_U32 => rd_u32(bin, o)?, + _ => return Err(GlbError::BadAccessor), + }; + out.push(v); + } + Ok(out) +} + +fn read_joints(bin: &[u8], av: &AccessorView) -> Result<Vec<[u16; 4]>, GlbError> { + if av.num_comps != 4 { + return Err(GlbError::BadAccessor); + } + let mut out = Vec::with_capacity(av.count); + for i in 0..av.count { + let base = av.offset + i * av.stride; + let mut j = [0u16; 4]; + for (c, slot) in j.iter_mut().enumerate() { + *slot = match av.comp_type { + CT_U8 => *bin.get(base + c).ok_or(GlbError::OutOfRange)? as u16, + CT_U16 => rd_u16(bin, base + c * 2)?, + _ => return Err(GlbError::BadAccessor), + }; + } + out.push(j); + } + Ok(out) +} + +fn read_mat4s(bin: &[u8], av: &AccessorView) -> Result<Vec<Mat4>, GlbError> { + if av.num_comps != 16 || av.comp_type != CT_F32 { + return Err(GlbError::BadAccessor); + } + let mut out = Vec::with_capacity(av.count); + for i in 0..av.count { + let base = av.offset + i * av.stride; + let mut m = [0.0f32; 16]; + for (c, slot) in m.iter_mut().enumerate() { + *slot = rd_f32(bin, base + c * 4)?; + } + out.push(Mat4 { m }); + } + Ok(out) +} + +// --------------------------------------------------------------------------- +// Top-level parse +// --------------------------------------------------------------------------- + +const GLB_MAGIC: u32 = 0x4654_6C67; +const CHUNK_JSON: u32 = 0x4E4F_534A; +const CHUNK_BIN: u32 = 0x004E_4942; + +/// Parse a `.glb` byte blob into a [`GlbDocument`]. +pub fn parse(bytes: &[u8]) -> Result<GlbDocument, GlbError> { + if bytes.len() < 12 || rd_u32(bytes, 0)? != GLB_MAGIC { + return Err(GlbError::BadMagic); + } + if rd_u32(bytes, 4)? != 2 { + return Err(GlbError::BadVersion); + } + let mut pos = 12usize; + let mut json_bytes: Option<&[u8]> = None; + let mut bin_bytes: Option<&[u8]> = None; + while pos + 8 <= bytes.len() { + let clen = rd_u32(bytes, pos)? as usize; + let ctype = rd_u32(bytes, pos + 4)?; + let start = pos + 8; + let end = start.checked_add(clen).ok_or(GlbError::BadChunk)?; + if end > bytes.len() { + return Err(GlbError::BadChunk); + } + match ctype { + CHUNK_JSON => json_bytes = Some(&bytes[start..end]), + CHUNK_BIN => bin_bytes = Some(&bytes[start..end]), + _ => {} + } + pos = end + ((4 - (clen & 3)) & 3); + } + let json_slice = json_bytes.ok_or(GlbError::BadChunk)?; + let json_str = core::str::from_utf8(json_slice).map_err(|_| GlbError::BadChunk)?; + let gltf = Json::parse(json_str.trim_end_matches(' '))?; + let mut punctual_lights_required = false; + if let Some(required) = gltf.get("extensionsRequired") { + let names = required + .as_array() + .ok_or(GlbError::Unsupported("extensionsRequired"))?; + for name in names { + match name.as_str() { + Some("KHR_lights_punctual") => punctual_lights_required = true, + _ => return Err(GlbError::Unsupported("extensionsRequired")), + } + } + } + let bin = bin_bytes.unwrap_or(&[]); + let lights = parse_lights(&gltf)?; + if punctual_lights_required + && (lights.is_empty() || lights.iter().any(|light| light.kind != GlbLightKind::Point)) + { + return Err(GlbError::Unsupported( + "required KHR_lights_punctual light type", + )); + } + Ok(GlbDocument { + nodes: parse_nodes(&gltf, lights.len())?, + meshes: parse_meshes(&gltf, bin)?, + materials: parse_materials(&gltf)?, + skins: parse_skins(&gltf, bin)?, + animations: parse_animations(&gltf, bin)?, + images: parse_images(&gltf, bin)?, + textures: parse_textures(&gltf)?, + texture_samplers: parse_texture_samplers(&gltf)?, + lights, + scene_roots: parse_scene_roots(&gltf), + }) +} + +fn parse_lights(gltf: &Json) -> Result<Vec<GlbPunctualLight>, GlbError> { + let Some(extensions) = gltf.get("extensions") else { + return Ok(Vec::new()); + }; + let Some(khr) = extensions.get("KHR_lights_punctual") else { + return Ok(Vec::new()); + }; + let values = khr + .get("lights") + .and_then(Json::as_array) + .ok_or(GlbError::Unsupported("KHR_lights_punctual lights"))?; + let mut out = Vec::with_capacity(values.len()); + for light in values { + let kind = match light.get("type").and_then(Json::as_str) { + Some("point") => GlbLightKind::Point, + Some("spot") => GlbLightKind::Spot, + Some("directional") => GlbLightKind::Directional, + _ => return Err(GlbError::Unsupported("KHR_lights_punctual type")), + }; + let color = strict_color(light.get("color"))?; + let intensity = strict_nonnegative(light.get("intensity"), 1.0, "light intensity")?; + let range = light + .get("range") + .map(|v| strict_positive(v, "light range")) + .transpose()?; + if kind == GlbLightKind::Directional && range.is_some() { + return Err(GlbError::Unsupported("directional light range")); + } + if kind == GlbLightKind::Spot { + if let Some(spot) = light.get("spot") { + if spot.as_object().is_none() { + return Err(GlbError::Unsupported("spot")); + } + let inner = strict_angle(spot.get("innerConeAngle"), 0.0)?; + let outer = strict_angle(spot.get("outerConeAngle"), core::f32::consts::FRAC_PI_4)?; + if outer <= 0.0 || inner > outer { + return Err(GlbError::Unsupported("spot cone angles")); + } + } + } + out.push(GlbPunctualLight { + name: light.get("name").and_then(Json::as_str).map(String::from), + kind, + color, + intensity, + range, + }); + } + Ok(out) +} + +fn strict_f32(value: Option<&Json>, default: f32, what: &'static str) -> Result<f32, GlbError> { + match value { + None => Ok(default), + Some(value) => { + let number = value.as_f64().ok_or(GlbError::Unsupported(what))?; + let value = number as f32; + if !number.is_finite() || !value.is_finite() { + return Err(GlbError::Unsupported(what)); + } + Ok(value) + } + } +} +fn strict_nonnegative( + value: Option<&Json>, + default: f32, + what: &'static str, +) -> Result<f32, GlbError> { + let value = strict_f32(value, default, what)?; + if value < 0.0 { + Err(GlbError::Unsupported(what)) + } else { + Ok(value) + } +} +fn strict_positive(value: &Json, what: &'static str) -> Result<f32, GlbError> { + let value = strict_f32(Some(value), 0.0, what)?; + if value <= 0.0 { + Err(GlbError::Unsupported(what)) + } else { + Ok(value) + } +} +fn strict_color(value: Option<&Json>) -> Result<[f32; 3], GlbError> { + let Some(values) = value else { + return Ok([1.0; 3]); + }; + let values = values + .as_array() + .ok_or(GlbError::Unsupported("light color"))?; + if values.len() != 3 { + return Err(GlbError::Unsupported("light color")); + } + let mut color = [0.0; 3]; + for (slot, value) in color.iter_mut().zip(values) { + *slot = strict_nonnegative(Some(value), 0.0, "light color")?; + if *slot > 1.0 { + return Err(GlbError::Unsupported("light color")); + } + } + Ok(color) +} +fn strict_angle(value: Option<&Json>, default: f32) -> Result<f32, GlbError> { + let value = strict_f32(value, default, "spot cone angle")?; + if !(0.0..=core::f32::consts::FRAC_PI_2).contains(&value) { + return Err(GlbError::Unsupported("spot cone angle")); + } + Ok(value) +} + +fn parse_nodes(gltf: &Json, light_count: usize) -> Result<Vec<GlbNode>, GlbError> { + let mut out = Vec::new(); + let Some(nodes) = gltf.get("nodes").and_then(Json::as_array) else { + return Ok(out); + }; + for n in nodes { + let (t, r, s) = if let Some(m) = n.get("matrix").and_then(Json::as_array) { + decompose_matrix(m)? + } else { + let t = read_vec3(n.get("translation"), Vec3::ZERO); + let r = read_quat(n.get("rotation")); + let s = read_vec3(n.get("scale"), Vec3::ONE); + (t, r, s) + }; + let children = n + .get("children") + .and_then(Json::as_array) + .map(|a| { + a.iter() + .filter_map(|c| c.as_i64().map(|x| x as usize)) + .collect() + }) + .unwrap_or_default(); + let light = if let Some(extensions) = n.get("extensions") { + if let Some(khr) = extensions.get("KHR_lights_punctual") { + let value = khr + .get("light") + .ok_or(GlbError::Unsupported("node light"))?; + let index = value.as_i64().ok_or(GlbError::Unsupported("node light"))?; + if index < 0 || index as usize >= light_count { + return Err(GlbError::Unsupported("node light")); + } + Some(index as usize) + } else { + None + } + } else { + None + }; + out.push(GlbNode { + name: n.get("name").and_then(Json::as_str).map(String::from), + translation: t, + rotation: r, + scale: s, + children, + mesh: u(n, "mesh"), + skin: u(n, "skin"), + weights: read_f32_array(n.get("weights")), + light, + }); + } + Ok(out) +} + +fn read_f32_array(v: Option<&Json>) -> Vec<f32> { + v.and_then(Json::as_array) + .map(|values| values.iter().filter_map(Json::as_f32).collect()) + .unwrap_or_default() +} + +fn read_vec3(v: Option<&Json>, fallback: Vec3) -> Vec3 { + match v.and_then(Json::as_array) { + Some(a) if a.len() >= 3 => vec3( + a[0].as_f32().unwrap_or(fallback.x), + a[1].as_f32().unwrap_or(fallback.y), + a[2].as_f32().unwrap_or(fallback.z), + ), + _ => fallback, + } +} + +fn read_quat(v: Option<&Json>) -> Quat { + match v.and_then(Json::as_array) { + Some(a) if a.len() >= 4 => Quat { + x: a[0].as_f32().unwrap_or(0.0), + y: a[1].as_f32().unwrap_or(0.0), + z: a[2].as_f32().unwrap_or(0.0), + w: a[3].as_f32().unwrap_or(1.0), + }, + _ => Quat::IDENTITY, + } +} + +/// Decompose a column-major TRS matrix into translation/rotation/scale. +fn decompose_matrix(m: &[Json]) -> Result<(Vec3, Quat, Vec3), GlbError> { + if m.len() < 16 { + return Err(GlbError::Unsupported("short node.matrix")); + } + let mut a = [0.0f32; 16]; + for (i, slot) in a.iter_mut().enumerate() { + *slot = m[i] + .as_f32() + .ok_or(GlbError::Unsupported("bad node.matrix"))?; + } + let t = vec3(a[12], a[13], a[14]); + // Column basis vectors. + let c0 = vec3(a[0], a[1], a[2]); + let c1 = vec3(a[4], a[5], a[6]); + let c2 = vec3(a[8], a[9], a[10]); + let s = vec3(c0.length(), c1.length(), c2.length()); + let r0 = if s.x > 1e-8 { c0.scale(1.0 / s.x) } else { c0 }; + let r1 = if s.y > 1e-8 { c1.scale(1.0 / s.y) } else { c1 }; + let r2 = if s.z > 1e-8 { c2.scale(1.0 / s.z) } else { c2 }; + let q = quat_from_basis(r0, r1, r2); + Ok((t, q, s)) +} + +/// Rotation quaternion from three orthonormal basis columns. +fn quat_from_basis(x: Vec3, y: Vec3, z: Vec3) -> Quat { + let trace = x.x + y.y + z.z; + let q = if trace > 0.0 { + let s = libm::sqrtf(trace + 1.0) * 2.0; + Quat { + w: 0.25 * s, + x: (y.z - z.y) / s, + y: (z.x - x.z) / s, + z: (x.y - y.x) / s, + } + } else if x.x > y.y && x.x > z.z { + let s = libm::sqrtf(1.0 + x.x - y.y - z.z) * 2.0; + Quat { + w: (y.z - z.y) / s, + x: 0.25 * s, + y: (y.x + x.y) / s, + z: (z.x + x.z) / s, + } + } else if y.y > z.z { + let s = libm::sqrtf(1.0 + y.y - x.x - z.z) * 2.0; + Quat { + w: (z.x - x.z) / s, + x: (y.x + x.y) / s, + y: 0.25 * s, + z: (z.y + y.z) / s, + } + } else { + let s = libm::sqrtf(1.0 + z.z - x.x - y.y) * 2.0; + Quat { + w: (x.y - y.x) / s, + x: (z.x + x.z) / s, + y: (z.y + y.z) / s, + z: 0.25 * s, + } + }; + q.normalize() +} + +fn parse_meshes(gltf: &Json, bin: &[u8]) -> Result<Vec<GlbMesh>, GlbError> { + let mut out = Vec::new(); + let Some(meshes) = gltf.get("meshes").and_then(Json::as_array) else { + return Ok(out); + }; + for m in meshes { + let mesh_weights = read_f32_array(m.get("weights")); + let mut mesh = GlbMesh { + name: m.get("name").and_then(Json::as_str).map(String::from), + primitives: Vec::new(), + weights: mesh_weights.clone(), + }; + let prims = m.get("primitives").and_then(Json::as_array).unwrap_or(&[]); + for p in prims { + if let Some(mode) = u(p, "mode") { + if mode != 4 { + return Err(GlbError::Unsupported("non-triangle primitive")); + } + } + let attrs = p.get("attributes").ok_or(GlbError::BadAccessor)?; + let mut prim = GlbPrimitive { + material: u(p, "material"), + ..Default::default() + }; + if let Some(i) = u(attrs, "POSITION") { + prim.positions = chunk3(&read_floats(bin, &resolve_accessor(gltf, i, bin.len())?)?); + } + if let Some(i) = u(attrs, "NORMAL") { + prim.normals = chunk3(&read_floats(bin, &resolve_accessor(gltf, i, bin.len())?)?); + } + if let Some(i) = u(attrs, "TEXCOORD_0") { + prim.uvs = chunk2(&read_floats(bin, &resolve_accessor(gltf, i, bin.len())?)?); + } + if let Some(i) = u(attrs, "TEXCOORD_1") { + prim.uvs1 = chunk2(&read_floats(bin, &resolve_accessor(gltf, i, bin.len())?)?); + } + if let Some(i) = u(attrs, "TANGENT") { + prim.tangents = chunk4(&read_floats(bin, &resolve_accessor(gltf, i, bin.len())?)?); + } + if let Some(i) = u(attrs, "COLOR_0") { + prim.colors = read_colors(bin, &resolve_accessor(gltf, i, bin.len())?)?; + } + if let Some(i) = u(attrs, "COLOR_1") { + prim.color1 = read_colors(bin, &resolve_accessor(gltf, i, bin.len())?)?; + } + if let Some(i) = u(attrs, "JOINTS_0") { + prim.joints = read_joints(bin, &resolve_accessor(gltf, i, bin.len())?)?; + } + if let Some(i) = u(attrs, "WEIGHTS_0") { + prim.weights = chunk4(&read_weights(bin, &resolve_accessor(gltf, i, bin.len())?)?); + } + if let Some(i) = u(p, "indices") { + prim.indices = read_indices(bin, &resolve_accessor(gltf, i, bin.len())?)?; + } else { + // Non-indexed: synthesize a trivial index list. + prim.indices = (0..prim.positions.len() as u32).collect(); + } + for target in p.get("targets").and_then(Json::as_array).unwrap_or(&[]) { + let mut morph = GlbMorphTarget::default(); + if let Some(i) = u(target, "POSITION") { + morph.positions = + chunk3(&read_floats(bin, &resolve_accessor(gltf, i, bin.len())?)?); + } + if let Some(i) = u(target, "NORMAL") { + morph.normals = + chunk3(&read_floats(bin, &resolve_accessor(gltf, i, bin.len())?)?); + } + if let Some(i) = u(target, "TANGENT") { + morph.tangents = + chunk3(&read_floats(bin, &resolve_accessor(gltf, i, bin.len())?)?); + } + prim.morph_targets.push(morph); + } + prim.morph_weights = mesh_weights.clone(); + mesh.primitives.push(prim); + } + out.push(mesh); + } + Ok(out) +} + +fn texture_ref(v: Option<&Json>, scale_key: &str) -> Result<Option<TextureRef>, GlbError> { + let Some(info) = v else { + return Ok(None); + }; + let texture = u(info, "index").ok_or(GlbError::OutOfRange)?; + let tex_coord = u(info, "texCoord").unwrap_or(0); + if tex_coord > 1 { + return Err(GlbError::Unsupported("texture texCoord > 1")); + } + Ok(Some(TextureRef { + texture, + tex_coord: tex_coord as u8, + scale: info.get(scale_key).and_then(Json::as_f32).unwrap_or(1.0), + })) +} + +fn extension<'a>(material: &'a Json, name: &str) -> Option<&'a Json> { + material + .get("extensions") + .and_then(|extensions| extensions.get(name)) +} + +fn parse_materials(gltf: &Json) -> Result<Vec<GlbMaterial>, GlbError> { + let mut out = Vec::new(); + let Some(mats) = gltf.get("materials").and_then(Json::as_array) else { + return Ok(out); + }; + for m in mats { + let mut mat = GlbMaterial { + name: m.get("name").and_then(Json::as_str).map(String::from), + double_sided: m + .get("doubleSided") + .and_then(Json::as_bool) + .unwrap_or(false), + ..Default::default() + }; + if let Some(pbr) = m.get("pbrMetallicRoughness") { + if let Some(bc) = pbr.get("baseColorFactor").and_then(Json::as_array) { + for (i, slot) in mat.base_color.iter_mut().enumerate() { + if let Some(v) = bc.get(i).and_then(Json::as_f32) { + *slot = v; + } + } + } + mat.metallic = pbr + .get("metallicFactor") + .and_then(Json::as_f32) + .unwrap_or(1.0); + mat.roughness = pbr + .get("roughnessFactor") + .and_then(Json::as_f32) + .unwrap_or(1.0); + mat.base_color_texture = texture_ref(pbr.get("baseColorTexture"), "")?; + mat.metallic_roughness_texture = texture_ref(pbr.get("metallicRoughnessTexture"), "")?; + } + mat.normal_texture = texture_ref(m.get("normalTexture"), "scale")?; + mat.normal_scale = mat + .normal_texture + .map(|texture| texture.scale) + .unwrap_or(1.0); + mat.occlusion_texture = texture_ref(m.get("occlusionTexture"), "strength")?; + mat.occlusion_strength = mat + .occlusion_texture + .map(|texture| texture.scale) + .unwrap_or(1.0); + mat.emissive_texture = texture_ref(m.get("emissiveTexture"), "")?; + if let Some(values) = m.get("emissiveFactor").and_then(Json::as_array) { + for (index, slot) in mat.emissive_factor.iter_mut().enumerate() { + *slot = values.get(index).and_then(Json::as_f32).unwrap_or(0.0); + } + } + if let Some(value) = extension(m, "KHR_materials_emissive_strength") + .and_then(|value| value.get("emissiveStrength")) + .and_then(Json::as_f32) + { + mat.emissive_strength = value; + } + if let Some(value) = extension(m, "KHR_materials_clearcoat") { + mat.clearcoat = value + .get("clearcoatFactor") + .and_then(Json::as_f32) + .unwrap_or(0.0); + mat.clearcoat_roughness = value + .get("clearcoatRoughnessFactor") + .and_then(Json::as_f32) + .unwrap_or(0.0); + } + mat.ior = extension(m, "KHR_materials_ior") + .and_then(|value| value.get("ior")) + .and_then(Json::as_f32) + .unwrap_or(1.5); + mat.specular = extension(m, "KHR_materials_specular") + .and_then(|value| value.get("specularFactor")) + .and_then(Json::as_f32) + .unwrap_or(1.0); + mat.transmission = extension(m, "KHR_materials_transmission") + .and_then(|value| value.get("transmissionFactor")) + .and_then(Json::as_f32) + .unwrap_or(0.0); + mat.alpha_mode = match m.get("alphaMode").and_then(Json::as_str) { + Some("MASK") => AlphaMode::Mask, + Some("BLEND") => AlphaMode::Blend, + Some("OPAQUE") | None => AlphaMode::Opaque, + _ => return Err(GlbError::Unsupported("alpha mode")), + }; + mat.alpha_cutoff = m.get("alphaCutoff").and_then(Json::as_f32).unwrap_or(0.5); + out.push(mat); + } + Ok(out) +} + +fn buffer_view_bytes(gltf: &Json, bin: &[u8], index: usize) -> Result<Vec<u8>, GlbError> { + let views = gltf + .get("bufferViews") + .and_then(Json::as_array) + .ok_or(GlbError::OutOfRange)?; + let view = views.get(index).ok_or(GlbError::OutOfRange)?; + if u(view, "buffer").unwrap_or(0) != 0 { + return Err(GlbError::Unsupported("multi-buffer image")); + } + let offset = u(view, "byteOffset").unwrap_or(0); + let length = u(view, "byteLength").ok_or(GlbError::OutOfRange)?; + let end = offset.checked_add(length).ok_or(GlbError::OutOfRange)?; + Ok(bin.get(offset..end).ok_or(GlbError::OutOfRange)?.to_vec()) +} + +fn parse_images(gltf: &Json, bin: &[u8]) -> Result<Vec<GlbImage>, GlbError> { + let mut out = Vec::new(); + for image in gltf.get("images").and_then(Json::as_array).unwrap_or(&[]) { + if image.get("uri").is_some() { + return Err(GlbError::Unsupported("external image")); + } + let mime_type = image + .get("mimeType") + .and_then(Json::as_str) + .ok_or(GlbError::Unsupported("image without mime type"))?; + if !matches!(mime_type, "image/png" | "image/jpeg") { + return Err(GlbError::Unsupported("image mime type")); + } + out.push(GlbImage { + name: image.get("name").and_then(Json::as_str).map(String::from), + mime_type: String::from(mime_type), + bytes: buffer_view_bytes( + gltf, + bin, + u(image, "bufferView").ok_or(GlbError::OutOfRange)?, + )?, + }); + } + Ok(out) +} + +fn parse_textures(gltf: &Json) -> Result<Vec<GlbTexture>, GlbError> { + gltf.get("textures") + .and_then(Json::as_array) + .unwrap_or(&[]) + .iter() + .map(|texture| { + Ok(GlbTexture { + source: u(texture, "source").ok_or(GlbError::OutOfRange)?, + sampler: u(texture, "sampler"), + }) + }) + .collect() +} + +fn parse_texture_samplers(gltf: &Json) -> Result<Vec<GlbTextureSampler>, GlbError> { + gltf.get("samplers") + .and_then(Json::as_array) + .unwrap_or(&[]) + .iter() + .map(|sampler| { + let mag_filter = match u(sampler, "magFilter").unwrap_or(9729) { + 9728 => MagFilter::Nearest, + 9729 => MagFilter::Linear, + _ => return Err(GlbError::Unsupported("sampler mag filter")), + }; + let min_filter = match u(sampler, "minFilter").unwrap_or(9987) { + 9984 => MinFilter::NearestMipmapNearest, + 9987 => MinFilter::LinearMipmapLinear, + _ => return Err(GlbError::Unsupported("sampler min filter")), + }; + let wrap = |value| match value { + 33071 => Ok(WrapMode::ClampToEdge), + 10497 => Ok(WrapMode::Repeat), + _ => Err(GlbError::Unsupported("sampler wrap")), + }; + Ok(GlbTextureSampler { + mag_filter, + min_filter, + wrap_s: wrap(u(sampler, "wrapS").unwrap_or(10497))?, + wrap_t: wrap(u(sampler, "wrapT").unwrap_or(10497))?, + }) + }) + .collect() +} + +fn parse_skins(gltf: &Json, bin: &[u8]) -> Result<Vec<GlbSkin>, GlbError> { + let mut out = Vec::new(); + let Some(skins) = gltf.get("skins").and_then(Json::as_array) else { + return Ok(out); + }; + for s in skins { + let joints = s + .get("joints") + .and_then(Json::as_array) + .map(|a| { + a.iter() + .filter_map(|j| j.as_i64().map(|x| x as usize)) + .collect::<Vec<_>>() + }) + .unwrap_or_default(); + let inverse_bind = if let Some(i) = u(s, "inverseBindMatrices") { + read_mat4s(bin, &resolve_accessor(gltf, i, bin.len())?)? + } else { + joints.iter().map(|_| Mat4::IDENTITY).collect() + }; + out.push(GlbSkin { + joints, + inverse_bind, + skeleton_root: u(s, "skeleton"), + }); + } + Ok(out) +} + +fn parse_animations(gltf: &Json, bin: &[u8]) -> Result<Vec<GlbAnimation>, GlbError> { + let mut out = Vec::new(); + let Some(anims) = gltf.get("animations").and_then(Json::as_array) else { + return Ok(out); + }; + for a in anims { + let mut samplers = Vec::new(); + let mut duration = 0.0f32; + for s in a.get("samplers").and_then(Json::as_array).unwrap_or(&[]) { + let input_idx = u(s, "input").ok_or(GlbError::BadAccessor)?; + let output_idx = u(s, "output").ok_or(GlbError::BadAccessor)?; + let input = read_floats(bin, &resolve_accessor(gltf, input_idx, bin.len())?)?; + let output = read_floats(bin, &resolve_accessor(gltf, output_idx, bin.len())?)?; + let interp = match s.get("interpolation").and_then(Json::as_str) { + Some("STEP") => Interp::Step, + Some("CUBICSPLINE") => return Err(GlbError::Unsupported("cubicspline")), + _ => Interp::Linear, + }; + if let Some(&last) = input.last() { + if last > duration { + duration = last; + } + } + samplers.push(GlbSampler { + input, + output, + interp, + }); + } + let mut channels = Vec::new(); + for c in a.get("channels").and_then(Json::as_array).unwrap_or(&[]) { + let sampler = u(c, "sampler").ok_or(GlbError::BadAccessor)?; + let target = c.get("target").ok_or(GlbError::BadAccessor)?; + let Some(node) = u(target, "node") else { + continue; // untargeted channel (e.g. weights on absent node): skip. + }; + let path = match target.get("path").and_then(Json::as_str) { + Some("translation") => ChannelPath::Translation, + Some("rotation") => ChannelPath::Rotation, + Some("scale") => ChannelPath::Scale, + Some("weights") => return Err(GlbError::Unsupported("animated morph weights")), + _ => return Err(GlbError::BadAccessor), + }; + channels.push(GlbChannel { + sampler, + target_node: node, + path, + }); + } + out.push(GlbAnimation { + name: a.get("name").and_then(Json::as_str).map(String::from), + channels, + samplers, + duration, + }); + } + Ok(out) +} +pub fn generate_mikktspace_corner_tangents( + positions: &[[f32; 3]], + normals: &[[f32; 3]], + uvs: &[[f32; 2]], + indices: &[u32], +) -> Result<Vec<[f32; 4]>, GlbError> { + struct LibmOps; + impl bevy_mikktspace::Ops for LibmOps { + fn sqrt(value: f32) -> f32 { + libm::sqrtf(value) + } + fn acos(value: f32) -> f32 { + libm::acosf(value) + } + } + struct GeometryAdapter<'a> { + positions: &'a [[f32; 3]], + normals: &'a [[f32; 3]], + uvs: &'a [[f32; 2]], + indices: &'a [u32], + corners: Vec<Option<bevy_mikktspace::TangentSpace>>, + } + impl bevy_mikktspace::Geometry<LibmOps> for GeometryAdapter<'_> { + fn num_faces(&self) -> usize { + self.indices.len() / 3 + } + fn num_vertices_of_face(&self, _face: usize) -> usize { + 3 + } + fn position(&self, face: usize, vert: usize) -> [f32; 3] { + self.positions[self.indices[face * 3 + vert] as usize] + } + fn normal(&self, face: usize, vert: usize) -> [f32; 3] { + self.normals[self.indices[face * 3 + vert] as usize] + } + fn tex_coord(&self, face: usize, vert: usize) -> [f32; 2] { + self.uvs[self.indices[face * 3 + vert] as usize] + } + fn set_tangent( + &mut self, + tangent: Option<bevy_mikktspace::TangentSpace>, + face: usize, + vert: usize, + ) { + self.corners[face * 3 + vert] = tangent; + } + } + if positions.len() != normals.len() + || positions.len() != uvs.len() + || indices + .iter() + .any(|index| *index as usize >= positions.len()) + { + return Err(GlbError::BadAccessor); + } + let mut adapter = GeometryAdapter { + positions, + normals, + uvs, + indices, + corners: alloc::vec![None; indices.len()], + }; + bevy_mikktspace::generate_tangents::<_, LibmOps>(&mut adapter) + .map_err(|_| GlbError::Unsupported("tangent generation"))?; + Ok(adapter + .corners + .into_iter() + .map(|value| { + value + .map(|tangent| tangent.tangent_encoded()) + .unwrap_or([1.0, 0.0, 0.0, 1.0]) + }) + .collect()) +} + +pub fn generate_mikktspace_tangents( + positions: &[[f32; 3]], + normals: &[[f32; 3]], + uvs: &[[f32; 2]], + indices: &[u32], +) -> Result<Vec<[f32; 4]>, GlbError> { + let corners = generate_mikktspace_corner_tangents(positions, normals, uvs, indices)?; + let mut result = alloc::vec![[1.0, 0.0, 0.0, 1.0]; positions.len()]; + for (corner, index) in corners.into_iter().zip(indices) { + result[*index as usize] = corner; + } + Ok(result) +} + +fn parse_scene_roots(gltf: &Json) -> Vec<usize> { + let scene_idx = u(gltf, "scene").unwrap_or(0); + gltf.get("scenes") + .and_then(Json::as_array) + .and_then(|s| s.get(scene_idx)) + .and_then(|s| s.get("nodes")) + .and_then(Json::as_array) + .map(|a| { + a.iter() + .filter_map(|n| n.as_i64().map(|x| x as usize)) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(all(test, feature = "std"))] +mod tests; diff --git a/client-rust/source/engine-core/src/glb/tests.rs b/client-rust/source/engine-core/src/glb/tests.rs new file mode 100644 index 00000000..40c50131 --- /dev/null +++ b/client-rust/source/engine-core/src/glb/tests.rs @@ -0,0 +1,410 @@ +//! GLB parser tests. Fixtures are assembled in-memory (no binary checked in). + +use super::*; +use alloc::vec; +use alloc::vec::Vec; + +/// Assemble a valid GLB from a JSON string and a BIN blob, handling the +/// 4-byte chunk padding (JSON padded with spaces, BIN with zeros). +fn build_glb(json: &str, bin: &[u8]) -> Vec<u8> { + fn pad4(v: &mut Vec<u8>, fill: u8) { + while !v.len().is_multiple_of(4) { + v.push(fill); + } + } + let mut json_bytes = json.as_bytes().to_vec(); + pad4(&mut json_bytes, b' '); + let mut bin_bytes = bin.to_vec(); + pad4(&mut bin_bytes, 0); + + let total = 12 + 8 + json_bytes.len() + 8 + bin_bytes.len(); + let mut out = Vec::with_capacity(total); + out.extend_from_slice(&GLB_MAGIC.to_le_bytes()); + out.extend_from_slice(&2u32.to_le_bytes()); + out.extend_from_slice(&(total as u32).to_le_bytes()); + out.extend_from_slice(&(json_bytes.len() as u32).to_le_bytes()); + out.extend_from_slice(&CHUNK_JSON.to_le_bytes()); + out.extend_from_slice(&json_bytes); + out.extend_from_slice(&(bin_bytes.len() as u32).to_le_bytes()); + out.extend_from_slice(&CHUNK_BIN.to_le_bytes()); + out.extend_from_slice(&bin_bytes); + out +} + +fn f32s(vals: &[f32]) -> Vec<u8> { + let mut b = Vec::new(); + for v in vals { + b.extend_from_slice(&v.to_le_bytes()); + } + b +} + +#[test] +fn parses_static_triangle_with_material() { + let mut bin = f32s(&[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]); // 3 vec3 = 36 bytes + bin.extend_from_slice(&0u16.to_le_bytes()); + bin.extend_from_slice(&1u16.to_le_bytes()); + bin.extend_from_slice(&2u16.to_le_bytes()); // +6 = 42 bytes + let json = r#"{ + "asset":{"version":"2.0"}, + "scene":0, + "scenes":[{"nodes":[0]}], + "nodes":[{"mesh":0,"name":"tri","translation":[1,2,3]}], + "meshes":[{"primitives":[{"attributes":{"POSITION":0},"indices":1,"material":0}]}], + "materials":[{"name":"red","pbrMetallicRoughness":{"baseColorFactor":[1,0,0,1],"metallicFactor":0.25,"roughnessFactor":0.4},"doubleSided":true,"alphaMode":"MASK","alphaCutoff":0.25}], + "accessors":[ + {"bufferView":0,"componentType":5126,"count":3,"type":"VEC3"}, + {"bufferView":1,"componentType":5123,"count":3,"type":"SCALAR"} + ], + "bufferViews":[ + {"buffer":0,"byteOffset":0,"byteLength":36}, + {"buffer":0,"byteOffset":36,"byteLength":6} + ], + "buffers":[{"byteLength":42}] + }"#; + let doc = parse(&build_glb(json, &bin)).expect("parse"); + assert_eq!(doc.scene_roots, vec![0]); + assert_eq!(doc.nodes.len(), 1); + assert_eq!(doc.nodes[0].name.as_deref(), Some("tri")); + assert_eq!(doc.nodes[0].translation, vec3(1.0, 2.0, 3.0)); + assert_eq!(doc.nodes[0].mesh, Some(0)); + + let prim = &doc.meshes[0].primitives[0]; + assert_eq!( + prim.positions, + vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + ); + assert_eq!(prim.indices, vec![0, 1, 2]); + assert_eq!(prim.material, Some(0)); + + let mat = &doc.materials[0]; + assert_eq!(mat.base_color, [1.0, 0.0, 0.0, 1.0]); + assert!(mat.double_sided); + assert_eq!(mat.alpha_mode, AlphaMode::Mask); + assert_eq!(mat.alpha_cutoff, 0.25); + assert_eq!(mat.metallic, 0.25); + assert_eq!(mat.roughness, 0.4); +} + +#[test] +fn reads_float_vec3_vertex_colors() { + let mut bin = f32s(&[0.0, 0.0, 0.0]); + bin.extend_from_slice(&f32s(&[1.0, 0.5, 0.0])); + let json = r#"{ + "asset":{"version":"2.0"}, + "nodes":[{"mesh":0}], + "meshes":[{"primitives":[{"attributes":{"POSITION":0,"COLOR_0":1}}]}], + "accessors":[ + {"bufferView":0,"componentType":5126,"count":1,"type":"VEC3"}, + {"bufferView":1,"componentType":5126,"count":1,"type":"VEC3"} + ], + "bufferViews":[ + {"buffer":0,"byteOffset":0,"byteLength":12}, + {"buffer":0,"byteOffset":12,"byteLength":12} + ], + "buffers":[{"byteLength":24}] + }"#; + let doc = parse(&build_glb(json, &bin)).expect("parse"); + assert_eq!(doc.meshes[0].primitives[0].colors, vec![[255, 128, 0, 255]]); +} + +#[test] +fn reads_skin_joints_normalized_u16_weights_and_ibm() { + // Blender emits normalized u16 weights. This exact accessor shape is used + // by the promoted Successor humanoids. + let mut bin = f32s(&[0.0, 0.0, 0.0]); // POSITION, offset 0, 12 bytes + bin.extend_from_slice(&[0u8, 1, 0, 0]); // JOINTS_0 u8x4, offset 12, 4 bytes + for weight in [32_768u16, 32_767, 0, 0] { + bin.extend_from_slice(&weight.to_le_bytes()); + } // WEIGHTS_0 normalized u16x4, offset 16, 8 bytes + // IBM: two identity mat4s, offset 24, 128 bytes + for _ in 0..2 { + let id = Mat4::IDENTITY; + bin.extend_from_slice(&f32s(&id.m)); + } + let json = r#"{ + "asset":{"version":"2.0"}, + "nodes":[{"mesh":0,"skin":0},{"name":"j0"},{"name":"j1"}], + "meshes":[{"primitives":[{"attributes":{"POSITION":0,"JOINTS_0":1,"WEIGHTS_0":2}}]}], + "skins":[{"joints":[1,2],"inverseBindMatrices":3}], + "accessors":[ + {"bufferView":0,"componentType":5126,"count":1,"type":"VEC3"}, + {"bufferView":1,"componentType":5121,"count":1,"type":"VEC4"}, + {"bufferView":2,"componentType":5123,"normalized":true,"count":1,"type":"VEC4"}, + {"bufferView":3,"componentType":5126,"count":2,"type":"MAT4"} + ], + "bufferViews":[ + {"buffer":0,"byteOffset":0,"byteLength":12}, + {"buffer":0,"byteOffset":12,"byteLength":4}, + {"buffer":0,"byteOffset":16,"byteLength":8}, + {"buffer":0,"byteOffset":24,"byteLength":128} + ], + "buffers":[{"byteLength":152}] + }"#; + let doc = parse(&build_glb(json, &bin)).expect("parse"); + let prim = &doc.meshes[0].primitives[0]; + assert_eq!(prim.joints, vec![[0, 1, 0, 0]]); + assert_eq!( + prim.weights, + vec![[32_768.0 / 65_535.0, 32_767.0 / 65_535.0, 0.0, 0.0]] + ); + assert_eq!(doc.skins[0].joints, vec![1, 2]); + assert_eq!(doc.skins[0].inverse_bind.len(), 2); + assert_eq!(doc.skins[0].inverse_bind[0], Mat4::IDENTITY); +} + +#[test] +fn reads_animation_channels() { + // sampler input: 2 times [0,1]; output: 2 vec3 translations. + let mut bin = f32s(&[0.0, 1.0]); // input, offset 0, 8 bytes + bin.extend_from_slice(&f32s(&[0.0, 0.0, 0.0, 5.0, 0.0, 0.0])); // output, offset 8, 24 bytes + let json = r#"{ + "asset":{"version":"2.0"}, + "nodes":[{"name":"n0"}], + "animations":[{"name":"idle","samplers":[{"input":0,"output":1,"interpolation":"LINEAR"}],"channels":[{"sampler":0,"target":{"node":0,"path":"translation"}}]}], + "accessors":[ + {"bufferView":0,"componentType":5126,"count":2,"type":"SCALAR"}, + {"bufferView":1,"componentType":5126,"count":2,"type":"VEC3"} + ], + "bufferViews":[ + {"buffer":0,"byteOffset":0,"byteLength":8}, + {"buffer":0,"byteOffset":8,"byteLength":24} + ], + "buffers":[{"byteLength":32}] + }"#; + let doc = parse(&build_glb(json, &bin)).expect("parse"); + let anim = doc.animation_by_name("idle").expect("idle anim"); + assert_eq!(anim.duration, 1.0); + assert_eq!(anim.channels.len(), 1); + assert_eq!(anim.channels[0].path, ChannelPath::Translation); + assert_eq!(anim.samplers[0].input, vec![0.0, 1.0]); + assert_eq!(anim.samplers[0].output, vec![0.0, 0.0, 0.0, 5.0, 0.0, 0.0]); +} + +#[test] +fn rejects_bad_magic() { + let bytes = [0u8; 20]; + assert!(matches!(parse(&bytes), Err(GlbError::BadMagic))); +} + +#[test] +fn material_defaults_and_observed_extensions_are_exact() { + let json = Json::parse( + r#"{"materials":[ + {"pbrMetallicRoughness":{}}, + { + "doubleSided":true, + "alphaMode":"BLEND", + "pbrMetallicRoughness":{ + "baseColorFactor":[0.2,0.3,0.4,0.5], + "metallicFactor":0.6, + "roughnessFactor":0.7, + "baseColorTexture":{"index":2,"texCoord":0}, + "metallicRoughnessTexture":{"index":3} + }, + "normalTexture":{"index":4,"scale":0.25}, + "occlusionTexture":{"index":5,"strength":0.75}, + "emissiveTexture":{"index":6}, + "emissiveFactor":[0.1,0.2,0.3], + "extensions":{ + "KHR_materials_emissive_strength":{"emissiveStrength":18.0}, + "KHR_materials_clearcoat":{"clearcoatFactor":0.8,"clearcoatRoughnessFactor":0.15}, + "KHR_materials_ior":{"ior":1.3}, + "KHR_materials_specular":{"specularFactor":0.4}, + "KHR_materials_transmission":{"transmissionFactor":0.9} + } + } + ]}"#, + ) + .expect("fixture JSON"); + let materials = parse_materials(&json).expect("materials"); + assert_eq!(materials[0].base_color, [1.0; 4]); + assert_eq!(materials[0].metallic, 1.0); + assert_eq!(materials[0].roughness, 1.0); + let material = &materials[1]; + assert_eq!(material.base_color, [0.2, 0.3, 0.4, 0.5]); + assert_eq!(material.normal_scale, 0.25); + assert_eq!(material.occlusion_strength, 0.75); + assert_eq!(material.emissive_strength, 18.0); + assert_eq!(material.clearcoat, 0.8); + assert_eq!(material.clearcoat_roughness, 0.15); + assert_eq!(material.ior, 1.3); + assert_eq!(material.specular, 0.4); + assert_eq!(material.transmission, 0.9); + assert!(material.double_sided); + assert_eq!(material.alpha_mode, AlphaMode::Blend); +} + +#[test] +fn texture_coordinates_outside_supported_sets_fail_closed() { + let json = Json::parse( + r#"{"materials":[{"pbrMetallicRoughness":{"baseColorTexture":{"index":0,"texCoord":2}}}]}"#, + ) + .expect("fixture JSON"); + assert!(matches!( + parse_materials(&json), + Err(GlbError::Unsupported("texture texCoord > 1")) + )); +} + +#[test] +fn generated_tangents_preserve_one_result_per_triangle_corner() { + let positions = [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [1.0, 1.0, 0.0], + [0.0, 1.0, 0.0], + ]; + let normals = [[0.0, 0.0, 1.0]; 4]; + let uvs = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; + let indices = [0, 1, 2, 0, 2, 3]; + let tangents = generate_mikktspace_corner_tangents(&positions, &normals, &uvs, &indices) + .expect("tangents"); + assert_eq!(tangents.len(), indices.len()); + for tangent in tangents { + assert!(tangent.into_iter().all(f32::is_finite)); + assert!(tangent[3].abs() == 1.0); + } +} + +#[test] +fn animated_morph_weights_are_rejected() { + let json = Json::parse( + r#"{ + "accessors":[], + "animations":[{ + "samplers":[], + "channels":[{"sampler":0,"target":{"node":0,"path":"weights"}}] + }] + }"#, + ) + .expect("fixture JSON"); + assert!(matches!( + parse_animations(&json, &[]), + Err(GlbError::Unsupported("animated morph weights")) + )); +} + +#[test] +fn punctual_light_defaults_and_authored_values() { + let json = r#"{ + "extensions":{"KHR_lights_punctual":{"lights":[ + {"type":"point"}, + {"name":"lamp","type":"point","color":[0.2,0.4,0.8],"intensity":12.5,"range":7.0} + ]}}, + "nodes":[ + {"extensions":{"KHR_lights_punctual":{"light":0}}}, + {"extensions":{"KHR_lights_punctual":{"light":1}}} + ] + }"#; + let doc = parse(&build_glb(json, &[])).expect("lights"); + assert_eq!(doc.lights.len(), 2); + assert_eq!(doc.lights[0].kind, GlbLightKind::Point); + assert_eq!(doc.lights[0].color, [1.0; 3]); + assert_eq!(doc.lights[0].intensity, 1.0); + assert_eq!(doc.lights[0].range, None); + assert_eq!(doc.lights[1].name.as_deref(), Some("lamp")); + assert_eq!(doc.lights[1].color, [0.2, 0.4, 0.8]); + assert_eq!(doc.lights[1].range, Some(7.0)); + assert_eq!(doc.nodes[0].light, Some(0)); + assert_eq!(doc.nodes[1].light, Some(1)); +} + +#[test] +fn punctual_light_shared_nested_reference_and_globals() { + let json = r#"{ + "extensions":{"KHR_lights_punctual":{"lights":[{"type":"spot","spot":{"innerConeAngle":0.1,"outerConeAngle":0.5}}]}}, + "scene":0,"scenes":[{"nodes":[0]}], + "nodes":[ + {"translation":[2,0,0],"children":[1]}, + {"translation":[0,3,0],"extensions":{"KHR_lights_punctual":{"light":0}}}, + {"translation":[9,0,0],"extensions":{"KHR_lights_punctual":{"light":0}}} + ] + }"#; + let doc = parse(&build_glb(json, &[])).expect("lights"); + assert_eq!(doc.nodes[1].light, Some(0)); + assert_eq!(doc.nodes[2].light, Some(0)); + let globals = doc.node_globals(); + assert_eq!(globals[0].transform_point(Vec3::ZERO), vec3(2.0, 0.0, 0.0)); + assert_eq!(globals[1].transform_point(Vec3::ZERO), vec3(2.0, 3.0, 0.0)); + assert_eq!(globals[2].transform_point(Vec3::ZERO), vec3(9.0, 0.0, 0.0)); +} + +#[test] +fn node_globals_visit_unselected_roots_before_lower_index_children() { + let json = r#"{ + "nodes":[ + {"translation":[0,3,0]}, + {"translation":[2,0,0],"children":[0]} + ] + }"#; + let doc = parse(&build_glb(json, &[])).expect("unselected hierarchy"); + let globals = doc.node_globals(); + assert_eq!(globals[0].transform_point(Vec3::ZERO), vec3(2.0, 3.0, 0.0)); + assert_eq!(globals[1].transform_point(Vec3::ZERO), vec3(2.0, 0.0, 0.0)); +} + +#[test] +fn punctual_light_rejects_malformed_values_and_unknown_required_extensions() { + for json in [ + r#"{"extensions":{"KHR_lights_punctual":{"lights":[{"type":"point","intensity":-1}]}}}"#, + r#"{"extensions":{"KHR_lights_punctual":{"lights":[{"type":"point","range":0}]}}}"#, + r#"{"extensions":{"KHR_lights_punctual":{"lights":[{"type":"point","color":[1,2]}]}}}"#, + r#"{"extensions":{"KHR_lights_punctual":{"lights":[{"type":"point"}]}},"nodes":[{"extensions":{"KHR_lights_punctual":{"light":3}}}]}"#, + r#"{"extensions":{"KHR_lights_punctual":{"lights":[{"type":"point"}]}},"nodes":[{"extensions":{"KHR_lights_punctual":{}}}]}"#, + r#"{"extensions":{"KHR_lights_punctual":{"lights":[{"type":"spot","spot":5}]}}}"#, + r#"{"extensions":{"KHR_lights_punctual":{"lights":[{"type":"spot","spot":{"outerConeAngle":0}}]}}}"#, + r#"{"extensionsRequired":["KHR_lights_punctual"]}"#, + r#"{"extensionsRequired":["VENDOR_unknown"]}"#, + ] { + assert!( + parse(&build_glb(json, &[])).is_err(), + "accepted malformed fixture" + ); + } +} + +#[test] +fn punctual_light_required_extension_accepts_only_renderable_points() { + let point = r#"{ + "extensionsRequired":["KHR_lights_punctual"], + "extensions":{"KHR_lights_punctual":{"lights":[{"type":"point"}]}} + }"#; + let doc = parse(&build_glb(point, &[])).expect("required point extension"); + assert_eq!(doc.lights[0].kind, GlbLightKind::Point); + + for kind in ["spot", "directional"] { + let optional = format!( + r#"{{"extensions":{{"KHR_lights_punctual":{{"lights":[{{"type":"{kind}"}}]}}}}}}"# + ); + assert!( + parse(&build_glb(&optional, &[])).is_ok(), + "optional {kind} definitions are parsed for validation" + ); + let required = format!( + r#"{{"extensionsRequired":["KHR_lights_punctual"],"extensions":{{"KHR_lights_punctual":{{"lights":[{{"type":"{kind}"}}]}}}}}}"# + ); + assert!( + parse(&build_glb(&required, &[])).is_err(), + "required {kind} cannot be rendered as a point" + ); + } +} + +#[test] +fn primitive_morphed_positions_match_authored_default_weights() { + let primitive = GlbPrimitive { + positions: vec![[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], + morph_targets: vec![GlbMorphTarget { + positions: vec![[2.0, 0.0, -2.0], [0.0, 4.0, 2.0]], + ..GlbMorphTarget::default() + }], + morph_weights: vec![0.5], + ..GlbPrimitive::default() + }; + + assert_eq!( + primitive.morphed_positions(), + vec![[1.0, 1.0, 1.0], [3.0, 6.0, 6.0]] + ); +} diff --git a/client-rust/source/engine-core/src/image.rs b/client-rust/source/engine-core/src/image.rs new file mode 100644 index 00000000..8dd9b051 --- /dev/null +++ b/client-rust/source/engine-core/src/image.rs @@ -0,0 +1,363 @@ +//! Embedded PNG/JPEG decoder → RGBA8. `no_std` + `alloc`. +//! +//! PNG supports the 8-bit, non-interlaced forms emitted by the asset pipeline. +//! JPEG decoding uses `zune-jpeg`. Both paths enforce the same bounded image +//! dimensions before returning uploadable pixels. + +use alloc::vec; +use alloc::vec::Vec; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ImageError { + BadSignature, + Truncated, + BadChunk, + UnsupportedBitDepth, + UnsupportedColorType, + UnsupportedMime, + DimensionsTooLarge, + Interlaced, + Inflate, + Jpeg, + BadFilter, + MissingPalette, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RgbaImage { + pub width: u32, + pub height: u32, + /// `width * height * 4` bytes. + pub pixels: Vec<u8>, +} + +const MAX_DIMENSION: u32 = 8_192; +const MAX_PIXELS: u64 = 67_108_864; + +fn validate_dimensions(width: u32, height: u32) -> Result<usize, ImageError> { + if width == 0 + || height == 0 + || width > MAX_DIMENSION + || height > MAX_DIMENSION + || u64::from(width) * u64::from(height) > MAX_PIXELS + { + return Err(ImageError::DimensionsTooLarge); + } + (width as usize) + .checked_mul(height as usize) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or(ImageError::DimensionsTooLarge) +} + +pub fn decode_image(mime: &str, bytes: &[u8]) -> Result<RgbaImage, ImageError> { + match mime { + "image/png" => decode_png(bytes), + "image/jpeg" => decode_jpeg(bytes), + _ => Err(ImageError::UnsupportedMime), + } +} + +pub fn decode_jpeg(bytes: &[u8]) -> Result<RgbaImage, ImageError> { + use zune_core::bytestream::ZCursor; + use zune_core::colorspace::ColorSpace; + use zune_core::options::DecoderOptions; + use zune_jpeg::JpegDecoder; + + let options = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::RGBA); + let mut decoder = JpegDecoder::new_with_options(ZCursor::new(bytes), options); + decoder.decode_headers().map_err(|_| ImageError::Jpeg)?; + let (width, height) = decoder.dimensions().ok_or(ImageError::Jpeg)?; + let width = u32::try_from(width).map_err(|_| ImageError::DimensionsTooLarge)?; + let height = u32::try_from(height).map_err(|_| ImageError::DimensionsTooLarge)?; + let expected = validate_dimensions(width, height)?; + let pixels = decoder.decode().map_err(|_| ImageError::Jpeg)?; + if pixels.len() != expected { + return Err(ImageError::Jpeg); + } + Ok(RgbaImage { + width, + height, + pixels, + }) +} +const SIG: [u8; 8] = [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']; + +fn be_u32(b: &[u8], o: usize) -> Result<u32, ImageError> { + if o + 4 > b.len() { + return Err(ImageError::Truncated); + } + Ok((b[o] as u32) << 24 | (b[o + 1] as u32) << 16 | (b[o + 2] as u32) << 8 | b[o + 3] as u32) +} + +pub fn decode_png(bytes: &[u8]) -> Result<RgbaImage, ImageError> { + if bytes.len() < 8 || bytes[..8] != SIG { + return Err(ImageError::BadSignature); + } + let mut pos = 8usize; + let mut width = 0u32; + let mut height = 0u32; + let mut color_type = 0u8; + let mut palette: Vec<[u8; 3]> = Vec::new(); + let mut trns: Vec<u8> = Vec::new(); + let mut idat: Vec<u8> = Vec::new(); + let mut seen_ihdr = false; + + while pos + 8 <= bytes.len() { + let len = be_u32(bytes, pos)? as usize; + let ctype = &bytes[pos + 4..pos + 8]; + let data_start = pos + 8; + let data_end = data_start.checked_add(len).ok_or(ImageError::BadChunk)?; + if data_end + 4 > bytes.len() { + return Err(ImageError::Truncated); + } + let data = &bytes[data_start..data_end]; + match ctype { + b"IHDR" => { + if len < 13 { + return Err(ImageError::BadChunk); + } + width = be_u32(data, 0)?; + height = be_u32(data, 4)?; + let bit_depth = data[8]; + color_type = data[9]; + let interlace = data[12]; + if bit_depth != 8 { + return Err(ImageError::UnsupportedBitDepth); + } + if interlace != 0 { + return Err(ImageError::Interlaced); + } + if !matches!(color_type, 0 | 2 | 3 | 4 | 6) { + return Err(ImageError::UnsupportedColorType); + } + seen_ihdr = true; + validate_dimensions(width, height)?; + } + b"PLTE" => { + for c in data.chunks_exact(3) { + palette.push([c[0], c[1], c[2]]); + } + } + b"tRNS" => trns = data.to_vec(), + b"IDAT" => idat.extend_from_slice(data), + b"IEND" => break, + _ => {} + } + pos = data_end + 4; // skip CRC + } + if !seen_ihdr { + return Err(ImageError::BadChunk); + } + + let channels: usize = match color_type { + 0 => 1, + 2 => 3, + 3 => 1, + 4 => 2, + 6 => 4, + _ => return Err(ImageError::UnsupportedColorType), + }; + let raw = + miniz_oxide::inflate::decompress_to_vec_zlib(&idat).map_err(|_| ImageError::Inflate)?; + + let w = width as usize; + let h = height as usize; + let stride = w + .checked_mul(channels) + .ok_or(ImageError::DimensionsTooLarge)?; + let unfiltered = unfilter(&raw, w, h, channels, stride)?; + + // Expand to RGBA8. + let mut pixels = vec![0u8; validate_dimensions(width, height)?]; + for i in 0..(w * h) { + let src = i * channels; + let dst = i * 4; + match color_type { + 0 => { + let g = unfiltered[src]; + pixels[dst] = g; + pixels[dst + 1] = g; + pixels[dst + 2] = g; + pixels[dst + 3] = 255; + } + 2 => { + pixels[dst] = unfiltered[src]; + pixels[dst + 1] = unfiltered[src + 1]; + pixels[dst + 2] = unfiltered[src + 2]; + pixels[dst + 3] = 255; + } + 3 => { + let idx = unfiltered[src] as usize; + let rgb = *palette.get(idx).ok_or(ImageError::MissingPalette)?; + pixels[dst] = rgb[0]; + pixels[dst + 1] = rgb[1]; + pixels[dst + 2] = rgb[2]; + pixels[dst + 3] = trns.get(idx).copied().unwrap_or(255); + } + 4 => { + let g = unfiltered[src]; + pixels[dst] = g; + pixels[dst + 1] = g; + pixels[dst + 2] = g; + pixels[dst + 3] = unfiltered[src + 1]; + } + 6 => { + pixels[dst..dst + 4].copy_from_slice(&unfiltered[src..src + 4]); + } + _ => unreachable!(), + } + } + Ok(RgbaImage { + width, + height, + pixels, + }) +} + +/// Reverse PNG scanline filtering in place, returning the raw pixel bytes +/// (filter bytes stripped). +fn unfilter( + raw: &[u8], + _w: usize, + h: usize, + channels: usize, + stride: usize, +) -> Result<Vec<u8>, ImageError> { + let bpp = channels; // 8-bit → bytes-per-pixel == channels + let expected = h * (stride + 1); + if raw.len() < expected { + return Err(ImageError::Truncated); + } + let mut out = vec![0u8; h * stride]; + for y in 0..h { + let filter = raw[y * (stride + 1)]; + let src = y * (stride + 1) + 1; + let dst = y * stride; + for x in 0..stride { + let cur = raw[src + x]; + let a = if x >= bpp { out[dst + x - bpp] } else { 0 }; + let b = if y > 0 { out[dst - stride + x] } else { 0 }; + let c = if y > 0 && x >= bpp { + out[dst - stride + x - bpp] + } else { + 0 + }; + let recon = match filter { + 0 => cur, + 1 => cur.wrapping_add(a), + 2 => cur.wrapping_add(b), + 3 => cur.wrapping_add(((a as u16 + b as u16) / 2) as u8), + 4 => cur.wrapping_add(paeth(a, b, c)), + _ => return Err(ImageError::BadFilter), + }; + out[dst + x] = recon; + } + } + Ok(out) +} + +fn paeth(a: u8, b: u8, c: u8) -> u8 { + let (a, b, c) = (a as i32, b as i32, c as i32); + let p = a + b - c; + let pa = (p - a).abs(); + let pb = (p - b).abs(); + let pc = (p - c).abs(); + if pa <= pb && pa <= pc { + a as u8 + } else if pb <= pc { + b as u8 + } else { + c as u8 + } +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + + /// Build a minimal RGBA PNG with zlib-stored IDAT and filter byte 0. + fn build_rgba_png(w: u32, h: u32, rgba: &[u8]) -> Vec<u8> { + fn chunk(out: &mut Vec<u8>, tag: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(tag); + out.extend_from_slice(data); + // CRC ignored by decoder; write zeros. + out.extend_from_slice(&[0, 0, 0, 0]); + } + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&w.to_be_bytes()); + ihdr.extend_from_slice(&h.to_be_bytes()); + ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); // depth 8, RGBA, deflate, filter 0, no interlace + + // Raw scanlines with filter byte 0. + let mut raw = Vec::new(); + let stride = (w * 4) as usize; + for y in 0..h as usize { + raw.push(0u8); + raw.extend_from_slice(&rgba[y * stride..(y + 1) * stride]); + } + let idat = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6); + + let mut out = Vec::new(); + out.extend_from_slice(&SIG); + chunk(&mut out, b"IHDR", &ihdr); + chunk(&mut out, b"IDAT", &idat); + chunk(&mut out, b"IEND", &[]); + out + } + + #[test] + fn roundtrips_2x2_rgba() { + let src = vec![ + 255, 0, 0, 255, 0, 255, 0, 255, // row 0: red, green + 0, 0, 255, 255, 255, 255, 0, 128, // row 1: blue, semi-yellow + ]; + let png = build_rgba_png(2, 2, &src); + let img = decode_png(&png).expect("decode"); + assert_eq!(img.width, 2); + assert_eq!(img.height, 2); + assert_eq!(img.pixels, src); + } + + #[test] + fn dispatches_png_and_rejects_unknown_mime() { + let src = [7, 11, 13, 255]; + let png = build_rgba_png(1, 1, &src); + assert_eq!(decode_image("image/png", &png).expect("PNG").pixels, src); + assert_eq!( + decode_image("image/webp", &png), + Err(ImageError::UnsupportedMime) + ); + } + + #[test] + fn dispatches_embedded_corpus_jpeg_as_rgba() { + let bytes = include_bytes!( + "../../../../client-3d/public/assets/items/custom/accessories/field_cap.glb" + ); + let document = crate::glb::parse(bytes).expect("field-cap GLB"); + let image = document + .images + .iter() + .find(|image| image.mime_type == "image/jpeg") + .expect("embedded JPEG"); + let decoded = decode_image(&image.mime_type, &image.bytes).expect("JPEG"); + assert_eq!( + decoded.pixels.len(), + decoded.width as usize * decoded.height as usize * 4 + ); + assert!(decoded.width <= MAX_DIMENSION && decoded.height <= MAX_DIMENSION); + } + + #[test] + fn rejects_oversized_png_before_inflate() { + let mut png = build_rgba_png(1, 1, &[0, 0, 0, 255]); + png[16..20].copy_from_slice(&(MAX_DIMENSION + 1).to_be_bytes()); + assert_eq!(decode_png(&png), Err(ImageError::DimensionsTooLarge)); + } + + #[test] + fn rejects_non_png() { + assert_eq!(decode_png(&[0u8; 8]), Err(ImageError::BadSignature)); + } +} diff --git a/client-rust/source/engine-core/src/input.rs b/client-rust/source/engine-core/src/input.rs new file mode 100644 index 00000000..42ff8a30 --- /dev/null +++ b/client-rust/source/engine-core/src/input.rs @@ -0,0 +1,70 @@ +//! Platform-agnostic input codes. The platform backend maps its native key +//! identifiers (GLFW key codes / DOM `KeyboardEvent.code`) onto these, so engine +//! and game code never sees a backend-specific constant. + +/// Keys the client actually consumes. `repr(u16)` so the platform can pass a +/// code across the FFI boundary and `Key::from_u16` it back. +#[repr(u16)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Key { + W = 0, + A = 1, + S = 2, + D = 3, + Up = 4, + Down = 5, + Left = 6, + Right = 7, + Space = 8, + Enter = 9, + Escape = 10, + Backspace = 11, + LeftShift = 12, + Backquote = 13, + R = 14, + F = 15, + I = 16, + C = 17, + Semicolon = 18, + O = 19, + Tab = 20, + V = 21, + X = 22, + N = 23, + Digit0 = 24, + Digit1 = 25, + Digit2 = 26, + Digit3 = 27, + Digit4 = 28, + Digit5 = 29, + Digit6 = 30, + Digit7 = 31, + Digit8 = 32, + Digit9 = 33, + P = 34, + K = 35, + B = 36, + M = 37, + G = 38, +} + +impl Key { + pub const COUNT: usize = 39; + + pub fn from_u16(v: u16) -> Option<Key> { + if (v as usize) < Key::COUNT { + // SAFETY: bounds-checked against COUNT; enum discriminants are contiguous. + Some(unsafe { core::mem::transmute::<u16, Key>(v) }) + } else { + None + } + } +} + +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum MouseButton { + Left = 0, + Right = 1, + Middle = 2, +} diff --git a/client-rust/source/engine-core/src/json.rs b/client-rust/source/engine-core/src/json.rs new file mode 100644 index 00000000..22671f41 --- /dev/null +++ b/client-rust/source/engine-core/src/json.rs @@ -0,0 +1,798 @@ +//! Hand-rolled `no_std` JSON value, parser, and writer. +//! +//! `serde_json` is not `no_std`-friendly, and the size gate forbids +//! `core::fmt`, so this is a small recursive-descent parser plus a string +//! builder. Modeled on the voxel engine's `json.rs`. The float writer is +//! adaptive: it emits the fewest fractional digits (0..=9) whose value +//! round-trips back to the same `f32`, which covers all client prefab/manifest +//! data (positions, colors, scales). + +use alloc::string::String; +use alloc::vec::Vec; + +#[derive(Clone, PartialEq, Debug)] +pub enum Json { + Null, + Bool(bool), + Num(f64), + Str(String), + Arr(Vec<Json>), + Obj(Vec<(String, Json)>), +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum JsonError { + Eof, + Unexpected, + BadEscape, + BadNumber, +} + +impl Json { + pub fn parse(input: &str) -> Result<Json, JsonError> { + let mut p = Parser { + bytes: input.as_bytes(), + pos: 0, + }; + p.skip_ws(); + let v = p.parse_value()?; + p.skip_ws(); + if p.pos != p.bytes.len() { + return Err(JsonError::Unexpected); + } + Ok(v) + } + + pub fn get(&self, key: &str) -> Option<&Json> { + match self { + Json::Obj(fields) => fields.iter().find(|(k, _)| k == key).map(|(_, v)| v), + _ => None, + } + } + + pub fn as_f64(&self) -> Option<f64> { + match self { + Json::Num(n) => Some(*n), + _ => None, + } + } + + pub fn as_f32(&self) -> Option<f32> { + self.as_f64().map(|n| n as f32) + } + + pub fn as_i64(&self) -> Option<i64> { + match self { + Json::Num(n) => Some(*n as i64), + _ => None, + } + } + + pub fn as_bool(&self) -> Option<bool> { + match self { + Json::Bool(b) => Some(*b), + _ => None, + } + } + + pub fn as_str(&self) -> Option<&str> { + match self { + Json::Str(s) => Some(s.as_str()), + _ => None, + } + } + + pub fn as_array(&self) -> Option<&[Json]> { + match self { + Json::Arr(a) => Some(a.as_slice()), + _ => None, + } + } + + pub fn as_object(&self) -> Option<&[(String, Json)]> { + match self { + Json::Obj(o) => Some(o.as_slice()), + _ => None, + } + } +} + +struct Parser<'a> { + bytes: &'a [u8], + pos: usize, +} + +impl<'a> Parser<'a> { + fn peek(&self) -> Option<u8> { + self.bytes.get(self.pos).copied() + } + + fn skip_ws(&mut self) { + while let Some(c) = self.peek() { + if c == b' ' || c == b'\t' || c == b'\n' || c == b'\r' { + self.pos += 1; + } else { + break; + } + } + } + + fn parse_value(&mut self) -> Result<Json, JsonError> { + match self.peek().ok_or(JsonError::Eof)? { + b'{' => self.parse_object(), + b'[' => self.parse_array(), + b'"' => Ok(Json::Str(self.parse_string()?)), + b't' => self.parse_lit("true", Json::Bool(true)), + b'f' => self.parse_lit("false", Json::Bool(false)), + b'n' => self.parse_lit("null", Json::Null), + b'-' | b'0'..=b'9' => self.parse_number(), + _ => Err(JsonError::Unexpected), + } + } + + fn parse_lit(&mut self, lit: &str, val: Json) -> Result<Json, JsonError> { + let end = self.pos + lit.len(); + if end <= self.bytes.len() && &self.bytes[self.pos..end] == lit.as_bytes() { + self.pos = end; + Ok(val) + } else { + Err(JsonError::Unexpected) + } + } + + fn parse_object(&mut self) -> Result<Json, JsonError> { + self.pos += 1; // '{' + let mut fields = Vec::new(); + self.skip_ws(); + if self.peek() == Some(b'}') { + self.pos += 1; + return Ok(Json::Obj(fields)); + } + loop { + self.skip_ws(); + if self.peek() != Some(b'"') { + return Err(JsonError::Unexpected); + } + let key = self.parse_string()?; + self.skip_ws(); + if self.peek() != Some(b':') { + return Err(JsonError::Unexpected); + } + self.pos += 1; + self.skip_ws(); + let value = self.parse_value()?; + fields.push((key, value)); + self.skip_ws(); + match self.peek() { + Some(b',') => { + self.pos += 1; + } + Some(b'}') => { + self.pos += 1; + return Ok(Json::Obj(fields)); + } + _ => return Err(JsonError::Unexpected), + } + } + } + + fn parse_array(&mut self) -> Result<Json, JsonError> { + self.pos += 1; // '[' + let mut items = Vec::new(); + self.skip_ws(); + if self.peek() == Some(b']') { + self.pos += 1; + return Ok(Json::Arr(items)); + } + loop { + self.skip_ws(); + items.push(self.parse_value()?); + self.skip_ws(); + match self.peek() { + Some(b',') => { + self.pos += 1; + } + Some(b']') => { + self.pos += 1; + return Ok(Json::Arr(items)); + } + _ => return Err(JsonError::Unexpected), + } + } + } + + fn parse_string(&mut self) -> Result<String, JsonError> { + self.pos += 1; // opening quote + let mut out = String::new(); + loop { + let c = self.peek().ok_or(JsonError::Eof)?; + self.pos += 1; + match c { + b'"' => return Ok(out), + b'\\' => { + let e = self.peek().ok_or(JsonError::Eof)?; + self.pos += 1; + match e { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + b'b' => out.push('\u{0008}'), + b'f' => out.push('\u{000C}'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'u' => { + let cp = self.parse_hex4()?; + // Surrogate pair handling. + if (0xD800..=0xDBFF).contains(&cp) { + if self.peek() == Some(b'\\') { + self.pos += 1; + if self.peek() == Some(b'u') { + self.pos += 1; + let lo = self.parse_hex4()?; + let c = 0x10000 + + (((cp - 0xD800) as u32) << 10) + + (lo - 0xDC00) as u32; + out.push(char::from_u32(c).ok_or(JsonError::BadEscape)?); + } else { + return Err(JsonError::BadEscape); + } + } else { + return Err(JsonError::BadEscape); + } + } else { + out.push(char::from_u32(cp as u32).ok_or(JsonError::BadEscape)?); + } + } + _ => return Err(JsonError::BadEscape), + } + } + _ => { + // Copy the full UTF-8 sequence starting at this byte. + let start = self.pos - 1; + let len = utf8_len(c); + if len == 0 || start + len > self.bytes.len() { + return Err(JsonError::Unexpected); + } + self.pos = start + len; + let s = core::str::from_utf8(&self.bytes[start..start + len]) + .map_err(|_| JsonError::Unexpected)?; + out.push_str(s); + } + } + } + } + + fn parse_hex4(&mut self) -> Result<u16, JsonError> { + if self.pos + 4 > self.bytes.len() { + return Err(JsonError::BadEscape); + } + let mut v: u16 = 0; + for _ in 0..4 { + let d = self.bytes[self.pos]; + self.pos += 1; + let nibble = match d { + b'0'..=b'9' => d - b'0', + b'a'..=b'f' => d - b'a' + 10, + b'A'..=b'F' => d - b'A' + 10, + _ => return Err(JsonError::BadEscape), + }; + v = (v << 4) | nibble as u16; + } + Ok(v) + } + + fn parse_number(&mut self) -> Result<Json, JsonError> { + let start = self.pos; + if self.peek() == Some(b'-') { + self.pos += 1; + } + while let Some(c) = self.peek() { + if c.is_ascii_digit() || c == b'.' || c == b'e' || c == b'E' || c == b'+' || c == b'-' { + self.pos += 1; + } else { + break; + } + } + let s = + core::str::from_utf8(&self.bytes[start..self.pos]).map_err(|_| JsonError::BadNumber)?; + parse_f64(s).map(Json::Num).ok_or(JsonError::BadNumber) + } +} + +fn utf8_len(first: u8) -> usize { + match first { + 0x00..=0x7F => 1, + 0xC0..=0xDF => 2, + 0xE0..=0xEF => 3, + 0xF0..=0xF7 => 4, + _ => 0, + } +} + +/// Parse a JSON number into `f64` without `core::str::parse` (which pulls in +/// float formatting/parsing infra we keep lean). Handles sign, fraction, and +/// exponent. +pub fn parse_f64(s: &str) -> Option<f64> { + let b = s.as_bytes(); + let mut i = 0; + let n = b.len(); + if n == 0 { + return None; + } + let mut neg = false; + if b[i] == b'-' { + neg = true; + i += 1; + } else if b[i] == b'+' { + i += 1; + } + let mut mantissa: f64 = 0.0; + let mut any = false; + while i < n && b[i].is_ascii_digit() { + mantissa = mantissa * 10.0 + (b[i] - b'0') as f64; + i += 1; + any = true; + } + if i < n && b[i] == b'.' { + i += 1; + let mut scale = 0.1; + while i < n && b[i].is_ascii_digit() { + mantissa += (b[i] - b'0') as f64 * scale; + scale *= 0.1; + i += 1; + any = true; + } + } + if !any { + return None; + } + let mut exp: i32 = 0; + if i < n && (b[i] == b'e' || b[i] == b'E') { + i += 1; + let mut eneg = false; + if i < n && (b[i] == b'+' || b[i] == b'-') { + eneg = b[i] == b'-'; + i += 1; + } + let mut e = 0i32; + let mut eany = false; + while i < n && b[i].is_ascii_digit() { + e = e * 10 + (b[i] - b'0') as i32; + i += 1; + eany = true; + } + if !eany { + return None; + } + exp = if eneg { -e } else { e }; + } + if i != n { + return None; + } + let mut value = mantissa; + if exp != 0 { + value *= pow10(exp); + } + Some(if neg { -value } else { value }) +} + +fn pow10(exp: i32) -> f64 { + let mut r = 1.0f64; + let mut e = exp.unsigned_abs(); + let mut base = 10.0f64; + while e > 0 { + if e & 1 == 1 { + r *= base; + } + base *= base; + e >>= 1; + } + if exp < 0 { + 1.0 / r + } else { + r + } +} + +// ============================================================================ +// Writer +// ============================================================================ + +/// Builds a JSON string. Object/array structure is caller-driven; the float +/// writer round-trips f32 data. +pub struct JsonWriter { + out: String, + stack: Vec<Frame>, +} + +#[derive(Clone, Copy)] +struct Frame { + /// Whether this container already emitted a member (controls commas). + has_member: bool, + /// Set right after a key; the following value must not emit a comma. + expecting_value: bool, +} + +impl Default for JsonWriter { + fn default() -> Self { + Self::new() + } +} + +impl JsonWriter { + pub fn new() -> Self { + Self { + out: String::new(), + stack: Vec::new(), + } + } + + pub fn into_string(self) -> String { + self.out + } + + fn pre_value(&mut self) { + let comma = match self.stack.last_mut() { + Some(f) => { + if f.expecting_value { + f.expecting_value = false; + false + } else { + let c = f.has_member; + f.has_member = true; + c + } + } + None => false, + }; + if comma { + self.out.push(','); + } + } + + fn push_frame(&mut self) { + self.stack.push(Frame { + has_member: false, + expecting_value: false, + }); + } + + pub fn begin_obj(&mut self) { + self.pre_value(); + self.out.push('{'); + self.push_frame(); + } + + pub fn end_obj(&mut self) { + self.out.push('}'); + self.stack.pop(); + } + + pub fn begin_array(&mut self) { + self.pre_value(); + self.out.push('['); + self.push_frame(); + } + + pub fn end_array(&mut self) { + self.out.push(']'); + self.stack.pop(); + } + + /// Begin an object-valued field: writes `"key":` then opens `{`. + pub fn begin_obj_field(&mut self, key: &str) { + self.key(key); + self.begin_obj(); + } + + /// Write a field key `"key":`. The next value writer fills the slot without + /// emitting a leading comma (tracked by `Frame::expecting_value`). + pub fn key(&mut self, key: &str) { + let comma = match self.stack.last_mut() { + Some(f) => { + let c = f.has_member; + f.has_member = true; + f.expecting_value = true; + c + } + None => false, + }; + if comma { + self.out.push(','); + } + self.write_str_raw(key); + self.out.push(':'); + } + + pub fn value_str(&mut self, s: &str) { + self.pre_value(); + self.write_str_raw(s); + } + + pub fn value_bool(&mut self, b: bool) { + self.pre_value(); + self.out.push_str(if b { "true" } else { "false" }); + } + + pub fn value_null(&mut self) { + self.pre_value(); + self.out.push_str("null"); + } + + pub fn value_i64(&mut self, n: i64) { + self.pre_value(); + if n < 0 { + self.out.push('-'); + } + self.write_u64(n.unsigned_abs()); + } + + pub fn value_u64(&mut self, n: u64) { + self.pre_value(); + self.write_u64(n); + } + + /// Field convenience helpers. + pub fn field_str(&mut self, key: &str, s: &str) { + self.key(key); + self.value_str(s); + } + pub fn field_f32(&mut self, key: &str, v: f32) { + self.key(key); + self.value_f32(v); + } + pub fn field_i64(&mut self, key: &str, v: i64) { + self.key(key); + self.value_i64(v); + } + pub fn field_bool(&mut self, key: &str, v: bool) { + self.key(key); + self.value_bool(v); + } + + fn write_u64(&mut self, mut n: u64) { + if n == 0 { + self.out.push('0'); + return; + } + let mut buf = [0u8; 20]; + let mut i = buf.len(); + while n > 0 { + i -= 1; + buf[i] = b'0' + (n % 10) as u8; + n /= 10; + } + self.out.push_str(core::str::from_utf8(&buf[i..]).unwrap()); + } + + fn write_str_raw(&mut self, s: &str) { + self.out.push('"'); + for ch in s.chars() { + match ch { + '"' => self.out.push_str("\\\""), + '\\' => self.out.push_str("\\\\"), + '\n' => self.out.push_str("\\n"), + '\r' => self.out.push_str("\\r"), + '\t' => self.out.push_str("\\t"), + '\u{0008}' => self.out.push_str("\\b"), + '\u{000C}' => self.out.push_str("\\f"), + c if (c as u32) < 0x20 => { + self.out.push_str("\\u00"); + let byte = c as u32; + let hi = (byte >> 4) & 0xF; + let lo = byte & 0xF; + self.out.push(hex_digit(hi as u8)); + self.out.push(hex_digit(lo as u8)); + } + c => self.out.push(c), + } + } + self.out.push('"'); + } + + /// Adaptive round-trip f32 writer: the fewest fractional digits (0..=9) + /// whose decimal parses back to the same `f32`. + pub fn value_f32(&mut self, v: f32) { + self.pre_value(); + self.write_f32(v); + } + + fn write_f32(&mut self, v: f32) { + if !v.is_finite() { + self.out.push('0'); + return; + } + if v == 0.0 { + self.out.push('0'); + return; + } + let mut work = String::new(); + for digits in 0..=9u32 { + work.clear(); + format_fixed(v, digits, &mut work); + if let Some(parsed) = parse_f64(&work) { + if parsed as f32 == v { + self.out.push_str(&work); + return; + } + } + } + // Fallback: 9 digits (already in `work`). + self.out.push_str(&work); + } +} + +fn hex_digit(n: u8) -> char { + if n < 10 { + (b'0' + n) as char + } else { + (b'a' + (n - 10)) as char + } +} + +/// Format `v` with exactly `digits` fractional digits into `out`. +fn format_fixed(v: f32, digits: u32, out: &mut String) { + let neg = v < 0.0; + let mag = if neg { -v as f64 } else { v as f64 }; + let scale = pow10(digits as i32); + let scaled = libm::round(mag * scale) as u128; + let int_part = (scaled / scale as u128) as u128; + let frac_part = (scaled % scale as u128) as u128; + if neg { + out.push('-'); + } + push_u128(int_part, out); + if digits > 0 { + out.push('.'); + // Zero-pad the fractional part to `digits`. + let mut buf = [0u8; 39]; + let mut i = buf.len(); + let mut f = frac_part; + for _ in 0..digits { + i -= 1; + buf[i] = b'0' + (f % 10) as u8; + f /= 10; + } + out.push_str(core::str::from_utf8(&buf[i..]).unwrap()); + } +} + +fn push_u128(mut n: u128, out: &mut String) { + if n == 0 { + out.push('0'); + return; + } + let mut buf = [0u8; 39]; + let mut i = buf.len(); + while n > 0 { + i -= 1; + buf[i] = b'0' + (n % 10) as u8; + n /= 10; + } + out.push_str(core::str::from_utf8(&buf[i..]).unwrap()); +} + +impl Json { + /// Serialize back to a compact string (used by tests / debug). + pub fn to_string_compact(&self) -> String { + let mut w = JsonWriter::new(); + write_value(&mut w, self); + w.into_string() + } +} + +fn write_value(w: &mut JsonWriter, v: &Json) { + match v { + Json::Null => w.value_null(), + Json::Bool(b) => w.value_bool(*b), + Json::Num(n) => { + // Integers write cleanly; non-integers via f32 round-trip. + if libm::trunc(*n) == *n && libm::fabs(*n) < 9.007e15 { + w.value_i64(*n as i64); + } else { + w.value_f32(*n as f32); + } + } + Json::Str(s) => w.value_str(s), + Json::Arr(items) => { + w.begin_array(); + for it in items { + write_value(w, it); + } + w.end_array(); + } + Json::Obj(fields) => { + w.begin_obj(); + for (k, val) in fields { + w.key(k); + write_value(w, val); + } + w.end_obj(); + } + } +} + +/// Convenience for tests/tools needing an owned string of any displayable. +pub fn to_json_string(v: &Json) -> String { + v.to_string_compact() +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + + #[test] + fn parse_scalars() { + assert_eq!(Json::parse("true"), Ok(Json::Bool(true))); + assert_eq!(Json::parse("null"), Ok(Json::Null)); + assert_eq!(Json::parse("-12.5"), Ok(Json::Num(-12.5))); + assert_eq!(Json::parse("\"hi\\n\""), Ok(Json::Str("hi\n".into()))); + } + + #[test] + fn parse_object_and_get() { + let j = Json::parse(r#"{ "a": 1, "b": [true, "x"], "c": { "d": 2.5 } }"#).unwrap(); + assert_eq!(j.get("a").and_then(Json::as_i64), Some(1)); + assert_eq!( + j.get("b").and_then(Json::as_array).map(|a| a.len()), + Some(2) + ); + assert_eq!( + j.get("c").and_then(|c| c.get("d")).and_then(Json::as_f64), + Some(2.5) + ); + } + + #[test] + fn parse_exponent_and_unicode() { + assert_eq!(Json::parse("1e3"), Ok(Json::Num(1000.0))); + assert_eq!(Json::parse("2.5e-1"), Ok(Json::Num(0.25))); + assert_eq!(Json::parse("\"\\u0041\""), Ok(Json::Str("A".into()))); + } + + #[test] + fn writer_roundtrips_f32() { + for v in [ + 0.0f32, + 1.0, + -2.5, + 0.021, + 1024.0, + 0.5, + 89.0 / 255.0, + core::f32::consts::PI, + ] { + let mut w = JsonWriter::new(); + w.value_f32(v); + let s = w.into_string(); + let back = parse_f64(&s).unwrap() as f32; + assert_eq!(back, v, "f32 {v} wrote {s} parsed {back}"); + } + } + + #[test] + fn writer_object_structure() { + let mut w = JsonWriter::new(); + w.begin_obj(); + w.field_str("schema", "successor.prefab.v1"); + w.key("pos"); + w.begin_array(); + w.value_f32(1.0); + w.value_f32(2.5); + w.end_array(); + w.field_bool("flag", true); + w.end_obj(); + let s = w.into_string(); + let j = Json::parse(&s).unwrap(); + assert_eq!( + j.get("schema").and_then(Json::as_str), + Some("successor.prefab.v1") + ); + assert_eq!(j.get("flag").and_then(Json::as_bool), Some(true)); + assert_eq!( + j.get("pos").and_then(Json::as_array).map(|a| a.len()), + Some(2) + ); + } +} diff --git a/client-rust/source/engine-core/src/lib.rs b/client-rust/source/engine-core/src/lib.rs new file mode 100644 index 00000000..410bcf88 --- /dev/null +++ b/client-rust/source/engine-core/src/lib.rs @@ -0,0 +1,26 @@ +//! Successor Rust client — engine core. +//! +//! `no_std` + `alloc`. Contains the ECS, fixed-point-free f32 math (via `libm`), +//! shared input codes, and the runtime shims (allocation counter, panic hook, +//! numeric logging, global cell). It depends only on `core`, `alloc`, and +//! `libm`, so it builds for bare-metal (`thumbv7em-none-eabihf`) as a no_std +//! purity proof. +//! +//! The `std` feature is enabled ONLY for the libtest harness (unit tests) and +//! host tooling. It must never be enabled for a shipping game build. + +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +pub mod anim; +pub mod assets; +pub mod audio; +pub mod ecs; +pub mod glb; +pub mod image; +pub mod input; +pub mod json; +pub mod math; +pub mod prefab; +pub mod rt; diff --git a/client-rust/source/engine-core/src/math.rs b/client-rust/source/engine-core/src/math.rs new file mode 100644 index 00000000..bd7c2072 --- /dev/null +++ b/client-rust/source/engine-core/src/math.rs @@ -0,0 +1,514 @@ +//! Minimal f32 linear algebra for the renderer. `no_std`; transcendental and +//! sqrt routines go through `libm` (no dependency on `std`'s float intrinsics). +//! +//! `Mat4` is column-major (OpenGL convention): element (row r, col c) lives at +//! `m[c * 4 + r]`, and `to_cols_array()` uploads directly to a GL uniform. + +#![allow(clippy::many_single_char_names)] + +use libm::{cosf, sinf, sqrtf, tanf}; + +#[derive(Clone, Copy, PartialEq, Debug, Default)] +pub struct Vec2 { + pub x: f32, + pub y: f32, +} + +pub const fn vec2(x: f32, y: f32) -> Vec2 { + Vec2 { x, y } +} + +impl Vec2 { + pub const ZERO: Vec2 = Vec2 { x: 0.0, y: 0.0 }; +} + +#[derive(Clone, Copy, PartialEq, Debug, Default)] +pub struct Vec3 { + pub x: f32, + pub y: f32, + pub z: f32, +} + +pub const fn vec3(x: f32, y: f32, z: f32) -> Vec3 { + Vec3 { x, y, z } +} + +impl Vec3 { + pub const ZERO: Vec3 = Vec3 { + x: 0.0, + y: 0.0, + z: 0.0, + }; + pub const ONE: Vec3 = Vec3 { + x: 1.0, + y: 1.0, + z: 1.0, + }; + pub const Y: Vec3 = Vec3 { + x: 0.0, + y: 1.0, + z: 0.0, + }; + + #[allow(clippy::should_implement_trait)] + pub fn add(self, o: Vec3) -> Vec3 { + vec3(self.x + o.x, self.y + o.y, self.z + o.z) + } + #[allow(clippy::should_implement_trait)] + pub fn sub(self, o: Vec3) -> Vec3 { + vec3(self.x - o.x, self.y - o.y, self.z - o.z) + } + pub fn scale(self, s: f32) -> Vec3 { + vec3(self.x * s, self.y * s, self.z * s) + } + pub fn dot(self, o: Vec3) -> f32 { + self.x * o.x + self.y * o.y + self.z * o.z + } + pub fn cross(self, o: Vec3) -> Vec3 { + vec3( + self.y * o.z - self.z * o.y, + self.z * o.x - self.x * o.z, + self.x * o.y - self.y * o.x, + ) + } + pub fn length(self) -> f32 { + sqrtf(self.dot(self)) + } + pub fn normalize(self) -> Vec3 { + let len = self.length(); + if len > 1e-6 { + self.scale(1.0 / len) + } else { + Vec3::ZERO + } + } +} + +#[derive(Clone, Copy, PartialEq, Debug, Default)] +pub struct Vec4 { + pub x: f32, + pub y: f32, + pub z: f32, + pub w: f32, +} + +pub const fn vec4(x: f32, y: f32, z: f32, w: f32) -> Vec4 { + Vec4 { x, y, z, w } +} + +/// Unit quaternion (x, y, z, w). +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct Quat { + pub x: f32, + pub y: f32, + pub z: f32, + pub w: f32, +} + +impl Default for Quat { + fn default() -> Self { + Quat::IDENTITY + } +} + +impl Quat { + pub const IDENTITY: Quat = Quat { + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + }; + + pub fn from_axis_angle(axis: Vec3, radians: f32) -> Quat { + let a = axis.normalize(); + let half = radians * 0.5; + let s = sinf(half); + Quat { + x: a.x * s, + y: a.y * s, + z: a.z * s, + w: cosf(half), + } + } + + /// Rotation about the world Y axis — the common yaw case for pawns/cameras. + pub fn from_yaw(radians: f32) -> Quat { + Quat::from_axis_angle(Vec3::Y, radians) + } + + #[allow(clippy::should_implement_trait)] + pub fn mul(self, o: Quat) -> Quat { + Quat { + w: self.w * o.w - self.x * o.x - self.y * o.y - self.z * o.z, + x: self.w * o.x + self.x * o.w + self.y * o.z - self.z * o.y, + y: self.w * o.y - self.x * o.z + self.y * o.w + self.z * o.x, + z: self.w * o.z + self.x * o.y - self.y * o.x + self.z * o.w, + } + } + + pub fn normalize(self) -> Quat { + let len = sqrtf(self.x * self.x + self.y * self.y + self.z * self.z + self.w * self.w); + if len > 1e-6 { + let inv = 1.0 / len; + Quat { + x: self.x * inv, + y: self.y * inv, + z: self.z * inv, + w: self.w * inv, + } + } else { + Quat::IDENTITY + } + } +} + +/// Column-major 4x4 matrix. +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct Mat4 { + pub m: [f32; 16], +} + +impl Default for Mat4 { + fn default() -> Self { + Mat4::IDENTITY + } +} + +impl Mat4 { + pub const IDENTITY: Mat4 = Mat4 { + m: [ + 1.0, 0.0, 0.0, 0.0, // + 0.0, 1.0, 0.0, 0.0, // + 0.0, 0.0, 1.0, 0.0, // + 0.0, 0.0, 0.0, 1.0, // + ], + }; + + pub fn to_cols_array(&self) -> [f32; 16] { + self.m + } + + pub fn from_translation(t: Vec3) -> Mat4 { + let mut m = Mat4::IDENTITY; + m.m[12] = t.x; + m.m[13] = t.y; + m.m[14] = t.z; + m + } + + pub fn from_scale(s: Vec3) -> Mat4 { + let mut m = Mat4::IDENTITY; + m.m[0] = s.x; + m.m[5] = s.y; + m.m[10] = s.z; + m + } + + pub fn from_quat(q: Quat) -> Mat4 { + let Quat { x, y, z, w } = q; + let (xx, yy, zz) = (x * x, y * y, z * z); + let (xy, xz, yz) = (x * y, x * z, y * z); + let (wx, wy, wz) = (w * x, w * y, w * z); + Mat4 { + m: [ + 1.0 - 2.0 * (yy + zz), + 2.0 * (xy + wz), + 2.0 * (xz - wy), + 0.0, + 2.0 * (xy - wz), + 1.0 - 2.0 * (xx + zz), + 2.0 * (yz + wx), + 0.0, + 2.0 * (xz + wy), + 2.0 * (yz - wx), + 1.0 - 2.0 * (xx + yy), + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + ], + } + } + + /// Translation * Rotation * Scale. + pub fn from_trs(t: Vec3, r: Quat, s: Vec3) -> Mat4 { + Mat4::from_translation(t) + .mul(Mat4::from_quat(r)) + .mul(Mat4::from_scale(s)) + } + + pub fn mul(&self, o: Mat4) -> Mat4 { + let a = &self.m; + let b = &o.m; + let mut r = [0.0f32; 16]; + for col in 0..4 { + for row in 0..4 { + let mut sum = 0.0; + for k in 0..4 { + sum += a[k * 4 + row] * b[col * 4 + k]; + } + r[col * 4 + row] = sum; + } + } + Mat4 { m: r } + } + + pub fn transform_point(&self, p: Vec3) -> Vec3 { + let m = &self.m; + vec3( + m[0] * p.x + m[4] * p.y + m[8] * p.z + m[12], + m[1] * p.x + m[5] * p.y + m[9] * p.z + m[13], + m[2] * p.x + m[6] * p.y + m[10] * p.z + m[14], + ) + } + + /// Right-handed perspective, NDC z in [-1, 1] (GL). `fovy` in radians. + pub fn perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> Mat4 { + let f = 1.0 / tanf(fovy * 0.5); + let nf = 1.0 / (near - far); + let mut m = [0.0f32; 16]; + m[0] = f / aspect; + m[5] = f; + m[10] = (far + near) * nf; + m[11] = -1.0; + m[14] = 2.0 * far * near * nf; + Mat4 { m } + } + + /// Right-handed orthographic, NDC z in [-1, 1] (GL). + pub fn ortho(left: f32, right: f32, bottom: f32, top: f32, near: f32, far: f32) -> Mat4 { + let mut m = Mat4::IDENTITY; + m.m[0] = 2.0 / (right - left); + m.m[5] = 2.0 / (top - bottom); + m.m[10] = -2.0 / (far - near); + m.m[12] = -(right + left) / (right - left); + m.m[13] = -(top + bottom) / (top - bottom); + m.m[14] = -(far + near) / (far - near); + m + } + + /// Right-handed view matrix (camera at `eye` looking at `center`). + pub fn look_at(eye: Vec3, center: Vec3, up: Vec3) -> Mat4 { + let f = center.sub(eye).normalize(); + let s = f.cross(up).normalize(); + let u = s.cross(f); + Mat4 { + m: [ + s.x, + u.x, + -f.x, + 0.0, // + s.y, + u.y, + -f.y, + 0.0, // + s.z, + u.z, + -f.z, + 0.0, // + -s.dot(eye), + -u.dot(eye), + f.dot(eye), + 1.0, // + ], + } + } + + /// Full 4x4 inverse (column-major). Returns identity if singular. Used for + /// unprojecting screen rays (`inverse(viewProj)`). + pub fn inverse(&self) -> Mat4 { + let m = &self.m; + let mut inv = [0.0f32; 16]; + inv[0] = m[5] * m[10] * m[15] - m[5] * m[11] * m[14] - m[9] * m[6] * m[15] + + m[9] * m[7] * m[14] + + m[13] * m[6] * m[11] + - m[13] * m[7] * m[10]; + inv[4] = -m[4] * m[10] * m[15] + m[4] * m[11] * m[14] + m[8] * m[6] * m[15] + - m[8] * m[7] * m[14] + - m[12] * m[6] * m[11] + + m[12] * m[7] * m[10]; + inv[8] = m[4] * m[9] * m[15] - m[4] * m[11] * m[13] - m[8] * m[5] * m[15] + + m[8] * m[7] * m[13] + + m[12] * m[5] * m[11] + - m[12] * m[7] * m[9]; + inv[12] = -m[4] * m[9] * m[14] + m[4] * m[10] * m[13] + m[8] * m[5] * m[14] + - m[8] * m[6] * m[13] + - m[12] * m[5] * m[10] + + m[12] * m[6] * m[9]; + inv[1] = -m[1] * m[10] * m[15] + m[1] * m[11] * m[14] + m[9] * m[2] * m[15] + - m[9] * m[3] * m[14] + - m[13] * m[2] * m[11] + + m[13] * m[3] * m[10]; + inv[5] = m[0] * m[10] * m[15] - m[0] * m[11] * m[14] - m[8] * m[2] * m[15] + + m[8] * m[3] * m[14] + + m[12] * m[2] * m[11] + - m[12] * m[3] * m[10]; + inv[9] = -m[0] * m[9] * m[15] + m[0] * m[11] * m[13] + m[8] * m[1] * m[15] + - m[8] * m[3] * m[13] + - m[12] * m[1] * m[11] + + m[12] * m[3] * m[9]; + inv[13] = m[0] * m[9] * m[14] - m[0] * m[10] * m[13] - m[8] * m[1] * m[14] + + m[8] * m[2] * m[13] + + m[12] * m[1] * m[10] + - m[12] * m[2] * m[9]; + inv[2] = m[1] * m[6] * m[15] - m[1] * m[7] * m[14] - m[5] * m[2] * m[15] + + m[5] * m[3] * m[14] + + m[13] * m[2] * m[7] + - m[13] * m[3] * m[6]; + inv[6] = -m[0] * m[6] * m[15] + m[0] * m[7] * m[14] + m[4] * m[2] * m[15] + - m[4] * m[3] * m[14] + - m[12] * m[2] * m[7] + + m[12] * m[3] * m[6]; + inv[10] = m[0] * m[5] * m[15] - m[0] * m[7] * m[13] - m[4] * m[1] * m[15] + + m[4] * m[3] * m[13] + + m[12] * m[1] * m[7] + - m[12] * m[3] * m[5]; + inv[14] = -m[0] * m[5] * m[14] + m[0] * m[6] * m[13] + m[4] * m[1] * m[14] + - m[4] * m[2] * m[13] + - m[12] * m[1] * m[6] + + m[12] * m[2] * m[5]; + inv[3] = -m[1] * m[6] * m[11] + m[1] * m[7] * m[10] + m[5] * m[2] * m[11] + - m[5] * m[3] * m[10] + - m[9] * m[2] * m[7] + + m[9] * m[3] * m[6]; + inv[7] = m[0] * m[6] * m[11] - m[0] * m[7] * m[10] - m[4] * m[2] * m[11] + + m[4] * m[3] * m[10] + + m[8] * m[2] * m[7] + - m[8] * m[3] * m[6]; + inv[11] = -m[0] * m[5] * m[11] + m[0] * m[7] * m[9] + m[4] * m[1] * m[11] + - m[4] * m[3] * m[9] + - m[8] * m[1] * m[7] + + m[8] * m[3] * m[5]; + inv[15] = m[0] * m[5] * m[10] - m[0] * m[6] * m[9] - m[4] * m[1] * m[10] + + m[4] * m[2] * m[9] + + m[8] * m[1] * m[6] + - m[8] * m[2] * m[5]; + let det = m[0] * inv[0] + m[1] * inv[4] + m[2] * inv[8] + m[3] * inv[12]; + if det.abs() < 1e-12 { + return Mat4::IDENTITY; + } + let inv_det = 1.0 / det; + for v in inv.iter_mut() { + *v *= inv_det; + } + Mat4 { m: inv } + } + + /// Transform a point through the full matrix, dividing by w (for unproject). + pub fn project_point(&self, p: Vec3) -> Vec3 { + let m = &self.m; + let x = m[0] * p.x + m[4] * p.y + m[8] * p.z + m[12]; + let y = m[1] * p.x + m[5] * p.y + m[9] * p.z + m[13]; + let z = m[2] * p.x + m[6] * p.y + m[10] * p.z + m[14]; + let w = m[3] * p.x + m[7] * p.y + m[11] * p.z + m[15]; + let inv_w = if w.abs() > 1e-12 { 1.0 / w } else { 1.0 }; + vec3(x * inv_w, y * inv_w, z * inv_w) + } + + /// Decompose a TRS matrix into translation / rotation / scale (assumes no + /// shear; scale is per-axis basis length). Used to place a socketed mesh at + /// a posed bone via the engine's TRS `Transform`. + pub fn to_trs(&self) -> (Vec3, Quat, Vec3) { + let m = &self.m; + let t = vec3(m[12], m[13], m[14]); + let c0 = vec3(m[0], m[1], m[2]); + let c1 = vec3(m[4], m[5], m[6]); + let c2 = vec3(m[8], m[9], m[10]); + let s = vec3(c0.length(), c1.length(), c2.length()); + let r0 = if s.x > 1e-8 { c0.scale(1.0 / s.x) } else { c0 }; + let r1 = if s.y > 1e-8 { c1.scale(1.0 / s.y) } else { c1 }; + let r2 = if s.z > 1e-8 { c2.scale(1.0 / s.z) } else { c2 }; + let trace = r0.x + r1.y + r2.z; + let q = if trace > 0.0 { + let w4 = sqrtf(trace + 1.0) * 2.0; + Quat { + w: 0.25 * w4, + x: (r1.z - r2.y) / w4, + y: (r2.x - r0.z) / w4, + z: (r0.y - r1.x) / w4, + } + } else if r0.x > r1.y && r0.x > r2.z { + let s4 = sqrtf(1.0 + r0.x - r1.y - r2.z) * 2.0; + Quat { + w: (r1.z - r2.y) / s4, + x: 0.25 * s4, + y: (r1.x + r0.y) / s4, + z: (r2.x + r0.z) / s4, + } + } else if r1.y > r2.z { + let s4 = sqrtf(1.0 + r1.y - r0.x - r2.z) * 2.0; + Quat { + w: (r2.x - r0.z) / s4, + x: (r1.x + r0.y) / s4, + y: 0.25 * s4, + z: (r2.y + r1.z) / s4, + } + } else { + let s4 = sqrtf(1.0 + r2.z - r0.x - r1.y) * 2.0; + Quat { + w: (r0.y - r1.x) / s4, + x: (r2.x + r0.z) / s4, + y: (r2.y + r1.z) / s4, + z: 0.25 * s4, + } + }; + (t, q.normalize(), s) + } +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + + fn approx(a: f32, b: f32) -> bool { + (a - b).abs() < 1e-4 + } + + #[test] + fn identity_mul_is_noop() { + let t = Mat4::from_translation(vec3(1.0, 2.0, 3.0)); + let r = Mat4::IDENTITY.mul(t); + assert_eq!(r, t); + } + + #[test] + fn translation_transforms_point() { + let t = Mat4::from_translation(vec3(1.0, 2.0, 3.0)); + let p = t.transform_point(vec3(0.0, 0.0, 0.0)); + assert_eq!(p, vec3(1.0, 2.0, 3.0)); + } + + #[test] + fn yaw_90_rotates_x_to_minus_z() { + let q = Quat::from_yaw(core::f32::consts::FRAC_PI_2); + let p = Mat4::from_quat(q).transform_point(vec3(1.0, 0.0, 0.0)); + assert!( + approx(p.x, 0.0) && approx(p.y, 0.0) && approx(p.z, -1.0), + "{p:?}" + ); + } + + #[test] + fn cross_and_normalize() { + let c = vec3(1.0, 0.0, 0.0).cross(vec3(0.0, 1.0, 0.0)); + assert_eq!(c, vec3(0.0, 0.0, 1.0)); + assert!(approx(vec3(3.0, 4.0, 0.0).length(), 5.0)); + } + + #[test] + fn trs_composition_order() { + // Scale then rotate(yaw 90) then translate: a point at +x scaled by 2 + // becomes (2,0,0), rotates to (0,0,-2), translates by (10,0,0). + let m = Mat4::from_trs( + vec3(10.0, 0.0, 0.0), + Quat::from_yaw(core::f32::consts::FRAC_PI_2), + vec3(2.0, 2.0, 2.0), + ); + let p = m.transform_point(vec3(1.0, 0.0, 0.0)); + assert!( + approx(p.x, 10.0) && approx(p.y, 0.0) && approx(p.z, -2.0), + "{p:?}" + ); + } +} diff --git a/client-rust/source/engine-core/src/prefab.rs b/client-rust/source/engine-core/src/prefab.rs new file mode 100644 index 00000000..642c5e29 --- /dev/null +++ b/client-rust/source/engine-core/src/prefab.rs @@ -0,0 +1,280 @@ +//! Prefab (entity template) save/load, tailored to this repo's versioned-schema +//! JSON convention. +//! +//! A prefab is JSON shaped like the repo's other specs — a `"schema"` +//! discriminator plus payload: +//! +//! ```json +//! { "schema": "successor.prefab.v1", +//! "components": { "transform": { ... }, "mesh": { ... } } } +//! ``` +//! +//! Loading is strict and fails closed (like `parseActorArchetypes` in +//! `client/src/slice-core/actorArchetypes.ts`): an unknown component name or a +//! wrong schema is an error, not a silent skip. +//! +//! Prefab capability is opt-in per world via [`world_prefab!`], so a world may +//! contain transient/render-only components that are not serializable. Only the +//! components listed in `world_prefab!` participate in save/load. + +use alloc::string::String; + +use crate::ecs::Entity; +use crate::json::{Json, JsonWriter}; + +pub const PREFAB_SCHEMA: &str = "successor.prefab.v1"; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PrefabError { + /// Top-level shape wrong (not an object, missing `components`, …). + Malformed, + /// `schema` field absent or not equal to [`PREFAB_SCHEMA`]. + WrongSchema, + /// A component name in the prefab is not registered on this world. + UnknownComponent, + /// A registered component's `from_json` rejected its payload. + BadComponent, +} + +/// A component that can round-trip through prefab JSON. +pub trait PrefabComponent: Sized { + /// Stable field name under `"components"`. + const NAME: &'static str; + /// Parse this component from its JSON value. + fn from_json(value: &Json) -> Result<Self, PrefabError>; + /// Emit exactly one JSON value for this component (the value after the key). + fn to_json(&self, w: &mut JsonWriter); + /// Return false to omit this component when saving (e.g. runtime-derived). + fn save_enabled(&self) -> bool { + true + } +} + +/// Implemented by the `world_prefab!` macro. Bridges prefab JSON and the ECS. +pub trait WorldPrefab { + fn apply_components(&mut self, entity: Entity, components: &Json) -> Result<(), PrefabError>; + fn write_components(&mut self, entity: Entity, w: &mut JsonWriter); + fn flush_world(&mut self); +} + +/// Spawn an entity from an already-decoded prefab `Json`. Validates the schema, +/// then applies its components strictly. On failure the partially-built entity +/// is destroyed so the world is not left with a half-applied template. +pub fn create_entity_from_json<W>(world: &mut W, prefab: &Json) -> Result<Entity, PrefabError> +where + W: WorldPrefab + crate::ecs::WorldOps, +{ + match prefab.get("schema").and_then(Json::as_str) { + Some(s) if s == PREFAB_SCHEMA => {} + _ => return Err(PrefabError::WrongSchema), + } + let components = prefab.get("components").ok_or(PrefabError::Malformed)?; + if components.as_object().is_none() { + return Err(PrefabError::Malformed); + } + let entity = world.spawn(); + if let Err(e) = world.apply_components(entity, components) { + world.destroy(entity); + world.flush_world(); + return Err(e); + } + Ok(entity) +} + +/// Serialize an entity's prefab-capable components to a prefab JSON string. +pub fn save_entity_alloc<W>(world: &mut W, entity: Entity) -> String +where + W: WorldPrefab, +{ + let mut w = JsonWriter::new(); + w.begin_obj(); + w.field_str("schema", PREFAB_SCHEMA); + w.key("components"); + w.begin_obj(); + world.write_components(entity, &mut w); + w.end_obj(); + w.end_obj(); + w.into_string() +} + +/// Generate `impl WorldPrefab` for a world, listing the prefab-capable +/// components (a subset of those in the matching `world!`). Each listed type +/// must implement [`PrefabComponent`] and be a component of the world. +/// +/// ```ignore +/// successor_engine_core::world_prefab! { GameWorld { +/// transform: Transform, +/// mesh: MeshRenderer, +/// } } +/// ``` +#[macro_export] +macro_rules! world_prefab { + ($W:ident { $($field:ident : $C:ty),+ $(,)? }) => { + impl $crate::prefab::WorldPrefab for $W { + fn flush_world(&mut self) { + $W::flush(self); + } + + fn apply_components( + &mut self, + entity: $crate::ecs::Entity, + components: &$crate::json::Json, + ) -> Result<(), $crate::prefab::PrefabError> { + let fields = components + .as_object() + .ok_or($crate::prefab::PrefabError::Malformed)?; + // Strict: reject any unknown component name before applying. + for (key, _) in fields { + let known = false + $( || key.as_str() == <$C as $crate::prefab::PrefabComponent>::NAME )+; + if !known { + return Err($crate::prefab::PrefabError::UnknownComponent); + } + } + $( + if let Some(v) = components.get(<$C as $crate::prefab::PrefabComponent>::NAME) { + let component = <$C as $crate::prefab::PrefabComponent>::from_json(v)?; + $crate::ecs::WorldOps::set_component::<$C>(self, entity, component); + } + )+ + Ok(()) + } + + fn write_components( + &mut self, + entity: $crate::ecs::Entity, + w: &mut $crate::json::JsonWriter, + ) { + $( + if let Some(c) = + $crate::ecs::WorldOps::get_component::<$C>(self, entity) + { + if $crate::prefab::PrefabComponent::save_enabled(&*c) { + w.key(<$C as $crate::prefab::PrefabComponent>::NAME); + $crate::prefab::PrefabComponent::to_json(&*c, w); + } + } + )+ + } + } + }; +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + use crate::ecs::WorldOps; + use crate::{impl_component, world}; + + #[derive(Clone, Copy, PartialEq, Debug)] + struct Transform { + x: f32, + y: f32, + } + #[derive(Clone, Copy, PartialEq, Debug)] + struct Health(i64); + // A render-only component deliberately left out of the prefab set. + #[derive(Clone, Copy, PartialEq, Debug)] + struct Scratch(u32); + + impl_component!(Transform: dense); + impl_component!(Health: sparse); + impl_component!(Scratch: dense); + + impl PrefabComponent for Transform { + const NAME: &'static str = "transform"; + fn from_json(v: &Json) -> Result<Self, PrefabError> { + Ok(Transform { + x: v.get("x") + .and_then(Json::as_f32) + .ok_or(PrefabError::BadComponent)?, + y: v.get("y") + .and_then(Json::as_f32) + .ok_or(PrefabError::BadComponent)?, + }) + } + fn to_json(&self, w: &mut JsonWriter) { + w.begin_obj(); + w.field_f32("x", self.x); + w.field_f32("y", self.y); + w.end_obj(); + } + } + + impl PrefabComponent for Health { + const NAME: &'static str = "health"; + fn from_json(v: &Json) -> Result<Self, PrefabError> { + Ok(Health(v.as_i64().ok_or(PrefabError::BadComponent)?)) + } + fn to_json(&self, w: &mut JsonWriter) { + w.value_i64(self.0); + } + } + + world! { pub struct GameWorld { transform: Transform, health: Health, scratch: Scratch } } + world_prefab! { GameWorld { transform: Transform, health: Health } } + + #[test] + fn create_from_prefab_json() { + let prefab = Json::parse( + r#"{ "schema": "successor.prefab.v1", + "components": { "transform": { "x": 1.5, "y": -2.0 }, "health": 42 } }"#, + ) + .unwrap(); + let mut w = GameWorld::new(); + let e = create_entity_from_json(&mut w, &prefab).unwrap(); + assert_eq!( + w.get_component::<Transform>(e), + Some(&mut Transform { x: 1.5, y: -2.0 }) + ); + assert_eq!(w.get_component::<Health>(e), Some(&mut Health(42))); + } + + #[test] + fn unknown_component_rejected() { + let prefab = + Json::parse(r#"{ "schema": "successor.prefab.v1", "components": { "bogus": {} } }"#) + .unwrap(); + let mut w = GameWorld::new(); + assert_eq!( + create_entity_from_json(&mut w, &prefab), + Err(PrefabError::UnknownComponent) + ); + assert_eq!(w.entity_count(), 0, "failed prefab leaves no entity"); + } + + #[test] + fn wrong_schema_rejected() { + let prefab = Json::parse(r#"{ "schema": "other.v1", "components": {} }"#).unwrap(); + let mut w = GameWorld::new(); + assert_eq!( + create_entity_from_json(&mut w, &prefab), + Err(PrefabError::WrongSchema) + ); + } + + #[test] + fn save_then_load_roundtrips() { + let mut w = GameWorld::new(); + let e = w.spawn(); + w.set_component(e, Transform { x: 3.25, y: 0.5 }); + w.set_component(e, Health(7)); + w.set_component(e, Scratch(999)); // not serialized + let json_str = save_entity_alloc(&mut w, e); + + let prefab = Json::parse(&json_str).unwrap(); + // Scratch must NOT appear in the prefab. + assert!(prefab + .get("components") + .and_then(|c| c.get("scratch")) + .is_none()); + let mut w2 = GameWorld::new(); + let e2 = create_entity_from_json(&mut w2, &prefab).unwrap(); + assert_eq!( + w2.get_component::<Transform>(e2), + Some(&mut Transform { x: 3.25, y: 0.5 }) + ); + assert_eq!(w2.get_component::<Health>(e2), Some(&mut Health(7))); + assert!(!w2.has_component::<Scratch>(e2)); + } +} diff --git a/client-rust/source/engine-core/src/rt/alloc.rs b/client-rust/source/engine-core/src/rt/alloc.rs new file mode 100644 index 00000000..fdeb8c13 --- /dev/null +++ b/client-rust/source/engine-core/src/rt/alloc.rs @@ -0,0 +1,65 @@ +//! Allocation counter. When the `alloc-count` feature is on, the app installs +//! [`CountingAllocator`] as the `#[global_allocator]`; the demo resets the +//! counter each frame start and reads it at frame end to prove zero +//! steady-state per-frame allocations. With the feature off, the counter API +//! is a zero-cost no-op so app/demo code compiles unconditionally. + +use core::sync::atomic::{AtomicUsize, Ordering}; + +static ALLOC_COUNT: AtomicUsize = AtomicUsize::new(0); + +/// Total live allocation calls since the last [`reset_alloc_count`]. +#[inline] +pub fn alloc_count() -> u64 { + ALLOC_COUNT.load(Ordering::Relaxed) as u64 +} +/// Reset the per-frame counter (call at frame start). +#[inline] +pub fn reset_alloc_count() { + ALLOC_COUNT.store(0, Ordering::Relaxed); +} + +/// Record one allocation. Called by [`CountingAllocator::alloc`] when the +/// feature is enabled; exposed so tests can exercise the counter without a +/// global allocator swap. +#[inline] +pub fn record_alloc() { + ALLOC_COUNT.fetch_add(1, Ordering::Relaxed); +} + +#[cfg(feature = "alloc-count")] +mod counting { + use super::record_alloc; + use core::alloc::{GlobalAlloc, Layout}; + + /// Global-allocator wrapper that counts `alloc`/`realloc` calls. Install in + /// the app with `#[global_allocator] static A: CountingAllocator<System> = …`. + pub struct CountingAllocator<A> { + pub inner: A, + } + + impl<A> CountingAllocator<A> { + pub const fn new(inner: A) -> Self { + Self { inner } + } + } + + // SAFETY: forwards verbatim to the wrapped allocator; only adds a relaxed + // atomic increment on the allocating paths. + unsafe impl<A: GlobalAlloc> GlobalAlloc for CountingAllocator<A> { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + record_alloc(); + self.inner.alloc(layout) + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + self.inner.dealloc(ptr, layout); + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + record_alloc(); + self.inner.realloc(ptr, layout, new_size) + } + } +} + +#[cfg(feature = "alloc-count")] +pub use counting::CountingAllocator; diff --git a/client-rust/source/engine-core/src/rt/cell.rs b/client-rust/source/engine-core/src/rt/cell.rs new file mode 100644 index 00000000..bfc3a134 --- /dev/null +++ b/client-rust/source/engine-core/src/rt/cell.rs @@ -0,0 +1,53 @@ +//! `GlobalCell<T>` — a single-threaded lazily-initialized global slot. +//! +//! The client runs one frame loop on one thread on every target (no async, no +//! worker threads touch engine state), so a non-atomic cell is sound. Marked +//! `Sync` to allow use in a `static`; callers must not share across threads. + +use core::cell::UnsafeCell; + +pub struct GlobalCell<T> { + slot: UnsafeCell<Option<T>>, +} + +// SAFETY: single-threaded access only (documented invariant of the frame loop). +unsafe impl<T> Sync for GlobalCell<T> {} + +impl<T> GlobalCell<T> { + pub const fn new() -> Self { + Self { + slot: UnsafeCell::new(None), + } + } + + /// Install the value. Overwrites any previous one. + pub fn set(&self, value: T) { + // SAFETY: single-threaded; no outstanding borrow from `get_mut`/`with`. + unsafe { + *self.slot.get() = Some(value); + } + } + + pub fn is_set(&self) -> bool { + // SAFETY: single-threaded read. + unsafe { (*self.slot.get()).is_some() } + } + + /// Mutable access to the stored value, if set. + #[allow(clippy::mut_from_ref)] + pub fn get_mut(&self) -> Option<&mut T> { + // SAFETY: single-threaded; caller holds no other borrow concurrently. + unsafe { (*self.slot.get()).as_mut() } + } + + /// Run `f` with a mutable borrow if the value is set. + pub fn with<R>(&self, f: impl FnOnce(&mut T) -> R) -> Option<R> { + self.get_mut().map(f) + } +} + +impl<T> Default for GlobalCell<T> { + fn default() -> Self { + Self::new() + } +} diff --git a/client-rust/source/engine-core/src/rt/log.rs b/client-rust/source/engine-core/src/rt/log.rs new file mode 100644 index 00000000..14eabb27 --- /dev/null +++ b/client-rust/source/engine-core/src/rt/log.rs @@ -0,0 +1,73 @@ +//! `no_std` logging with no `core::fmt`. +//! +//! The platform installs a `fn(&str)` sink at startup (`stderr` on native, +//! `console.log` on web). Numeric helpers format into a stack buffer without +//! pulling in the `core::fmt` machinery the size gate forbids. + +use super::cell::GlobalCell; + +static SINK: GlobalCell<fn(&str)> = GlobalCell::new(); + +/// Install the platform log sink. +pub fn set_sink(f: fn(&str)) { + SINK.set(f); +} + +/// Emit a raw string line (no newline appended by the engine; the sink decides). +pub fn log_str(s: &str) { + if let Some(f) = SINK.get_mut() { + (*f)(s); + } +} + +/// `label` then an unsigned integer, e.g. `log1u("frame-allocs ", 0)`. +pub fn log1u(label: &str, n: u64) { + log_str(label); + let mut buf = [0u8; 20]; + log_str(u64_to_str(n, &mut buf)); +} + +/// `label` then a signed integer. +pub fn log1i(label: &str, n: i64) { + log_str(label); + if n < 0 { + log_str("-"); + let mut buf = [0u8; 20]; + log_str(u64_to_str(n.unsigned_abs(), &mut buf)); + } else { + let mut buf = [0u8; 20]; + log_str(u64_to_str(n as u64, &mut buf)); + } +} + +/// Format an unsigned integer into `buf`, returning the populated suffix. +pub fn u64_to_str(mut n: u64, buf: &mut [u8; 20]) -> &str { + if n == 0 { + buf[0] = b'0'; + // SAFETY: single ASCII digit. + return core::str::from_utf8(&buf[..1]).unwrap(); + } + let mut i = buf.len(); + while n > 0 { + i -= 1; + buf[i] = b'0' + (n % 10) as u8; + n /= 10; + } + // SAFETY: bytes written are ASCII digits. + core::str::from_utf8(&buf[i..]).unwrap() +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + + #[test] + fn u64_formatting() { + let mut b = [0u8; 20]; + assert_eq!(u64_to_str(0, &mut b), "0"); + let mut b = [0u8; 20]; + assert_eq!(u64_to_str(1234567890, &mut b), "1234567890"); + let mut b = [0u8; 20]; + assert_eq!(u64_to_str(u64::MAX, &mut b), "18446744073709551615"); + } +} diff --git a/client-rust/source/engine-core/src/rt/mod.rs b/client-rust/source/engine-core/src/rt/mod.rs new file mode 100644 index 00000000..5d18afb0 --- /dev/null +++ b/client-rust/source/engine-core/src/rt/mod.rs @@ -0,0 +1,7 @@ +//! Runtime shims: allocation counter, numeric logging, a single-threaded global +//! cell, and an optional bare-metal panic handler. All `no_std`-safe. + +pub mod alloc; +pub mod cell; +pub mod log; +pub mod panic; diff --git a/client-rust/source/engine-core/src/rt/panic.rs b/client-rust/source/engine-core/src/rt/panic.rs new file mode 100644 index 00000000..bc723e77 --- /dev/null +++ b/client-rust/source/engine-core/src/rt/panic.rs @@ -0,0 +1,17 @@ +//! Optional bare-metal panic handler. +//! +//! Our shipping artifacts are `std` at the app layer (native binary and the +//! wasm cdylib), so `std` supplies the panic handler and this file contributes +//! nothing to them. It exists for a future genuinely-`no_std` embedding (e.g. a +//! bare-metal `thumbv7em` *binary*), enabled by the off-by-default +//! `panic-handler` feature. The `nostd` build gate compiles the engine crates +//! as libraries, which need no handler, so that gate never enables this. + +#[cfg(all(not(feature = "std"), feature = "panic-handler"))] +#[panic_handler] +fn panic(_info: &core::panic::PanicInfo) -> ! { + // No unwinding in no_std; spin. A real embedder would reset or trap. + loop { + core::hint::spin_loop(); + } +} diff --git a/client-rust/source/engine-render/Cargo.toml b/client-rust/source/engine-render/Cargo.toml new file mode 100644 index 00000000..ec08db0b --- /dev/null +++ b/client-rust/source/engine-render/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "successor-engine-render" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "no_std render graph (Gpu trait, multi-camera, RTT, shadows, dither) for the Successor Rust client." + +[dependencies] +successor-engine-core.workspace = true +libm.workspace = true + +[features] +default = [] +std = ["successor-engine-core/std"] +alloc-count = ["successor-engine-core/alloc-count"] + +[dev-dependencies] +criterion = "0.5" + +[[bench]] +name = "engine" +harness = false +required-features = ["std"] diff --git a/client-rust/source/engine-render/benches/engine.rs b/client-rust/source/engine-render/benches/engine.rs new file mode 100644 index 00000000..93f4db0d --- /dev/null +++ b/client-rust/source/engine-render/benches/engine.rs @@ -0,0 +1,176 @@ +//! Criterion microbenches for the CPU paths that back the perf budget: ECS +//! iteration/spawn, matrix math, and render draw-list building (via `NullGpu`, +//! so no GL/window). Requires `--features std`. Never enabled for game builds. +//! Never initializes the platform layer. + +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; + +use successor_engine_core::ecs::WorldOps; +use successor_engine_core::math::{vec3, Mat4, Quat, Vec2, Vec3}; +use successor_engine_core::{impl_component, world}; +use successor_engine_render::components::*; +use successor_engine_render::gpu::NullGpu; +use successor_engine_render::renderer::{Renderer, RendererLimits}; + +#[derive(Clone, Copy)] +#[allow(dead_code)] +struct Pos(f32, f32, f32); +#[derive(Clone, Copy)] +#[allow(dead_code)] +struct Vel(f32, f32, f32); +impl_component!(Pos: dense); +impl_component!(Vel: dense); +world! { pub struct EcsWorld { pos: Pos, vel: Vel } } + +fn bench_ecs(c: &mut Criterion) { + let n = 4096u64; + c.bench_function("ecs/spawn-set/4096", |b| { + b.iter(|| { + let mut w = EcsWorld::new(); + for i in 0..n { + let e = w.spawn(); + w.set_component(e, Pos(i as f32, 0.0, 0.0)); + } + core::hint::black_box(w.entity_count()) + }) + }); + + let mut w = EcsWorld::new(); + for i in 0..n { + let e = w.spawn(); + w.set_component(e, Pos(i as f32, 0.0, 0.0)); + if i % 2 == 0 { + w.set_component(e, Vel(1.0, 0.0, 0.0)); + } + } + c.bench_function("ecs/query1/4096", |b| { + b.iter(|| { + let mut sum = 0.0f32; + let mut q = w.query1::<Pos>(); + while let Some((_, p)) = q.next() { + sum += p.0; + } + core::hint::black_box(sum) + }) + }); + c.bench_function("ecs/query2/4096", |b| { + b.iter(|| { + let mut sum = 0.0f32; + let mut q = w.query2::<Vel, Pos>(); + while let Some((_, v, p)) = q.next() { + sum += v.0 + p.0; + } + core::hint::black_box(sum) + }) + }); +} + +fn bench_math(c: &mut Criterion) { + let a = Mat4::from_trs(vec3(1.0, 2.0, 3.0), Quat::from_yaw(0.5), Vec3::ONE); + let b = Mat4::perspective(1.1, 1.7, 0.1, 100.0); + c.bench_with_input( + BenchmarkId::new("math/mat4-mul", 1024), + &1024, + |bn, &count| { + bn.iter(|| { + let mut m = a; + for _ in 0..count { + m = m.mul(b); + } + core::hint::black_box(m.m[0]) + }) + }, + ); +} + +world! { pub struct RWorld { + transform: Transform, + mesh: MeshRenderer, + cutaway: HeightCutaway, + camera: Camera, + light: DirectionalLight, + point_light: PointLight, + composite: CompositeQuad, + text: TextOverlay, +} } + +fn bench_render(c: &mut Criterion) { + let mut gpu = NullGpu::default(); + let mut r = + Renderer::new(&mut gpu, RendererLimits::default()).expect("renderer initialization failed"); + let (v, i) = successor_engine_render::primitives::cube(); + let mesh = r.upload_mesh(&mut gpu, &v, &i); + let mat = r.add_material_desc(successor_engine_render::renderer::MaterialDesc { + base_color: [0.7, 0.7, 0.7, 1.0], + blend: false, + ..successor_engine_render::renderer::MaterialDesc::default() + }); + + let mut w = RWorld::new(); + let l = w.spawn(); + w.set_component( + l, + DirectionalLight { + dir: vec3(-0.4, -1.0, -0.3), + color: [1.0; 3], + cast_shadows: true, + }, + ); + let cam = w.spawn(); + w.set_component( + cam, + Camera { + viewport_id: 0, + order: 0, + projection: Projection::Perspective { + fovy: 1.1, + near: 0.1, + far: 300.0, + }, + target: CamTarget::Screen(RectNorm::FULL), + clear: Default::default(), + eye: vec3(0.0, 40.0, 60.0), + look_at: Vec3::ZERO, + up: Vec3::Y, + }, + ); + let side = 64; + for x in 0..side { + for z in 0..side { + let e = w.spawn(); + w.set_component( + e, + Transform { + pos: vec3(x as f32, 0.0, z as f32), + rot: Quat::IDENTITY, + scale: Vec3::ONE, + }, + ); + w.set_component( + e, + MeshRenderer { + mesh, + material: mat, + viewport_mask: 0b1, + ..Default::default() + }, + ); + } + } + let t = w.spawn(); + w.set_component( + t, + TextOverlay::new("frame p50", Vec2 { x: 0.02, y: 0.04 }, [255; 4]), + ); + + c.bench_function("render/build-drawlist/4096", |b| { + b.iter(|| { + r.render(&mut gpu, &mut w, 1280, 720) + .expect("render failed"); + core::hint::black_box(&r as *const _) + }) + }); +} + +criterion_group!(benches, bench_ecs, bench_math, bench_render); +criterion_main!(benches); diff --git a/client-rust/source/engine-render/src/components.rs b/client-rust/source/engine-render/src/components.rs new file mode 100644 index 00000000..5549dcad --- /dev/null +++ b/client-rust/source/engine-render/src/components.rs @@ -0,0 +1,295 @@ +//! Renderer state expressed as ECS components. +//! +//! Every drawable/camera/light/overlay is an entity component, so the renderer +//! is "just systems over the world". The user-facing rule: an entity renders in +//! camera *C* iff its `viewport_mask` has bit `C.viewport_id` set — one entity +//! can appear in many viewports. +//! +//! Prefab-serializable components (`Transform`, `ModelRef`) carry asset *keys* +//! (strings); the projection layer resolves a `ModelRef` key to a concrete +//! `MeshRenderer` via `assets::AssetManifest` + the `Gpu`, mirroring how +//! `client-3d`'s `props.ts` keeps an `assetKey` and instances it lazily. + +use alloc::string::{String, ToString}; + +use successor_engine_core::json::{Json, JsonWriter}; +use successor_engine_core::math::{Quat, Vec2, Vec3}; +use successor_engine_core::prefab::{PrefabComponent, PrefabError}; +use successor_engine_core::{impl_component, math}; + +use crate::gpu::{ClearSpec, RenderTargetId}; + +/// Index into the renderer's mesh table (procedural or GLB-derived). +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub struct MeshId(pub u32); + +/// Index into the renderer's material table. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub struct MaterialId(pub u32); + +/// World transform (dense — most entities have one). +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct Transform { + pub pos: Vec3, + pub rot: Quat, + pub scale: Vec3, +} + +impl Default for Transform { + fn default() -> Self { + Self { + pos: Vec3::ZERO, + rot: Quat::IDENTITY, + scale: Vec3::ONE, + } + } +} + +/// Prefab-authored model reference by stable asset key. Resolved into a +/// `MeshRenderer` by the projection layer. +#[derive(Clone, PartialEq, Debug)] +pub struct ModelRef { + pub key: String, + pub viewport_mask: u32, +} + +/// GPU-skinning binding: `count` joint matrices starting at `offset` in the +/// renderer's per-frame skin palette arena. `count == 0` means a static mesh. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub struct SkinRef { + pub offset: u32, + pub count: u32, +} + +impl SkinRef { + pub const NONE: SkinRef = SkinRef { + offset: 0, + count: 0, + }; + pub fn is_skinned(&self) -> bool { + self.count > 0 + } +} + +/// Resolved drawable (runtime; not prefab-serialized). +#[derive(Clone, Copy, PartialEq, Debug, Default)] +pub struct MeshRenderer { + pub mesh: MeshId, + pub material: MaterialId, + /// Bit *i* set => visible in the camera whose `viewport_id == i`. + pub viewport_mask: u32, + /// Skinning palette binding; `SkinRef::NONE` for static meshes. + pub skin: SkinRef, +} +/// World-height presentation cutaway. Geometry below `cutoff_y` remains; +/// fragments above it are dithered by `amount`. +#[derive(Clone, Copy, PartialEq, Debug, Default)] +pub struct HeightCutaway { + pub cutoff_y: f32, + pub amount: f32, +} + +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum Projection { + Perspective { + fovy: f32, + near: f32, + far: f32, + }, + /// Orthographic half-height in world units (width derived from aspect). + Ortho { + half_height: f32, + near: f32, + far: f32, + }, +} + +/// Normalized screen rectangle in [0,1], origin bottom-left. +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct RectNorm { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +impl RectNorm { + pub const FULL: RectNorm = RectNorm { + x: 0.0, + y: 0.0, + w: 1.0, + h: 1.0, + }; +} + +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum CamTarget { + /// Render to (a sub-rectangle of) the screen. + Screen(RectNorm), + /// Render to an offscreen color+depth target (composited later). + Texture(RenderTargetId), +} + +/// A camera entity (sparse — few cameras). +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct Camera { + pub viewport_id: u8, + /// Lower renders first; composite/screen order follows this. + pub order: i16, + pub projection: Projection, + pub target: CamTarget, + pub clear: ClearSpec, + pub eye: Vec3, + pub look_at: Vec3, + pub up: Vec3, +} + +/// A directional light (sparse). The first shadow-casting light drives the +/// single shadow map. +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct DirectionalLight { + pub dir: Vec3, + pub color: [f32; 3], + pub cast_shadows: bool, +} + +/// A local point light (sparse). Position comes from the entity's `Transform`. +/// Consumed by the deferred point-light volume pass (additive into the scene +/// HDR target). No shadows. +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct PointLight { + pub color: [f32; 3], + pub intensity: f32, + pub radius: f32, +} + +/// Draws a render target's color texture onto the screen (RTT compositing). +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct CompositeQuad { + pub source: RenderTargetId, + pub rect: RectNorm, + pub order: i16, +} + +/// A screen-space text line (sparse). Fixed inline buffer => no per-overlay heap. +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct TextOverlay { + pub text: [u8; 128], + pub len: u8, + /// Top-left position in normalized screen coords [0,1]. + pub pos: Vec2, + pub rgba: [u8; 4], +} + +impl TextOverlay { + pub fn new(s: &str, pos: Vec2, rgba: [u8; 4]) -> Self { + let mut text = [0u8; 128]; + let bytes = s.as_bytes(); + let len = bytes.len().min(128); + text[..len].copy_from_slice(&bytes[..len]); + Self { + text, + len: len as u8, + pos, + rgba, + } + } + + pub fn as_str(&self) -> &str { + core::str::from_utf8(&self.text[..self.len as usize]).unwrap_or("") + } +} + +impl_component!(Transform: dense); +impl_component!(ModelRef: sparse); +impl_component!(MeshRenderer: dense); +impl_component!(HeightCutaway: sparse); +impl_component!(Camera: sparse); +impl_component!(DirectionalLight: sparse); +impl_component!(PointLight: sparse); +impl_component!(CompositeQuad: sparse); +impl_component!(TextOverlay: sparse); + +// --- Prefab support (asset-key carrying components) -------------------------- + +fn read_vec3(v: &Json, fallback: Vec3) -> Vec3 { + match v.as_array() { + Some(a) if a.len() == 3 => math::vec3( + a[0].as_f32().unwrap_or(fallback.x), + a[1].as_f32().unwrap_or(fallback.y), + a[2].as_f32().unwrap_or(fallback.z), + ), + _ => fallback, + } +} + +fn write_vec3(w: &mut JsonWriter, v: Vec3) { + w.begin_array(); + w.value_f32(v.x); + w.value_f32(v.y); + w.value_f32(v.z); + w.end_array(); +} + +impl PrefabComponent for Transform { + const NAME: &'static str = "transform"; + + fn from_json(v: &Json) -> Result<Self, PrefabError> { + let pos = v + .get("pos") + .map(|p| read_vec3(p, Vec3::ZERO)) + .unwrap_or(Vec3::ZERO); + let scale = v + .get("scale") + .map(|s| read_vec3(s, Vec3::ONE)) + .unwrap_or(Vec3::ONE); + let rot = match v.get("rot").and_then(Json::as_array) { + Some(a) if a.len() == 4 => Quat { + x: a[0].as_f32().unwrap_or(0.0), + y: a[1].as_f32().unwrap_or(0.0), + z: a[2].as_f32().unwrap_or(0.0), + w: a[3].as_f32().unwrap_or(1.0), + } + .normalize(), + _ => Quat::IDENTITY, + }; + Ok(Transform { pos, rot, scale }) + } + + fn to_json(&self, w: &mut JsonWriter) { + w.begin_obj(); + w.key("pos"); + write_vec3(w, self.pos); + w.key("rot"); + w.begin_array(); + w.value_f32(self.rot.x); + w.value_f32(self.rot.y); + w.value_f32(self.rot.z); + w.value_f32(self.rot.w); + w.end_array(); + w.key("scale"); + write_vec3(w, self.scale); + w.end_obj(); + } +} + +impl PrefabComponent for ModelRef { + const NAME: &'static str = "model"; + + fn from_json(v: &Json) -> Result<Self, PrefabError> { + let key = v + .get("key") + .and_then(Json::as_str) + .ok_or(PrefabError::BadComponent)? + .to_string(); + let viewport_mask = v.get("viewportMask").and_then(Json::as_i64).unwrap_or(1) as u32; + Ok(ModelRef { key, viewport_mask }) + } + + fn to_json(&self, w: &mut JsonWriter) { + w.begin_obj(); + w.field_str("key", &self.key); + w.field_i64("viewportMask", self.viewport_mask as i64); + w.end_obj(); + } +} diff --git a/client-rust/source/engine-render/src/cursor.rs b/client-rust/source/engine-render/src/cursor.rs new file mode 100644 index 00000000..41109d4a --- /dev/null +++ b/client-rust/source/engine-render/src/cursor.rs @@ -0,0 +1,473 @@ +//! Client-drawn mouse cursor. +//! +//! The original client never let the desktop cursor into the game: it hid the +//! OS pointer, drew its own from a `UICursorSet`, and clipped the hardware +//! cursor to the client rect (`Graphics::constrainMouseCursor` → +//! `ClipCursor`). The set is a vocabulary, not one bitmap — default, selection, +//! attack, activate, resize on four axes, hourglass, drag-bad and friends — +//! and the mediator under the pointer picks the entry. +//! +//! Successor draws that vocabulary instead of shipping bitmaps: every kind here +//! is built from the same primitives the window chrome uses, so the pointer is +//! made of the same material as the frames it sits on (translucent inset pane, +//! one bright perimeter, the window's own drop shadow) and it stays crisp at +//! any framebuffer without an atlas cell per resolution. +//! +//! Geometry is authored in a 16 px design space with the hotspot at the origin +//! and scaled by [`CursorStyle::size`]. Nothing here allocates. + +use crate::ui::UiBuilder; + +/// Design-space extent the shapes below are authored against. +const DESIGN: f32 = 16.0; + +/// Classic arrow silhouette, hotspot first. Every other vertex is visible from +/// the tip, so a triangle fan from `[0]` is a valid fill. +const ARROW: [(f32, f32); 7] = [ + (0.0, 0.0), + (0.0, 13.6), + (3.5, 10.2), + (5.9, 16.0), + (8.5, 14.8), + (6.1, 9.2), + (10.6, 8.6), +]; + +/// Unit component of a 45-degree axis. +const DIAG: f32 = core::f32::consts::FRAC_1_SQRT_2; + +/// What the pointer is over. Named for the original cursor set entries so the +/// mapping to `ui_cursor_*` behaviour stays legible. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum CursorKind { + /// `ui_cursor_default`. + #[default] + Arrow, + /// `ui_cursor_selection` — over something that answers a click. + Select, + /// `ui_cursor_attack` — over a valid hostile target. + Attack, + /// `ui_cursor_activate` — over a door, terminal, or other world verb. + Interact, + /// `ui_cursor_move` — a frame is being dragged. + Move, + /// `ui_cursor_resize_hor`. + ResizeHorizontal, + /// `ui_cursor_resize_vert`. + ResizeVertical, + /// `ui_cursor_resize_se` — north-west/south-east corner. + ResizeNwSe, + /// `ui_cursor_resize_sw` — north-east/south-west corner. + ResizeNeSw, + /// Text entry caret. + Text, + /// `ui_cursor_hourglass`. + Busy, + /// `ui_cursor_drag_bad` — the gesture under the pointer is refused. + Blocked, +} + +impl CursorKind { + /// Resize cursor for an edge mask, or `None` when no edge is engaged. + /// A corner wins over a side, which is what the original's hit order does. + pub fn for_edges(left: bool, right: bool, top: bool, bottom: bool) -> Option<Self> { + match (left, right, top, bottom) { + (true, _, true, _) | (_, true, _, true) => Some(Self::ResizeNwSe), + (_, true, true, _) | (true, _, _, true) => Some(Self::ResizeNeSw), + (true, _, _, _) | (_, true, _, _) => Some(Self::ResizeHorizontal), + (_, _, true, _) | (_, _, _, true) => Some(Self::ResizeVertical), + _ => None, + } + } +} + +/// Ink for the pointer. Defaults are the window chrome's own tones so the +/// cursor reads as part of the frame set rather than an overlay. +#[derive(Clone, Copy, Debug)] +pub struct CursorStyle { + /// Body fill. Bright, because a pointer has to stay legible over pale + /// desert and over a dark pane, and an outline-dominant glyph reads as + /// mush at cursor size. + pub fill: [u8; 4], + /// Border. The caption ink: dark enough to hold the silhouette against + /// any background without introducing a colour the UI does not already use. + pub edge: [u8; 4], + /// State glyphs — reticle, brackets, arrowheads. + pub accent: [u8; 4], + /// Drop shadow, matching the window frame's. + pub shadow: [u8; 4], + /// Refusal tone for [`CursorKind::Blocked`]. + pub danger: [u8; 4], + /// Height of the arrow in screen pixels. + pub size: f32, +} + +impl Default for CursorStyle { + fn default() -> Self { + Self { + fill: [0x1C, 0xFF, 0xFF, 242], + edge: [0x00, 0x35, 0x4F, 255], + accent: [0x20, 0xE0, 0xF0, 255], + shadow: [0, 0, 0, 110], + danger: [0xFF, 0x4C, 0x3A, 240], + size: 21.0, + } + } +} + +/// Draw `kind` with its hotspot at `(x, y)`. +/// +/// `phase_ms` only drives [`CursorKind::Busy`]; every other kind is static, so +/// a caller that never shows a busy pointer can pass zero. +pub fn draw( + ui: &mut UiBuilder, + kind: CursorKind, + x: f32, + y: f32, + style: CursorStyle, + phase_ms: u64, +) { + let s = style.size / DESIGN; + match kind { + CursorKind::Arrow => arrow(ui, x, y, s, style), + CursorKind::Select => { + arrow(ui, x, y, s, style); + // A small open reticle off the tail: the original's selection + // cursor is the arrow plus a target box, not a different pointer. + let (bx, by) = (x + 9.0 * s, y + 9.0 * s); + let side = 7.0 * s; + corner_ticks(ui, [bx, by, side, side], 2.4 * s, 1.2 * s, style.accent); + } + CursorKind::Attack => reticle(ui, x, y, s, style), + CursorKind::Interact => { + arrow(ui, x, y, s, style); + chevron( + ui, + x + 10.0 * s, + y + 10.0 * s, + 4.5 * s, + 1.5 * s, + style.accent, + ); + } + CursorKind::Move => four_way(ui, x, y, s, style), + CursorKind::ResizeHorizontal => double_arrow(ui, x, y, 1.0, 0.0, s, style), + CursorKind::ResizeVertical => double_arrow(ui, x, y, 0.0, 1.0, s, style), + CursorKind::ResizeNwSe => double_arrow(ui, x, y, DIAG, DIAG, s, style), + CursorKind::ResizeNeSw => double_arrow(ui, x, y, DIAG, -DIAG, s, style), + CursorKind::Text => beam(ui, x, y, s, style), + CursorKind::Busy => busy(ui, x, y, s, style, phase_ms), + CursorKind::Blocked => { + arrow(ui, x, y, s, style); + let (cx, cy) = (x + 11.0 * s, y + 11.0 * s); + let r = 5.0 * s; + ui.ring(cx, cy, r, 12, 1.4 * s, style.danger); + let d = r * DIAG; + ui.line(cx - d, cy - d, cx + d, cy + d, 1.4 * s, style.danger); + } + } +} + +/// Filled polygon as a triangle fan from vertex zero. Valid only for shapes +/// that are star-shaped about that vertex, which [`ARROW`] is. +fn fan(ui: &mut UiBuilder, points: &[(f32, f32)], ox: f32, oy: f32, s: f32, rgba: [u8; 4]) { + let at = |index: usize| { + let (px, py) = points[index]; + (ox + px * s, oy + py * s) + }; + let a = at(0); + for index in 1..points.len() - 1 { + ui.tri(a, at(index), at(index + 1), rgba); + } +} + +fn outline( + ui: &mut UiBuilder, + points: &[(f32, f32)], + ox: f32, + oy: f32, + s: f32, + w: f32, + rgba: [u8; 4], +) { + for index in 0..points.len() { + let (x0, y0) = points[index]; + let (x1, y1) = points[(index + 1) % points.len()]; + ui.line(ox + x0 * s, oy + y0 * s, ox + x1 * s, oy + y1 * s, w, rgba); + } +} + +fn arrow(ui: &mut UiBuilder, x: f32, y: f32, s: f32, style: CursorStyle) { + // The frame's own shadow offset, scaled, so the pointer keeps a silhouette + // over pale desert as well as over a dark pane. + fan(ui, &ARROW, x + 2.0 * s, y + 3.0 * s, s, style.shadow); + fan(ui, &ARROW, x, y, s, style.fill); + // Fixed-width border: scaling the stroke with the glyph would eat the body + // at cursor sizes, which is exactly what makes small arrows read as mush. + outline(ui, &ARROW, x, y, s, 1.3, style.edge); + // Dark notch just below the point. The eye lands on the boundary between + // the two tones, which is the hotspot, and the arrow stops reading as a + // flat wedge. + ui.tri( + (x + 0.9 * s, y + 2.6 * s), + (x + 0.9 * s, y + 6.4 * s), + (x + 3.4 * s, y + 5.6 * s), + style.edge, + ); +} + +/// Target reticle: the original's attack cursor, and where the old fixed +/// screen-centre crosshair's meaning now lives — on the pointer, where the +/// swing is actually aimed. +fn reticle(ui: &mut UiBuilder, x: f32, y: f32, s: f32, style: CursorStyle) { + let r = 8.0 * s; + let w = (1.4 * s).max(1.0); + ui.ring(x, y, r + 1.0, 16, w + 1.0, style.shadow); + ui.ring(x, y, r, 16, w, style.accent); + let inner = r * 0.45; + let outer = r * 1.5; + for (dx, dy) in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] { + ui.line( + x + dx * inner, + y + dy * inner, + x + dx * outer, + y + dy * outer, + w, + style.accent, + ); + } + ui.rect(x - s, y - s, 2.0 * s, 2.0 * s, style.accent); +} + +fn four_way(ui: &mut UiBuilder, x: f32, y: f32, s: f32, style: CursorStyle) { + let arm = 8.0 * s; + let w = (1.4 * s).max(1.0); + for (dx, dy) in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] { + ui.line(x, y, x + dx * arm, y + dy * arm, w, style.edge); + head(ui, x + dx * arm, y + dy * arm, dx, dy, 3.4 * s, style.edge); + } + ui.rect(x - 1.5 * s, y - 1.5 * s, 3.0 * s, 3.0 * s, style.accent); +} + +/// Double-headed arrow along `(dx, dy)`, centred on the hotspot. +fn double_arrow(ui: &mut UiBuilder, x: f32, y: f32, dx: f32, dy: f32, s: f32, style: CursorStyle) { + let arm = 8.5 * s; + let w = (1.5 * s).max(1.0); + ui.line( + x - dx * arm + s, + y - dy * arm + s, + x + dx * arm + s, + y + dy * arm + s, + w + 1.0, + style.shadow, + ); + ui.line( + x - dx * arm, + y - dy * arm, + x + dx * arm, + y + dy * arm, + w, + style.edge, + ); + head( + ui, + x + dx * arm, + y + dy * arm, + dx, + dy, + 4.0 * s, + style.accent, + ); + head( + ui, + x - dx * arm, + y - dy * arm, + -dx, + -dy, + 4.0 * s, + style.accent, + ); +} + +/// Solid arrowhead at `(hx, hy)` pointing along the unit vector `(dx, dy)`. +fn head(ui: &mut UiBuilder, hx: f32, hy: f32, dx: f32, dy: f32, size: f32, rgba: [u8; 4]) { + let (nx, ny) = (-dy, dx); + let base = size * 0.55; + ui.tri( + (hx + dx * size, hy + dy * size), + (hx + nx * base, hy + ny * base), + (hx - nx * base, hy - ny * base), + rgba, + ); +} + +fn beam(ui: &mut UiBuilder, x: f32, y: f32, s: f32, style: CursorStyle) { + let half = 7.0 * s; + let serif = 2.6 * s; + let w = (1.2 * s).max(1.0); + ui.line(x, y - half, x, y + half, w, style.edge); + ui.line(x - serif, y - half, x + serif, y - half, w, style.edge); + ui.line(x - serif, y + half, x + serif, y + half, w, style.edge); +} + +/// A ring with one bright arc sweeping it. The original shows an hourglass; +/// a sweep says the same thing without a bitmap and without a text glyph. +fn busy(ui: &mut UiBuilder, x: f32, y: f32, s: f32, style: CursorStyle, phase_ms: u64) { + let r = 7.0 * s; + let w = (1.6 * s).max(1.0); + ui.ring(x, y, r, 16, w, style.fill); + let turn = (phase_ms % 1_200) as f32 / 1_200.0 * core::f32::consts::TAU; + let mut prev = (x + libm::cosf(turn) * r, y + libm::sinf(turn) * r); + for step in 1..=5 { + let angle = turn + step as f32 / 5.0 * (core::f32::consts::TAU * 0.28); + let next = (x + libm::cosf(angle) * r, y + libm::sinf(angle) * r); + ui.line(prev.0, prev.1, next.0, next.1, w, style.accent); + prev = next; + } +} + +/// Four L-brackets inset into a rect — the original's selection box, reused by +/// the world target indicator. +pub fn corner_ticks(ui: &mut UiBuilder, rect: [f32; 4], arm: f32, thickness: f32, rgba: [u8; 4]) { + let [x, y, w, h] = rect; + let arm = arm.min(w * 0.5).min(h * 0.5); + let t = thickness.max(1.0); + for (cx, sx) in [(x, 1.0), (x + w, -1.0)] { + for (cy, sy) in [(y, 1.0), (y + h, -1.0)] { + ui.rect(cx.min(cx + sx * arm), cy - t * 0.5, arm, t, rgba); + ui.rect(cx - t * 0.5, cy.min(cy + sy * arm), t, arm, rgba); + } + } +} + +fn chevron(ui: &mut UiBuilder, x: f32, y: f32, size: f32, thickness: f32, rgba: [u8; 4]) { + ui.line(x, y - size, x + size * 0.8, y, thickness, rgba); + ui.line(x + size * 0.8, y, x, y + size, thickness, rgba); +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + use crate::ui::AtlasMeta; + + const ATLAS: AtlasMeta = AtlasMeta { + cell: 32, + cols: 8, + width: 256, + height: 160, + }; + + fn builder() -> UiBuilder { + let mut ui = UiBuilder::new(ATLAS); + ui.begin(1280, 720); + ui + } + + #[test] + fn every_kind_draws_geometry() { + for kind in [ + CursorKind::Arrow, + CursorKind::Select, + CursorKind::Attack, + CursorKind::Interact, + CursorKind::Move, + CursorKind::ResizeHorizontal, + CursorKind::ResizeVertical, + CursorKind::ResizeNwSe, + CursorKind::ResizeNeSw, + CursorKind::Text, + CursorKind::Busy, + CursorKind::Blocked, + ] { + let mut ui = builder(); + draw(&mut ui, kind, 640.0, 360.0, CursorStyle::default(), 400); + assert!(ui.quads > 0, "{kind:?} drew nothing"); + } + } + + /// The hotspot is the click point: the arrow's ink must start there and + /// extend down-right, never straddle it. + #[test] + fn arrow_ink_starts_at_the_hotspot() { + let mut ui = builder(); + draw( + &mut ui, + CursorKind::Arrow, + 100.0, + 100.0, + CursorStyle::default(), + 0, + ); + let style = CursorStyle::default(); + // Tallest arrow vertex is 16 design units; the shadow adds a 3-unit + // offset and the outline stroke half a pixel on top of that. + let extent = (DESIGN + 3.0) * (style.size / DESIGN) + 1.0; + let (min_x, min_y, max_x, max_y) = ink_bounds(&ui, 1280.0, 720.0); + assert!(min_x >= 100.0 - 1.5, "ink left of the hotspot: {min_x}"); + assert!(min_y >= 100.0 - 1.5, "ink above the hotspot: {min_y}"); + assert!( + max_x <= 100.0 + extent, + "arrow wider than its silhouette: {max_x}" + ); + assert!( + max_y <= 100.0 + extent, + "arrow taller than its silhouette: {max_y}" + ); + } + + /// Centred kinds straddle the hotspot; a resize pointer that only grew + /// down-right would sit visibly off the edge it grabs. + #[test] + fn resize_and_reticle_are_centred_on_the_hotspot() { + for kind in [ + CursorKind::ResizeHorizontal, + CursorKind::ResizeVertical, + CursorKind::ResizeNwSe, + CursorKind::ResizeNeSw, + CursorKind::Attack, + CursorKind::Move, + ] { + let mut ui = builder(); + draw(&mut ui, kind, 400.0, 300.0, CursorStyle::default(), 0); + let (min_x, min_y, max_x, max_y) = ink_bounds(&ui, 1280.0, 720.0); + assert!(min_x < 400.0 && max_x > 400.0, "{kind:?} not centred in x"); + assert!(min_y < 300.0 && max_y > 300.0, "{kind:?} not centred in y"); + } + } + + #[test] + fn corner_edges_win_over_sides() { + assert_eq!( + CursorKind::for_edges(true, false, true, false), + Some(CursorKind::ResizeNwSe) + ); + assert_eq!( + CursorKind::for_edges(false, true, true, false), + Some(CursorKind::ResizeNeSw) + ); + assert_eq!( + CursorKind::for_edges(false, true, false, false), + Some(CursorKind::ResizeHorizontal) + ); + assert_eq!( + CursorKind::for_edges(false, false, false, true), + Some(CursorKind::ResizeVertical) + ); + assert_eq!(CursorKind::for_edges(false, false, false, false), None); + } + + /// Vertices are NDC; map back to pixels to reason about placement. + fn ink_bounds(ui: &UiBuilder, sw: f32, sh: f32) -> (f32, f32, f32, f32) { + let (mut min_x, mut min_y) = (f32::MAX, f32::MAX); + let (mut max_x, mut max_y) = (f32::MIN, f32::MIN); + for vertex in ui.buf.chunks_exact(8) { + let px = (vertex[0] + 1.0) * 0.5 * sw; + let py = (1.0 - vertex[1]) * 0.5 * sh; + min_x = min_x.min(px); + min_y = min_y.min(py); + max_x = max_x.max(px); + max_y = max_y.max(py); + } + (min_x, min_y, max_x, max_y) + } +} diff --git a/client-rust/source/engine-render/src/environment.rs b/client-rust/source/engine-render/src/environment.rs new file mode 100644 index 00000000..bc9f306b --- /dev/null +++ b/client-rust/source/engine-render/src/environment.rs @@ -0,0 +1,281 @@ +//! World environment: server world-clock → presentation (port of +//! `SUCCESSOR_3D_CONFIG.environment`). Maps a minute-of-day to the sun +//! direction/elevation/color and a time-of-day color grade (fog clear color, +//! bone tint, desaturate, scene darken, black lift, bloom) by interpolating the +//! authored grade anchors, wrapping across midnight. Pure + `no_std`. + +use libm::{cosf, sinf, sqrtf}; + +/// Minutes in a day. +pub const DAY_MINUTES: f32 = 1440.0; + +/// One authored grade anchor. +#[derive(Clone, Copy, Debug)] +pub struct GradeAnchor { + pub minute: f32, + pub fog: [f32; 3], + pub bone_tint: [f32; 3], + pub desaturate: f32, + pub scene_darken: f32, + pub black_lift: f32, + pub bloom: f32, +} + +const fn rgb(r: u8, g: u8, b: u8) -> [f32; 3] { + [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0] +} + +/// The authored day grade (config `environment.grade.anchors`). +pub const GRADE: [GradeAnchor; 7] = [ + GradeAnchor { + minute: 0.0, + fog: rgb(0x2b, 0x30, 0x40), + bone_tint: [0.86, 0.92, 1.14], + desaturate: 0.34, + scene_darken: 0.38, + black_lift: 0.05, + bloom: 0.65, + }, + GradeAnchor { + minute: 360.0, + fog: rgb(0xb9, 0x7d, 0x58), + bone_tint: [1.1, 0.95, 0.82], + desaturate: 0.16, + scene_darken: 0.85, + black_lift: 0.04, + bloom: 0.5, + }, + GradeAnchor { + minute: 480.0, + fog: rgb(0xc9, 0xa9, 0x7e), + bone_tint: [1.06, 0.99, 0.88], + desaturate: 0.18, + scene_darken: 0.96, + black_lift: 0.015, + bloom: 0.18, + }, + GradeAnchor { + minute: 720.0, + fog: rgb(0xc9, 0xad, 0x82), + bone_tint: [1.04, 1.0, 0.9], + desaturate: 0.2, + scene_darken: 1.0, + black_lift: 0.03, + bloom: 0.35, + }, + GradeAnchor { + minute: 1080.0, + fog: rgb(0xc9, 0x9a, 0x6e), + bone_tint: [1.07, 0.97, 0.86], + desaturate: 0.17, + scene_darken: 0.9, + black_lift: 0.025, + bloom: 0.32, + }, + GradeAnchor { + minute: 1140.0, + fog: rgb(0xb0, 0x6a, 0x4a), + bone_tint: [1.12, 0.92, 0.85], + desaturate: 0.14, + scene_darken: 0.8, + black_lift: 0.04, + bloom: 0.55, + }, + GradeAnchor { + minute: 1260.0, + fog: rgb(0x33, 0x3a, 0x52), + bone_tint: [0.88, 0.94, 1.12], + desaturate: 0.32, + scene_darken: 0.42, + black_lift: 0.05, + bloom: 0.6, + }, +]; + +/// Sun light tints (config `environment.sun.tints`). +const NOON: [f32; 3] = rgb(0xff, 0xf3, 0xe2); +const DAWN_DUSK: [f32; 3] = rgb(0xff, 0xb2, 0x77); +const NIGHT: [f32; 3] = rgb(0x8f, 0xa0, 0xc8); + +/// Sampled environment for a given time of day. +#[derive(Clone, Copy, Debug)] +pub struct EnvSample { + /// Direction the sunlight travels (normalized, world space). + pub sun_dir: [f32; 3], + /// Sun/moon light color. + pub sun_color: [f32; 3], + /// 0 at horizon, 1 at zenith (drives shadow strength + brightness). + pub sun_elevation01: f32, + /// Whether the sun is above the horizon (else moonlit night). + pub is_day: bool, + pub fog: [f32; 3], + pub bone_tint: [f32; 3], + pub desaturate: f32, + pub scene_darken: f32, + pub black_lift: f32, + pub bloom: f32, +} + +#[inline] +fn lerp(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t +} +#[inline] +fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { + [ + lerp(a[0], b[0], t), + lerp(a[1], b[1], t), + lerp(a[2], b[2], t), + ] +} + +/// Interpolate the grade anchors at `minute` (wrapping across midnight). +pub fn sample_grade(minute: f32) -> GradeAnchor { + let m = wrap_day(minute); + let n = GRADE.len(); + // Find the anchor pair bracketing m (wrapping). + for i in 0..n { + let a = GRADE[i]; + let b = GRADE[(i + 1) % n]; + let bm = if b.minute <= a.minute { + b.minute + DAY_MINUTES + } else { + b.minute + }; + let mm = if m < a.minute { m + DAY_MINUTES } else { m }; + if mm >= a.minute && mm <= bm { + let t = if bm > a.minute { + (mm - a.minute) / (bm - a.minute) + } else { + 0.0 + }; + return GradeAnchor { + minute: m, + fog: lerp3(a.fog, b.fog, t), + bone_tint: lerp3(a.bone_tint, b.bone_tint, t), + desaturate: lerp(a.desaturate, b.desaturate, t), + scene_darken: lerp(a.scene_darken, b.scene_darken, t), + black_lift: lerp(a.black_lift, b.black_lift, t), + bloom: lerp(a.bloom, b.bloom, t), + }; + } + } + GRADE[0] +} + +/// Full environment sample at `minute` of the day. +pub fn sample(minute: f32) -> EnvSample { + let m = wrap_day(minute); + let grade = sample_grade(m); + // Daylight window 6:00 (360) .. 18:00 (1080). Azimuth 0..π over the arc + // (sunrise=0, noon=π/2, dusk=π); env horizontal dir = (-cos a, -sin a). + let day = (360.0..=1080.0).contains(&m); + let (sun_dir, elevation01, color) = if day { + let p = (m - 360.0) / 720.0; // 0..1 + let a = p * core::f32::consts::PI; + let elev = sinf(a).max(0.0); // 0 at horizon, 1 at noon + // Light travels downward + along the horizontal azimuth. + let hx = -cosf(a); + let hz = -sinf(a); + let mut dir = [hx, -(0.2 + 0.8 * elev), hz]; + norm3(&mut dir); + // Color: dawn/dusk ember near horizon → bone white at noon. + let c = lerp3(DAWN_DUSK, NOON, elev); + (dir, elev, c) + } else { + // Night: dim moonlight straight-ish down, slate blue. + ([0.1, -0.98, 0.1], 0.0, NIGHT) + }; + EnvSample { + sun_dir, + sun_color: color, + sun_elevation01: elevation01, + is_day: day, + fog: grade.fog, + bone_tint: grade.bone_tint, + desaturate: grade.desaturate, + scene_darken: grade.scene_darken, + black_lift: grade.black_lift, + bloom: grade.bloom, + } +} + +fn wrap_day(m: f32) -> f32 { + m - libm::floorf(m / DAY_MINUTES) * DAY_MINUTES +} + +fn norm3(v: &mut [f32; 3]) { + let l = sqrtf(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); + if l > 1e-6 { + v[0] /= l; + v[1] /= l; + v[2] /= l; + } +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + + #[test] + fn midnight_is_dark_blue_night() { + let e = sample(0.0); + assert!(!e.is_day, "midnight is night"); + // Fog ≈ #2b3040. + assert!((e.fog[2] - 0x40 as f32 / 255.0).abs() < 0.02, "blueish fog"); + assert!(e.scene_darken < 0.5, "dim at midnight"); + } + + #[test] + fn noon_is_bright_desert() { + let e = sample(720.0); + assert!(e.is_day); + assert!( + (e.scene_darken - 1.0).abs() < 1e-3, + "peak brightness at noon" + ); + // Fog ≈ #c9ad82 (warm sand): red channel high. + assert!(e.fog[0] > 0.7 && e.fog[0] > e.fog[2], "warm noon fog"); + assert!(e.sun_elevation01 > 0.98, "sun near zenith at noon"); + } + + #[test] + fn sun_climbs_from_dawn_to_noon() { + let dawn = sample(420.0); + let noon = sample(720.0); + assert!( + noon.sun_elevation01 > dawn.sun_elevation01, + "sun higher at noon" + ); + // Dawn light is warmer (more red vs blue) than noon. + assert!(dawn.sun_color[0] - dawn.sun_color[2] >= noon.sun_color[0] - noon.sun_color[2]); + } + + #[test] + fn grade_interpolates_between_anchors() { + // Halfway between 480 and 720 (=600) → fields between the two anchors. + let g = sample_grade(600.0); + assert!(g.scene_darken > 0.96 && g.scene_darken <= 1.0); + assert!(g.fog[0] > 0.0); + } + + #[test] + fn wraps_across_midnight() { + // 1350 sits between anchor 1260 and wrapped 0(=1440). + let g = sample_grade(1350.0); + // Between #333a52 and #2b3040 → dark. + assert!( + g.scene_darken < 0.45, + "late night is dim, got {}", + g.scene_darken + ); + } + + #[test] + fn sun_dir_points_downward_during_day() { + let e = sample(600.0); + assert!(e.sun_dir[1] < 0.0, "sunlight travels downward"); + let len = (e.sun_dir[0].powi(2) + e.sun_dir[1].powi(2) + e.sun_dir[2].powi(2)).sqrt(); + assert!((len - 1.0).abs() < 1e-4, "normalized"); + } +} diff --git a/client-rust/source/engine-render/src/font.rs b/client-rust/source/engine-render/src/font.rs new file mode 100644 index 00000000..b30b5201 --- /dev/null +++ b/client-rust/source/engine-render/src/font.rs @@ -0,0 +1,234 @@ +//! Baked 5×7 bitmap font (uppercase, digits, punctuation) for HUD/UI text. +//! +//! This is the "font" output of the asset bake step: a compact, deterministic +//! glyph table (no external font file — the repo ships only a Saira woff2, which +//! is display-only and not runtime-rasterizable here). Each glyph is 7 rows; +//! each row's low 5 bits are columns, bit 4 = leftmost. Lowercase folds to +//! uppercase. The text pass emits one small quad per lit pixel, reusing the +//! existing solid-quad text shader (no atlas texture required). + +use alloc::vec::Vec; + +/// Glyph cell dimensions in pixels (5 wide, 7 tall) + 1px inter-glyph advance. +pub const GLYPH_W: u32 = 5; +pub const GLYPH_H: u32 = 7; +/// Shared text advance in legacy `px` units. Kept fixed so existing HUD +/// geometry remains allocation-free while the rendered glyphs are antialiased. +pub const GLYPH_ADVANCE: f32 = 5.5; + +/// PT Sans advance estimate in legacy `px` units. Runtime drawing uses the +/// exact rasterized advance; this allocation-free table keeps static layout +/// measurement within a fraction of a pixel for the supported ASCII UI set. +pub fn text_advance(ch: char) -> f32 { + let em = match ch { + ' ' => 0.30, + 'I' | 'i' | 'l' | '!' | '|' | '\'' | '.' | ',' | ':' | ';' => 0.28, + 'f' | 'j' | 'r' | 't' | '(' | ')' | '[' | ']' => 0.36, + 'm' | 'w' => 0.78, + 'M' | 'W' => 0.84, + '-' | '_' | '/' | '\\' => 0.40, + '0'..='9' => 0.54, + 'A'..='Z' => 0.60, + 'a'..='z' => 0.50, + _ => 0.55, + }; + em * 8.75 +} + +/// One pre-rasterized glyph inside the shared UI texture atlas. +#[derive(Clone, Copy, Debug, Default)] +pub struct RasterGlyph { + pub ch: char, + pub uv: (f32, f32, f32, f32), + pub width: f32, + pub height: f32, + pub xmin: f32, + pub ymin: f32, + pub advance: f32, +} + +/// Startup-built scalable font metadata. Glyph coverage lives in the same +/// texture as UI icons, preserving the renderer's single blended UI pass. +#[derive(Clone, Debug)] +pub struct RasterFont { + pub source_px: f32, + pub ascent: f32, + pub line_height: f32, + pub glyphs: Vec<RasterGlyph>, +} + +impl RasterFont { + pub fn glyph(&self, ch: char) -> Option<RasterGlyph> { + self.glyphs + .iter() + .find(|glyph| glyph.ch == ch) + .copied() + .or_else(|| self.glyphs.iter().find(|glyph| glyph.ch == '?').copied()) + } +} + +/// Row bitmaps for a character, or `None` for unmapped (rendered blank). +pub fn glyph(ch: char) -> Option<[u8; 7]> { + let c = ch.to_ascii_uppercase(); + Some(match c { + ' ' => [0, 0, 0, 0, 0, 0, 0], + '0' => [ + 0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110, + ], + '1' => [ + 0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110, + ], + '2' => [ + 0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111, + ], + '3' => [ + 0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110, + ], + '4' => [ + 0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010, + ], + '5' => [ + 0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110, + ], + '6' => [ + 0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110, + ], + '7' => [ + 0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000, + ], + '8' => [ + 0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110, + ], + '9' => [ + 0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100, + ], + 'A' => [ + 0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001, + ], + 'B' => [ + 0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110, + ], + 'C' => [ + 0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110, + ], + 'D' => [ + 0b11100, 0b10010, 0b10001, 0b10001, 0b10001, 0b10010, 0b11100, + ], + 'E' => [ + 0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111, + ], + 'F' => [ + 0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000, + ], + 'G' => [ + 0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01111, + ], + 'H' => [ + 0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001, + ], + 'I' => [ + 0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110, + ], + 'J' => [ + 0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100, + ], + 'K' => [ + 0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001, + ], + 'L' => [ + 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111, + ], + 'M' => [ + 0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001, + ], + 'N' => [ + 0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001, + ], + 'O' => [ + 0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110, + ], + 'P' => [ + 0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000, + ], + 'Q' => [ + 0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101, + ], + 'R' => [ + 0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001, + ], + 'S' => [ + 0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110, + ], + 'T' => [ + 0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, + ], + 'U' => [ + 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110, + ], + 'V' => [ + 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100, + ], + 'W' => [ + 0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001, + ], + 'X' => [ + 0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001, + ], + 'Y' => [ + 0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100, + ], + 'Z' => [ + 0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111, + ], + '.' => [0, 0, 0, 0, 0, 0b00110, 0b00110], + ',' => [0, 0, 0, 0, 0b00110, 0b00100, 0b01000], + ':' => [0, 0b00110, 0b00110, 0, 0b00110, 0b00110, 0], + '-' => [0, 0, 0, 0b11111, 0, 0, 0], + '_' => [0, 0, 0, 0, 0, 0, 0b11111], + '/' => [ + 0b00001, 0b00010, 0b00100, 0b00100, 0b01000, 0b10000, 0b10000, + ], + '(' => [ + 0b00010, 0b00100, 0b01000, 0b01000, 0b01000, 0b00100, 0b00010, + ], + ')' => [ + 0b01000, 0b00100, 0b00010, 0b00010, 0b00010, 0b00100, 0b01000, + ], + '+' => [0, 0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0], + '!' => [0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0, 0b00100], + '?' => [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0, 0b00100], + '\'' => [0b00100, 0b00100, 0b01000, 0, 0, 0, 0], + '<' => [ + 0b00010, 0b00100, 0b01000, 0b10000, 0b01000, 0b00100, 0b00010, + ], + '>' => [ + 0b01000, 0b00100, 0b00010, 0b00001, 0b00010, 0b00100, 0b01000, + ], + '=' => [0, 0, 0b11111, 0, 0b11111, 0, 0], + '#' => [ + 0b01010, 0b11111, 0b01010, 0b01010, 0b01010, 0b11111, 0b01010, + ], + '*' => [0, 0b00100, 0b10101, 0b01110, 0b10101, 0b00100, 0], + '%' => [0b11001, 0b11010, 0b00100, 0b01000, 0b10110, 0b00101, 0], + _ => return None, + }) +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + + #[test] + fn known_glyph_shape() { + // 'A' top row is a centered arc: 0b01110. + assert_eq!(glyph('A').unwrap()[0], 0b01110); + // Lowercase folds to uppercase. + assert_eq!(glyph('a'), glyph('A')); + } + + #[test] + fn space_is_blank_unknown_is_none() { + assert_eq!(glyph(' ').unwrap(), [0; 7]); + assert!(glyph('€').is_none()); + } +} diff --git a/client-rust/source/engine-render/src/fx.rs b/client-rust/source/engine-render/src/fx.rs new file mode 100644 index 00000000..9fe7297f --- /dev/null +++ b/client-rust/source/engine-render/src/fx.rs @@ -0,0 +1,713 @@ +//! Combat FX particle pool — a zero-per-frame-alloc port of +//! `client-3d/src/render/fx/particles.ts`. +//! +//! Three ring-buffered CPU layers (additive sparks/muzzle, normal-blend blood, +//! long-lived residue) integrate on the CPU exactly like the web client +//! (drag + gravity + a damped ground splat, with size/alpha/color lerped over +//! remaining-life fraction). Emitters push into the rings; `fill_billboards` +//! turns live particles into camera-facing quads for a textured, blended draw. +//! All storage is allocated once at construction; `push`/`step`/`fill` never +//! allocate. + +use alloc::vec; +use alloc::vec::Vec; + +/// Ground reference height (`FX_CONFIG.groundY`). +pub const GROUND_Y: f32 = 0.012; +/// Layer capacities (`FX_CONFIG.particles`). +pub const ADDITIVE_MAX: usize = 768; +pub const NORMAL_MAX: usize = 512; +pub const RESIDUE_MAX: usize = 192; +/// Muzzle flash drag (`FX_CONFIG.muzzle.flashDrag`). +pub const FLASH_DRAG: f32 = 3.8; + +/// Deterministic xorshift RNG so emitter bursts are reproducible in tests. +#[derive(Clone, Copy, Debug)] +pub struct Rng(u32); +impl Rng { + pub fn new(seed: u32) -> Self { + Rng(seed.max(1)) + } + #[inline] + fn next_u32(&mut self) -> u32 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + self.0 = x; + x + } + /// Uniform in [0,1). + #[inline] + pub fn unit(&mut self) -> f32 { + (self.next_u32() >> 8) as f32 / (1u32 << 24) as f32 + } + /// Uniform in [-1,1). + #[inline] + pub fn jit(&mut self) -> f32 { + self.unit() * 2.0 - 1.0 + } +} + +/// One blend layer: parallel arrays + a write cursor (ring buffer). +pub struct ParticleLayer { + pub max: usize, + drag: f32, + // position (x,y,z) interleaved. + pos: Vec<f32>, + // current color (r,g,b) interleaved (lerped over life). + col: Vec<f32>, + alpha: Vec<f32>, + size: Vec<f32>, + vx: Vec<f32>, + vy: Vec<f32>, + vz: Vec<f32>, + life: Vec<f32>, + max_life: Vec<f32>, + grav: Vec<f32>, + s0: Vec<f32>, + s1: Vec<f32>, + a_peak: Vec<f32>, + c0: Vec<f32>, + c1: Vec<f32>, + cursor: usize, +} + +impl ParticleLayer { + pub fn new(max: usize, drag: f32) -> Self { + Self { + max, + drag, + pos: vec![0.0; max * 3], + col: vec![0.0; max * 3], + alpha: vec![0.0; max], + size: vec![0.0; max], + vx: vec![0.0; max], + vy: vec![0.0; max], + vz: vec![0.0; max], + life: vec![0.0; max], + max_life: vec![0.0; max], + grav: vec![0.0; max], + s0: vec![0.0; max], + s1: vec![0.0; max], + a_peak: vec![0.0; max], + c0: vec![0.0; max * 3], + c1: vec![0.0; max * 3], + cursor: 0, + } + } + + /// Count of currently-alive particles (life > 0). + pub fn alive(&self) -> usize { + self.life.iter().filter(|&&l| l > 0.0).count() + } + + /// Push one particle into the ring (evicts the oldest slot at the cursor). + #[allow(clippy::too_many_arguments)] + pub fn push( + &mut self, + p: [f32; 3], + v: [f32; 3], + life: f32, + s0: f32, + s1: f32, + a_peak: f32, + grav: f32, + c0: [f32; 3], + c1: [f32; 3], + ) { + let i = self.cursor; + self.cursor = (i + 1) % self.max; + let i3 = i * 3; + self.pos[i3] = p[0]; + self.pos[i3 + 1] = p[1]; + self.pos[i3 + 2] = p[2]; + self.vx[i] = v[0]; + self.vy[i] = v[1]; + self.vz[i] = v[2]; + self.life[i] = life; + self.max_life[i] = life; + self.grav[i] = grav; + self.s0[i] = s0; + self.s1[i] = s1; + self.a_peak[i] = a_peak; + self.c0[i3] = c0[0]; + self.c0[i3 + 1] = c0[1]; + self.c0[i3 + 2] = c0[2]; + self.c1[i3] = c1[0]; + self.c1[i3 + 1] = c1[1]; + self.c1[i3 + 2] = c1[2]; + // Prime render attributes so a just-pushed particle is visible pre-step. + self.alpha[i] = a_peak; + self.size[i] = s0; + self.col[i3] = c0[0]; + self.col[i3 + 1] = c0[1]; + self.col[i3 + 2] = c0[2]; + } + + /// Integrate one timestep (drag + gravity + damped ground splat), updating + /// per-particle size/alpha/color from the remaining-life fraction. + pub fn step(&mut self, dt: f32, ground_y: f32) { + let drag = self.drag; + for i in 0..self.max { + let life_i = self.life[i]; + if life_i <= 0.0 { + continue; + } + let remaining = life_i - dt; + self.life[i] = remaining; + if remaining <= 0.0 { + self.alpha[i] = 0.0; + continue; + } + let i3 = i * 3; + let (vx, vy, vz) = (self.vx[i], self.vy[i], self.vz[i]); + self.pos[i3] += vx * dt; + let mut ny = self.pos[i3 + 1] + vy * dt; + self.pos[i3 + 2] += vz * dt; + let mut nvy = vy - self.grav[i] * dt; + if ny < ground_y { + ny = ground_y; + nvy *= -0.18; + self.vx[i] = vx * 0.4; + self.vz[i] = vz * 0.4; + } + self.pos[i3 + 1] = ny; + self.vy[i] = nvy; + let d = (1.0 - drag * dt).max(0.0); + self.vx[i] *= d; + self.vz[i] *= d; + let frac = remaining / self.max_life[i]; // 1 -> 0 + self.size[i] = self.s1[i] + (self.s0[i] - self.s1[i]) * frac; + self.alpha[i] = self.a_peak[i] * (frac * 1.6).min(1.0); + self.col[i3] = self.c1[i3] + (self.c0[i3] - self.c1[i3]) * frac; + self.col[i3 + 1] = self.c1[i3 + 1] + (self.c0[i3 + 1] - self.c1[i3 + 1]) * frac; + self.col[i3 + 2] = self.c1[i3 + 2] + (self.c0[i3 + 2] - self.c1[i3 + 2]) * frac; + } + } + + /// Append camera-facing quads (`pos:3, uv:2, color:4`) for live particles. + /// `right`/`up` are the camera basis vectors (world space). Returns the + /// number of quads appended. + pub fn fill_billboards(&self, right: [f32; 3], up: [f32; 3], out: &mut Vec<f32>) -> u32 { + let mut quads = 0u32; + for i in 0..self.max { + if self.life[i] <= 0.0 || self.alpha[i] <= 0.003 { + continue; + } + let i3 = i * 3; + let (cx, cy, cz) = (self.pos[i3], self.pos[i3 + 1], self.pos[i3 + 2]); + let hs = self.size[i] * 0.5; + let (r, g, b, a) = ( + self.col[i3], + self.col[i3 + 1], + self.col[i3 + 2], + self.alpha[i], + ); + let rx = right[0] * hs; + let ry = right[1] * hs; + let rz = right[2] * hs; + let ux = up[0] * hs; + let uy = up[1] * hs; + let uz = up[2] * hs; + // Corners: bl, br, tr, tl. + let bl = [cx - rx - ux, cy - ry - uy, cz - rz - uz]; + let br = [cx + rx - ux, cy + ry - uy, cz + rz - uz]; + let tr = [cx + rx + ux, cy + ry + uy, cz + rz + uz]; + let tl = [cx - rx + ux, cy - ry + uy, cz - rz + uz]; + let mut v = |p: [f32; 3], u: f32, w: f32| { + out.extend_from_slice(&[p[0], p[1], p[2], u, w, r, g, b, a]); + }; + v(bl, 0.0, 0.0); + v(br, 1.0, 0.0); + v(tr, 1.0, 1.0); + v(bl, 0.0, 0.0); + v(tr, 1.0, 1.0); + v(tl, 0.0, 1.0); + quads += 1; + } + quads + } +} + +/// The three-layer combat particle system. +pub struct ParticlePool { + pub additive: ParticleLayer, + pub normal: ParticleLayer, + pub residue: ParticleLayer, + rng: Rng, +} + +impl ParticlePool { + pub fn new(seed: u32) -> Self { + Self { + additive: ParticleLayer::new(ADDITIVE_MAX, FLASH_DRAG), + normal: ParticleLayer::new(NORMAL_MAX, 1.2), + residue: ParticleLayer::new(RESIDUE_MAX, 0.0), + rng: Rng::new(seed), + } + } + + pub fn update(&mut self, dt: f32) { + if dt <= 0.0 { + return; + } + self.additive.step(dt, GROUND_Y); + self.normal.step(dt, GROUND_Y); + self.residue.step(dt, GROUND_Y); + } + + pub fn alive(&self) -> usize { + self.additive.alive() + self.normal.alive() + self.residue.alive() + } + + /// Ricochet spark burst (additive) — port of `emitSparkBurst`. + pub fn emit_spark_burst( + &mut self, + point: [f32; 3], + normal: [f32; 3], + incoming: [f32; 3], + mag: f32, + ) { + let sm = 0.84 + 0.16 * mag; + let vm = 0.84 + 0.16 * mag; + let cnt = |base: f32| (libm::roundf(base * mag) as i32).max(1); + // reflect incoming about normal + let dn = incoming[0] * normal[0] + incoming[1] * normal[1] + incoming[2] * normal[2]; + let r = [ + incoming[0] - 2.0 * dn * normal[0], + incoming[1] - 2.0 * dn * normal[1], + incoming[2] - 2.0 * dn * normal[2], + ]; + let base = [ + r[0] * 0.7 + normal[0] * 0.4, + r[1] * 0.7 + normal[1] * 0.4, + r[2] * 0.7 + normal[2] * 0.4, + ]; + let streaks = cnt(6.0); + for _ in 0..streaks { + let mut e = [ + base[0] + self.rng.jit() * 0.7, + base[1] + self.rng.jit() * 0.7 + 0.15, + base[2] + self.rng.jit() * 0.7, + ]; + normalize3(&mut e); + let sp = (2.6 + self.rng.unit() * 4.5) * vm; + let life = 0.16 + self.rng.unit() * 0.3; + let sz = (0.013 + self.rng.unit() * 0.022) * sm; + self.additive.push( + point, + [e[0] * sp, e[1] * sp, e[2] * sp], + life, + sz, + sz * 0.2, + 1.0, + 9.5, + [1.0, 0.95, 0.62], + [1.0, 0.32, 0.06], + ); + } + let flashes = cnt(3.0); + for _ in 0..flashes { + let mut e = [ + normal[0] + self.rng.jit() * 0.5, + normal[1] + self.rng.jit() * 0.5, + normal[2] + self.rng.jit() * 0.5, + ]; + normalize3(&mut e); + let life = 0.05 + self.rng.unit() * 0.05; + let sz = (0.05 + self.rng.unit() * 0.035) * sm; + self.additive.push( + point, + [e[0] * 0.6, e[1] * 0.6, e[2] * 0.6], + life, + sz, + sz * 0.4, + 1.0, + 0.0, + [1.0, 0.92, 0.7], + [1.0, 0.6, 0.3], + ); + } + } + + /// Blood droplet burst (normal blend) — port of `emitBloodBurst` (red). + pub fn emit_blood_burst(&mut self, point: [f32; 3], incoming: [f32; 3], mag: f32) { + let droplets = libm::roundf(10.0 * mag).max(6.0) as i32; + let spray = [0.62, 0.05, 0.06]; + let drip = [0.40, 0.02, 0.03]; + for _ in 0..droplets { + let mut e = [ + incoming[0] * 0.7 + self.rng.jit() * 0.6, + self.rng.unit() * 0.5 + 0.2, + incoming[2] * 0.7 + self.rng.jit() * 0.6, + ]; + normalize3(&mut e); + let sp = (1.4 + self.rng.unit() * 2.2) * (0.9 + 0.1 * mag); + let life = 0.3 + self.rng.unit() * 0.5; + let sz = 0.03 + self.rng.unit() * 0.03; + self.normal.push( + point, + [e[0] * sp, e[1] * sp, e[2] * sp], + life, + sz, + sz * 0.6, + 0.95, + 6.5, + spray, + drip, + ); + } + } + + /// Muzzle flash (additive) — port of `MuzzleFx.flash`: a fat core pop at the + /// barrel lip, a forward cone of hot streaks, and a few drifting embers. + /// `color` is linear rgb (default warm). The web client's budgeted point + /// light is a forward-compat no-op (pawn materials are unlit). + pub fn emit_muzzle_flash(&mut self, point: [f32; 3], dir: [f32; 3], mag: f32, color: [f32; 3]) { + let mut d = dir; + normalize3(&mut d); + let (u, w) = basis_perp(d); + let [r, g, b] = color; + let cnt = |base: f32| (libm::roundf(base * mag) as i32).max(1); + // 1) core pop + for _ in 0..cnt(1.0) { + let sz = (0.05 + self.rng.unit() * 0.03) * (0.8 + 0.2 * mag); + self.additive.push( + [ + point[0] + d[0] * 0.02, + point[1] + d[1] * 0.02, + point[2] + d[2] * 0.02, + ], + [d[0] * 0.6, d[1] * 0.6, d[2] * 0.6], + 0.045 + self.rng.unit() * 0.03, + sz, + sz * 0.35, + 1.0, + 0.0, + [(r + 0.2).min(1.0), (g + 0.2).min(1.0), (b + 0.2).min(1.0)], + [r * 0.8, g * 0.6, b * 0.6], + ); + } + // 2) forward cone of hot streaks + for _ in 0..cnt(6.0) { + let ca = self.rng.unit() * core::f32::consts::TAU; + let spread = 0.26 + self.rng.unit() * 0.2; + let (cc, ss) = (libm::cosf(ca) * spread, libm::sinf(ca) * spread); + let mut t = [ + d[0] + u[0] * cc + w[0] * ss, + d[1] + u[1] * cc + w[1] * ss, + d[2] + u[2] * cc + w[2] * ss, + ]; + normalize3(&mut t); + let sp = (5.5 + self.rng.unit() * 7.0) * (0.85 + 0.15 * mag); + let life = 0.05 + self.rng.unit() * 0.1; + let sz = (0.022 + self.rng.unit() * 0.028) * (0.85 + 0.15 * mag); + self.additive.push( + point, + [t[0] * sp, t[1] * sp, t[2] * sp], + life, + sz, + sz * 0.18, + 1.0, + 6.0, + [r, g, b], + [r * 0.5, g * 0.3, b * 0.3], + ); + } + // 3) lazy embers + for _ in 0..cnt(2.0) { + let ju = self.rng.jit() * 0.5; + let jw = self.rng.jit() * 0.5 + 0.1; + let mut t = [ + d[0] + u[0] * ju + w[0] * jw, + d[1] + u[1] * ju + w[1] * jw, + d[2] + u[2] * ju + w[2] * jw, + ]; + normalize3(&mut t); + let sp = 0.8 + self.rng.unit() * 1.8; + let sz = 0.02 + self.rng.unit() * 0.02; + self.additive.push( + point, + [t[0] * sp, t[1] * sp, t[2] * sp], + 0.18 + self.rng.unit() * 0.22, + sz, + sz * 0.4, + 0.9, + 7.0, + [r * 0.8, g * 0.6, b * 0.4], + [r * 0.3, g * 0.1, b * 0.05], + ); + } + } + + /// Tracer streak (additive): lay a line of short-lived hot points from + /// `from` (muzzle) to `to` (impact). A pragmatic stand-in for the web + /// client's pooled cylinder+head tracer that reuses the particle layer. + pub fn emit_tracer(&mut self, from: [f32; 3], to: [f32; 3], mag: f32) { + let seg = [to[0] - from[0], to[1] - from[1], to[2] - from[2]]; + let len = libm::sqrtf(seg[0] * seg[0] + seg[1] * seg[1] + seg[2] * seg[2]); + if len < 1e-4 { + return; + } + let steps = ((len / 0.18) as i32).clamp(2, 64); + let sz = (0.026 + 0.004 * mag).max(0.02); + for k in 0..=steps { + let f = k as f32 / steps as f32; + let p = [ + from[0] + seg[0] * f, + from[1] + seg[1] * f, + from[2] + seg[2] * f, + ]; + self.additive.push( + p, + [0.0, 0.0, 0.0], + 0.08 + self.rng.unit() * 0.04, + sz, + sz * 0.4, + 0.9, + 0.0, + [1.0, 0.89, 0.60], + [1.0, 0.60, 0.20], + ); + } + } +} + +/// Procedural soft radial glow sprite (RGBA8, `size`×`size`) — the shared point +/// texture (`makeGlowSprite`): white core fading to transparent edge. +pub fn glow_sprite(size: usize) -> Vec<u8> { + let mut out = vec![0u8; size * size * 4]; + let half = size as f32 / 2.0; + for y in 0..size { + for x in 0..size { + let dx = (x as f32 + 0.5) - half; + let dy = (y as f32 + 0.5) - half; + let r = libm::sqrtf(dx * dx + dy * dy) / half; // 0..~1 + // Piecewise gradient matching the canvas stops. + let a: f32 = if r >= 1.0 { + 0.0 + } else if r <= 0.3 { + 1.0 - (1.0 - 0.95) * (r / 0.3) + } else if r <= 0.65 { + 0.95 + (0.35 - 0.95) * ((r - 0.3) / 0.35) + } else { + 0.35 + (0.0 - 0.35) * ((r - 0.65) / 0.35) + }; + let i = (y * size + x) * 4; + out[i] = 255; + out[i + 1] = 255; + out[i + 2] = 255; + out[i + 3] = (a.clamp(0.0, 1.0) * 255.0) as u8; + } + } + out +} + +fn normalize3(v: &mut [f32; 3]) { + let len = libm::sqrtf(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); + if len > 1e-6 { + v[0] /= len; + v[1] /= len; + v[2] /= len; + } +} + +/// Orthonormal pair spanning the plane perpendicular to `dir` (port of +/// `MuzzleFx.basisPerp`). +fn basis_perp(dir: [f32; 3]) -> ([f32; 3], [f32; 3]) { + let mut helper = [0.0, 1.0, 0.0]; + if (dir[0] * helper[0] + dir[1] * helper[1] + dir[2] * helper[2]).abs() > 0.92 { + helper = [1.0, 0.0, 0.0]; + } + let mut u = cross(dir, helper); + normalize3(&mut u); + let mut w = cross(dir, u); + normalize3(&mut w); + (u, w) +} + +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + + #[test] + fn push_makes_particle_alive_then_expires() { + let mut l = ParticleLayer::new(16, 1.0); + l.push( + [0.0, 1.0, 0.0], + [0.0, 0.0, 0.0], + 0.10, + 0.05, + 0.0, + 1.0, + 0.0, + [1.0, 1.0, 1.0], + [0.0, 0.0, 0.0], + ); + assert_eq!(l.alive(), 1); + l.step(0.05, GROUND_Y); + assert_eq!(l.alive(), 1, "still alive at half life"); + l.step(0.06, GROUND_Y); // total 0.11 > 0.10 + assert_eq!(l.alive(), 0, "expired"); + } + + #[test] + fn ring_buffer_evicts_oldest_and_never_grows() { + let mut l = ParticleLayer::new(4, 0.0); + for _ in 0..10 { + l.push( + [0.0, 1.0, 0.0], + [0.0, 0.0, 0.0], + 1.0, + 0.05, + 0.0, + 1.0, + 0.0, + [1.0; 3], + [0.0; 3], + ); + } + assert!(l.alive() <= 4, "capacity bounded to max"); + assert_eq!(l.pos.len(), 4 * 3, "storage fixed"); + } + + #[test] + fn gravity_pulls_down_and_ground_splats() { + let mut l = ParticleLayer::new(4, 0.0); + // Start just above ground, moving down, heavy gravity. + l.push( + [0.0, GROUND_Y + 0.01, 0.0], + [0.0, -1.0, 0.0], + 1.0, + 0.05, + 0.05, + 1.0, + 20.0, + [1.0; 3], + [1.0; 3], + ); + l.step(0.1, GROUND_Y); + // Clamped to ground and vy reflected (damped). + assert!( + l.pos[1] >= GROUND_Y - 1e-4, + "settled at/above ground: {}", + l.pos[1] + ); + } + + #[test] + fn size_and_alpha_lerp_over_life() { + let mut l = ParticleLayer::new(4, 0.0); + l.push( + [0.0, 1.0, 0.0], + [0.0, 0.0, 0.0], + 1.0, + 0.10, + 0.02, + 1.0, + 0.0, + [1.0, 0.0, 0.0], + [0.0, 0.0, 1.0], + ); + l.step(0.5, GROUND_Y); // frac ~ 0.5 + assert!( + l.size[0] > 0.02 && l.size[0] < 0.10, + "size between s1 and s0" + ); + // color lerps from c1(blue) toward c0(red) as frac->1; at 0.5 mixed. + assert!( + l.col[0] > 0.4 && l.col[0] < 0.6, + "red channel ~0.5, got {}", + l.col[0] + ); + } + + #[test] + fn spark_burst_emits_additive_and_is_deterministic() { + let mut a = ParticlePool::new(1234); + a.emit_spark_burst([0.0, 1.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0], 1.0); + let n = a.additive.alive(); + assert!(n > 0, "sparks emitted"); + let mut b = ParticlePool::new(1234); + b.emit_spark_burst([0.0, 1.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0], 1.0); + assert_eq!( + a.additive.alive(), + b.additive.alive(), + "deterministic with same seed" + ); + } + + #[test] + fn billboards_emit_six_verts_per_alive_particle() { + let mut l = ParticleLayer::new(8, 0.0); + l.push( + [0.0, 1.0, 0.0], + [0.0, 0.0, 0.0], + 1.0, + 0.1, + 0.1, + 1.0, + 0.0, + [1.0; 3], + [1.0; 3], + ); + l.step(0.01, GROUND_Y); + let mut out = Vec::new(); + let q = l.fill_billboards([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], &mut out); + assert_eq!(q, 1); + assert_eq!(out.len(), 6 * 9, "6 verts * 9 floats (pos3+uv2+color4)"); + } + + #[test] + fn glow_sprite_center_opaque_edge_transparent() { + let s = glow_sprite(64); + let center = s[(32 * 64 + 32) * 4 + 3]; + let corner = s[3]; // (0,0) + assert!(center > 200, "center bright"); + assert_eq!(corner, 0, "corner transparent"); + } + + #[test] + fn muzzle_flash_emits_additive_core_cone_embers() { + let mut p = ParticlePool::new(99); + p.emit_muzzle_flash([0.0, 1.3, 0.0], [1.0, 0.0, 0.0], 1.0, [1.0, 0.7, 0.3]); + // core(>=1) + cone(6) + embers(2) at mag 1. + assert!( + p.additive.alive() >= 8, + "flash particles emitted, got {}", + p.additive.alive() + ); + } + + #[test] + fn tracer_lays_a_line_of_points() { + let mut p = ParticlePool::new(7); + p.emit_tracer([0.0, 1.0, 0.0], [3.6, 1.0, 0.0], 1.0); + // length 3.6 / 0.18 = 20 steps + 1. + assert!( + p.additive.alive() >= 20, + "tracer points laid, got {}", + p.additive.alive() + ); + } + + #[test] + fn basis_perp_is_orthonormal() { + let d = [0.0, 1.0, 0.0]; + let (u, w) = basis_perp(d); + let dot = |a: [f32; 3], b: [f32; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + assert!(dot(u, d).abs() < 1e-5, "u ⟂ dir"); + assert!(dot(w, d).abs() < 1e-5, "w ⟂ dir"); + assert!(dot(u, w).abs() < 1e-5, "u ⟂ w"); + assert!((dot(u, u) - 1.0).abs() < 1e-4, "u unit"); + } +} diff --git a/client-rust/source/engine-render/src/gi.rs b/client-rust/source/engine-render/src/gi.rs new file mode 100644 index 00000000..1acfac8c --- /dev/null +++ b/client-rust/source/engine-render/src/gi.rs @@ -0,0 +1,704 @@ +//! Camera-independent, world-aligned voxel GI for a mostly-static world. +//! +//! A 64³ toroidal volume is updated in 8×64×8 bricks. X/Z physical slots are +//! derived from world coordinates with Euclidean modulo, so scrolling preserves +//! overlapping data and only uploads newly exposed bricks. Animated meshes are +//! intentionally excluded: they receive GI but contribute through direct shadows. + +use alloc::vec; +use alloc::vec::Vec; +use libm::{cosf, floorf, sinf, sqrtf}; + +use crate::gpu::{Gpu, Texture3dDesc, TextureFormat, TextureId}; + +pub const GI_SIZE: u32 = 64; +pub const GI_CELL: f32 = 0.75; +pub const GI_BRICK_SIZE: u32 = 8; +pub const GI_BRICKS_PER_FRAME: usize = 4; +const GI_BRICKS: i32 = (GI_SIZE / GI_BRICK_SIZE) as i32; +const GI_ORIGIN_Y: f32 = -2.0 * GI_CELL; +const BRICK_VOXELS: usize = (GI_BRICK_SIZE * GI_SIZE * GI_BRICK_SIZE) as usize; +const FADE_FRAMES: f32 = 8.0; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GiOccluder { + pub center: [f32; 3], + pub half_extents: [f32; 3], + pub yaw: f32, + pub albedo: [f32; 3], +} + +#[derive(Clone, Copy, Debug)] +struct PreparedGiOccluder { + source: GiOccluder, + sin_yaw: f32, + cos_yaw: f32, + min: [f32; 3], + max: [f32; 3], +} + +impl PreparedGiOccluder { + fn new(source: GiOccluder) -> Self { + let sin_yaw = sinf(source.yaw); + let cos_yaw = cosf(source.yaw); + let ex = cos_yaw.abs() * source.half_extents[0] + sin_yaw.abs() * source.half_extents[2]; + let ez = sin_yaw.abs() * source.half_extents[0] + cos_yaw.abs() * source.half_extents[2]; + Self { + source, + sin_yaw, + cos_yaw, + min: [ + source.center[0] - ex, + source.center[1] - source.half_extents[1], + source.center[2] - ez, + ], + max: [ + source.center[0] + ex, + source.center[1] + source.half_extents[1], + source.center[2] + ez, + ], + } + } + + fn contains(&self, p: [f32; 3]) -> bool { + let dx = p[0] - self.source.center[0]; + let dz = p[2] - self.source.center[2]; + let lx = dx * self.cos_yaw + dz * self.sin_yaw; + let lz = -dx * self.sin_yaw + dz * self.cos_yaw; + lx.abs() <= self.source.half_extents[0] + && (p[1] - self.source.center[1]).abs() <= self.source.half_extents[1] + && lz.abs() <= self.source.half_extents[2] + } + + fn ray_hit(&self, origin: [f32; 3], dir: [f32; 3]) -> bool { + let ox = origin[0] - self.source.center[0]; + let oz = origin[2] - self.source.center[2]; + let local_o = [ + ox * self.cos_yaw + oz * self.sin_yaw, + origin[1] - self.source.center[1], + -ox * self.sin_yaw + oz * self.cos_yaw, + ]; + let local_d = [ + dir[0] * self.cos_yaw + dir[2] * self.sin_yaw, + dir[1], + -dir[0] * self.sin_yaw + dir[2] * self.cos_yaw, + ]; + let mut t_min: f32 = 0.001; + let mut t_max = f32::MAX; + for axis in 0..3 { + let extent = self.source.half_extents[axis]; + if local_d[axis].abs() < 1.0e-6 { + if local_o[axis].abs() > extent { + return false; + } + } else { + let inv = 1.0 / local_d[axis]; + let mut a = (-extent - local_o[axis]) * inv; + let mut b = (extent - local_o[axis]) * inv; + if a > b { + core::mem::swap(&mut a, &mut b); + } + t_min = t_min.max(a); + t_max = t_max.min(b); + if t_max < t_min { + return false; + } + } + } + t_max >= t_min + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct GiWorkCounters { + pub albedo_builds: u64, + pub radiance_builds: u64, + pub resident_uploads: u64, + pub mipmap_rebuilds: u64, + pub full_rebuilds: u64, +} + +#[derive(Clone, Copy, Debug)] +pub struct GiBinding { + pub radiance: TextureId, + pub origin: [f32; 3], + pub valid_min: [f32; 3], + pub valid_max: [f32; 3], + pub blend: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WorkKind { + None, + Geometry, + Radiance, + Scroll, +} + +pub struct GiVolume { + committed_origin: [i32; 2], + requested_origin: [i32; 2], + active_origin: [i32; 2], + valid_min: [i32; 2], + valid_max: [i32; 2], + focus: [f32; 3], + occluders: Vec<GiOccluder>, + prepared: Vec<PreparedGiOccluder>, + ground_albedo: [f32; 3], + albedo_tex: TextureId, + radiance_tex: TextureId, + albedo_scratch: Vec<u8>, + radiance_scratch: Vec<u8>, + owner_scratch: Vec<u32>, + dirty_bricks: Vec<[i32; 2]>, + dirty_index: usize, + work: WorkKind, + geometry_dirty: bool, + light_dirty: bool, + ready: bool, + blend: f32, + fade: i8, + sun_dir: [f32; 3], + sun_color: [f32; 3], + last_sun: i32, + counters: GiWorkCounters, +} + +impl GiVolume { + pub fn new<G: Gpu>(gpu: &mut G) -> Self { + let albedo_tex = gpu.create_texture_3d( + &Texture3dDesc { + size: GI_SIZE, + format: TextureFormat::Rgba8, + mips: false, + wrap_xz: true, + }, + None, + ); + let radiance_tex = gpu.create_texture_3d( + &Texture3dDesc { + size: GI_SIZE, + format: TextureFormat::Rgba8, + mips: true, + wrap_xz: true, + }, + None, + ); + let mut volume = Self { + committed_origin: [-(GI_SIZE as i32) / 2, -(GI_SIZE as i32) / 2], + requested_origin: [-(GI_SIZE as i32) / 2, -(GI_SIZE as i32) / 2], + active_origin: [-(GI_SIZE as i32) / 2, -(GI_SIZE as i32) / 2], + valid_min: [-(GI_SIZE as i32) / 2, -(GI_SIZE as i32) / 2], + valid_max: [GI_SIZE as i32 / 2, GI_SIZE as i32 / 2], + focus: [0.0, 0.0, 0.0], + occluders: Vec::with_capacity(512), + prepared: Vec::with_capacity(512), + ground_albedo: [0.5, 0.5, 0.5], + albedo_tex, + radiance_tex, + albedo_scratch: vec![0; BRICK_VOXELS * 4], + radiance_scratch: vec![0; BRICK_VOXELS * 4], + owner_scratch: vec![0; BRICK_VOXELS], + dirty_bricks: Vec::with_capacity((GI_BRICKS * GI_BRICKS) as usize), + dirty_index: 0, + work: WorkKind::None, + geometry_dirty: false, + light_dirty: false, + ready: false, + blend: 0.0, + fade: 0, + sun_dir: [0.0, -1.0, 0.0], + sun_color: [1.0, 1.0, 1.0], + last_sun: 0, + counters: GiWorkCounters::default(), + }; + volume.start_full(WorkKind::Geometry, volume.requested_origin); + volume + } + + pub(crate) fn radiance_texture(&self) -> TextureId { + self.radiance_tex + } + + pub fn binding(&self) -> Option<GiBinding> { + if !self.ready { + return None; + } + Some(GiBinding { + radiance: self.radiance_tex, + origin: [ + self.committed_origin[0] as f32 * GI_CELL, + GI_ORIGIN_Y, + self.committed_origin[1] as f32 * GI_CELL, + ], + valid_min: [ + self.valid_min[0] as f32 * GI_CELL, + GI_ORIGIN_Y, + self.valid_min[1] as f32 * GI_CELL, + ], + valid_max: [ + self.valid_max[0] as f32 * GI_CELL, + GI_ORIGIN_Y + GI_SIZE as f32 * GI_CELL, + self.valid_max[1] as f32 * GI_CELL, + ], + blend: self.blend, + }) + } + + pub fn counters(&self) -> GiWorkCounters { + self.counters + } + + pub fn is_idle(&self) -> bool { + self.work == WorkKind::None && self.fade == 0 && !self.geometry_dirty && !self.light_dirty + } + + pub fn set_focus(&mut self, focus: [f32; 3]) { + self.focus = focus; + let brick_cells = GI_BRICK_SIZE as i32; + let center_brick_x = floorf(focus[0] / (GI_CELL * GI_BRICK_SIZE as f32)) as i32; + let center_brick_z = floorf(focus[2] / (GI_CELL * GI_BRICK_SIZE as f32)) as i32; + self.requested_origin = [ + (center_brick_x - GI_BRICKS / 2) * brick_cells, + (center_brick_z - GI_BRICKS / 2) * brick_cells, + ]; + } + + pub fn set_ground_albedo(&mut self, rgb: [f32; 3]) { + if self.ground_albedo != rgb { + self.ground_albedo = rgb; + self.geometry_dirty = true; + self.begin_fade_out(); + } + } + + pub fn set_occluders(&mut self, occ: &[GiOccluder]) { + if self.occluders == occ { + return; + } + self.occluders.clear(); + self.occluders.extend_from_slice(occ); + self.prepared.clear(); + self.prepared + .extend(occ.iter().copied().map(PreparedGiOccluder::new)); + self.geometry_dirty = true; + self.begin_fade_out(); + } + + pub fn step<G: Gpu>(&mut self, gpu: &mut G, sun_dir: [f32; 3], sun_color: [f32; 3]) { + let key = sun_key(sun_dir, sun_color); + self.sun_dir = sun_dir; + self.sun_color = sun_color; + if self.last_sun != 0 && key != self.last_sun { + self.light_dirty = true; + self.begin_fade_out(); + } + self.last_sun = key; + + if self.fade < 0 { + self.blend = (self.blend - 1.0 / FADE_FRAMES).max(0.0); + if self.blend > 0.0 { + return; + } + self.fade = 0; + if self.geometry_dirty { + self.geometry_dirty = false; + self.light_dirty = false; + self.start_full(WorkKind::Geometry, self.requested_origin); + } else if self.light_dirty { + self.light_dirty = false; + self.start_full(WorkKind::Radiance, self.committed_origin); + } + } + + if self.work == WorkKind::None { + if self.geometry_dirty { + self.begin_fade_out(); + return; + } + if self.requested_origin != self.committed_origin { + self.start_scroll(); + } else if self.light_dirty { + self.begin_fade_out(); + return; + } else if self.fade > 0 { + self.blend = (self.blend + 1.0 / FADE_FRAMES).min(1.0); + if self.blend >= 1.0 { + self.fade = 0; + } + return; + } else { + return; + } + } + + let end = (self.dirty_index + GI_BRICKS_PER_FRAME).min(self.dirty_bricks.len()); + while self.dirty_index < end { + let world_brick = self.dirty_bricks[self.dirty_index]; + fill_albedo_brick( + &mut self.albedo_scratch, + &mut self.owner_scratch, + world_brick, + self.ground_albedo, + &self.prepared, + ); + if self.work != WorkKind::Radiance { + self.counters.albedo_builds += 1; + let offset = brick_offset(world_brick); + gpu.update_texture_3d_region( + self.albedo_tex, + offset, + [GI_BRICK_SIZE, GI_SIZE, GI_BRICK_SIZE], + &self.albedo_scratch, + ); + } + fill_radiance_brick( + &mut self.radiance_scratch, + &self.albedo_scratch, + &self.owner_scratch, + world_brick, + self.sun_dir, + self.sun_color, + &self.prepared, + ); + self.counters.radiance_builds += 1; + gpu.update_texture_3d_region( + self.radiance_tex, + brick_offset(world_brick), + [GI_BRICK_SIZE, GI_SIZE, GI_BRICK_SIZE], + &self.radiance_scratch, + ); + self.counters.resident_uploads += 1; + self.dirty_index += 1; + } + + if self.dirty_index == self.dirty_bricks.len() { + gpu.generate_mipmaps_3d(self.radiance_tex); + self.counters.mipmap_rebuilds += 1; + let completed = self.work; + self.work = WorkKind::None; + if completed == WorkKind::Scroll || completed == WorkKind::Geometry { + self.committed_origin = self.active_origin; + } + self.valid_min = self.committed_origin; + self.valid_max = [ + self.committed_origin[0] + GI_SIZE as i32, + self.committed_origin[1] + GI_SIZE as i32, + ]; + self.ready = true; + if completed != WorkKind::Scroll || self.blend < 1.0 { + self.fade = 1; + } + } + } + + fn begin_fade_out(&mut self) { + if self.ready && self.work == WorkKind::None && self.fade >= 0 { + self.fade = -1; + } + } + + fn start_full(&mut self, kind: WorkKind, origin: [i32; 2]) { + self.dirty_bricks.clear(); + let bx0 = origin[0].div_euclid(GI_BRICK_SIZE as i32); + let bz0 = origin[1].div_euclid(GI_BRICK_SIZE as i32); + for z in 0..GI_BRICKS { + for x in 0..GI_BRICKS { + self.dirty_bricks.push([bx0 + x, bz0 + z]); + } + } + self.dirty_index = 0; + self.work = kind; + self.active_origin = origin; + self.counters.full_rebuilds += 1; + } + + fn start_scroll(&mut self) { + let dx = self.requested_origin[0] - self.committed_origin[0]; + let dz = self.requested_origin[1] - self.committed_origin[1]; + if dx.abs() >= GI_SIZE as i32 || dz.abs() >= GI_SIZE as i32 { + self.start_full(WorkKind::Geometry, self.requested_origin); + return; + } + self.dirty_bricks.clear(); + let old_min = [ + self.committed_origin[0].div_euclid(GI_BRICK_SIZE as i32), + self.committed_origin[1].div_euclid(GI_BRICK_SIZE as i32), + ]; + let new_min = [ + self.requested_origin[0].div_euclid(GI_BRICK_SIZE as i32), + self.requested_origin[1].div_euclid(GI_BRICK_SIZE as i32), + ]; + for z in 0..GI_BRICKS { + for x in 0..GI_BRICKS { + let b = [new_min[0] + x, new_min[1] + z]; + if b[0] < old_min[0] + || b[0] >= old_min[0] + GI_BRICKS + || b[1] < old_min[1] + || b[1] >= old_min[1] + GI_BRICKS + { + self.dirty_bricks.push(b); + } + } + } + self.dirty_index = 0; + self.work = WorkKind::Scroll; + self.active_origin = self.requested_origin; + self.valid_min = [ + self.committed_origin[0].max(self.requested_origin[0]), + self.committed_origin[1].max(self.requested_origin[1]), + ]; + self.valid_max = [ + (self.committed_origin[0] + GI_SIZE as i32) + .min(self.requested_origin[0] + GI_SIZE as i32), + (self.committed_origin[1] + GI_SIZE as i32) + .min(self.requested_origin[1] + GI_SIZE as i32), + ]; + } +} + +fn fill_albedo_brick( + out: &mut [u8], + owners: &mut [u32], + world_brick: [i32; 2], + ground: [f32; 3], + occluders: &[PreparedGiOccluder], +) { + out.fill(0); + owners.fill(u32::MAX); + let world_min_x = world_brick[0] as f32 * GI_BRICK_SIZE as f32 * GI_CELL; + let world_min_z = world_brick[1] as f32 * GI_BRICK_SIZE as f32 * GI_CELL; + let world_max_x = world_min_x + GI_BRICK_SIZE as f32 * GI_CELL; + let world_max_z = world_min_z + GI_BRICK_SIZE as f32 * GI_CELL; + + for z in 0..GI_BRICK_SIZE { + for y in 0..GI_SIZE { + let band_lo = GI_ORIGIN_Y + y as f32 * GI_CELL; + let band_hi = band_lo + GI_CELL; + if band_lo < 0.0 && band_hi > -GI_CELL { + for x in 0..GI_BRICK_SIZE { + write_voxel(out, brick_index(x, y, z), ground); + } + } + } + } + + for (owner, occ) in occluders.iter().enumerate() { + if occ.max[0] < world_min_x + || occ.min[0] > world_max_x + || occ.max[2] < world_min_z + || occ.min[2] > world_max_z + { + continue; + } + let x0 = floorf((occ.min[0] - world_min_x) / GI_CELL).max(0.0) as u32; + let x1 = (floorf((occ.max[0] - world_min_x) / GI_CELL) as i32 + 1) + .clamp(0, GI_BRICK_SIZE as i32) as u32; + let z0 = floorf((occ.min[2] - world_min_z) / GI_CELL).max(0.0) as u32; + let z1 = (floorf((occ.max[2] - world_min_z) / GI_CELL) as i32 + 1) + .clamp(0, GI_BRICK_SIZE as i32) as u32; + let y0 = floorf((occ.min[1] - GI_ORIGIN_Y) / GI_CELL).max(0.0) as u32; + let y1 = (floorf((occ.max[1] - GI_ORIGIN_Y) / GI_CELL) as i32 + 1).clamp(0, GI_SIZE as i32) + as u32; + for z in z0..z1 { + for y in y0..y1 { + for x in x0..x1 { + let idx = brick_index(x, y, z); + if out[idx * 4 + 3] != 0 { + continue; + } + let p = [ + world_min_x + (x as f32 + 0.5) * GI_CELL, + GI_ORIGIN_Y + (y as f32 + 0.5) * GI_CELL, + world_min_z + (z as f32 + 0.5) * GI_CELL, + ]; + if occ.contains(p) { + write_voxel(out, idx, occ.source.albedo); + owners[idx] = owner as u32; + } + } + } + } + } +} + +fn fill_radiance_brick( + out: &mut [u8], + albedo: &[u8], + owners: &[u32], + world_brick: [i32; 2], + sun_dir: [f32; 3], + sun_color: [f32; 3], + occluders: &[PreparedGiOccluder], +) { + out.fill(0); + let length = sqrtf(sun_dir[0] * sun_dir[0] + sun_dir[1] * sun_dir[1] + sun_dir[2] * sun_dir[2]) + .max(1.0e-6); + let ray = [ + -sun_dir[0] / length, + -sun_dir[1] / length, + -sun_dir[2] / length, + ]; + let min_x = world_brick[0] as f32 * GI_BRICK_SIZE as f32 * GI_CELL; + let min_z = world_brick[1] as f32 * GI_BRICK_SIZE as f32 * GI_CELL; + for z in 0..GI_BRICK_SIZE { + for y in 0..GI_SIZE { + for x in 0..GI_BRICK_SIZE { + let idx = brick_index(x, y, z); + if albedo[idx * 4 + 3] == 0 { + continue; + } + let p = [ + min_x + (x as f32 + 0.5) * GI_CELL, + GI_ORIGIN_Y + (y as f32 + 0.5) * GI_CELL, + min_z + (z as f32 + 0.5) * GI_CELL, + ]; + let source = owners[idx]; + let shadowed = occluders + .iter() + .enumerate() + .any(|(i, occ)| i as u32 != source && occ.ray_hit(p, ray)); + let visibility = if shadowed { 0.0 } else { 0.75 }; + out[idx * 4] = + (albedo[idx * 4] as f32 * sun_color[0].max(0.0) * visibility).min(255.0) as u8; + out[idx * 4 + 1] = (albedo[idx * 4 + 1] as f32 * sun_color[1].max(0.0) * visibility) + .min(255.0) as u8; + out[idx * 4 + 2] = (albedo[idx * 4 + 2] as f32 * sun_color[2].max(0.0) * visibility) + .min(255.0) as u8; + out[idx * 4 + 3] = 255; + } + } + } +} + +fn write_voxel(out: &mut [u8], idx: usize, rgb: [f32; 3]) { + out[idx * 4] = (rgb[0].clamp(0.0, 1.0) * 255.0) as u8; + out[idx * 4 + 1] = (rgb[1].clamp(0.0, 1.0) * 255.0) as u8; + out[idx * 4 + 2] = (rgb[2].clamp(0.0, 1.0) * 255.0) as u8; + out[idx * 4 + 3] = 255; +} + +fn brick_index(x: u32, y: u32, z: u32) -> usize { + ((z * GI_SIZE + y) * GI_BRICK_SIZE + x) as usize +} + +fn brick_offset(world_brick: [i32; 2]) -> [u32; 3] { + [ + (world_brick[0].rem_euclid(GI_BRICKS) as u32) * GI_BRICK_SIZE, + 0, + (world_brick[1].rem_euclid(GI_BRICKS) as u32) * GI_BRICK_SIZE, + ] +} + +fn sun_key(dir: [f32; 3], color: [f32; 3]) -> i32 { + let q = |v: f32| (v * 32.0) as i32; + q(dir[0]).wrapping_mul(73_856_093) + ^ q(dir[1]).wrapping_mul(19_349_663) + ^ q(dir[2]).wrapping_mul(83_492_791) + ^ q(color[0]).wrapping_mul(2_654_435_761u32 as i32) + ^ q(color[1]).wrapping_mul(40_503) + ^ q(color[2]).wrapping_mul(51_787) +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + use crate::gpu::{MockCall, MockGpu}; + + fn settle(vol: &mut GiVolume, gpu: &mut MockGpu) { + for _ in 0..256 { + vol.step(gpu, [0.2, -1.0, 0.1], [1.0, 0.9, 0.8]); + if vol.is_idle() { + return; + } + } + panic!("GI did not settle"); + } + + #[test] + fn focus_scroll_updates_entering_bricks_only() { + let mut gpu = MockGpu::default(); + let mut vol = GiVolume::new(&mut gpu); + settle(&mut vol, &mut gpu); + let before = vol.counters(); + vol.set_focus([GI_BRICK_SIZE as f32 * GI_CELL, 0.0, 0.0]); + settle(&mut vol, &mut gpu); + let after = vol.counters(); + assert_eq!(after.albedo_builds - before.albedo_builds, 8); + assert_eq!(after.radiance_builds - before.radiance_builds, 8); + assert_eq!(after.resident_uploads - before.resident_uploads, 8); + assert_eq!(after.mipmap_rebuilds - before.mipmap_rebuilds, 1); + assert_eq!(after.full_rebuilds - before.full_rebuilds, 0); + } + + #[test] + fn identical_inputs_are_noops() { + let mut gpu = MockGpu::default(); + let mut vol = GiVolume::new(&mut gpu); + settle(&mut vol, &mut gpu); + let before = vol.counters(); + vol.set_focus([0.0, 0.0, 0.0]); + vol.set_ground_albedo([0.5, 0.5, 0.5]); + vol.set_occluders(&[]); + settle(&mut vol, &mut gpu); + assert_eq!(vol.counters(), before); + } + + #[test] + fn geometry_and_light_invalidation_are_separate() { + let mut gpu = MockGpu::default(); + let mut vol = GiVolume::new(&mut gpu); + settle(&mut vol, &mut gpu); + let before = vol.counters(); + vol.set_ground_albedo([0.4, 0.3, 0.2]); + settle(&mut vol, &mut gpu); + let geometry = vol.counters(); + assert_eq!(geometry.albedo_builds - before.albedo_builds, 64); + assert_eq!(geometry.radiance_builds - before.radiance_builds, 64); + vol.step(&mut gpu, [0.4, -1.0, 0.1], [1.0, 0.9, 0.8]); + settle(&mut vol, &mut gpu); + let light = vol.counters(); + assert_eq!(light.albedo_builds - geometry.albedo_builds, 0); + assert_eq!(light.radiance_builds - geometry.radiance_builds, 64); + } + + #[test] + fn static_proxy_radiance_shadows_ground() { + let occ = PreparedGiOccluder::new(GiOccluder { + center: [2.5, 1.0, 2.5], + half_extents: [0.75, 1.0, 0.75], + yaw: 0.0, + albedo: [1.0, 0.1, 0.1], + }); + let mut albedo = vec![0; BRICK_VOXELS * 4]; + let mut owners = vec![0; BRICK_VOXELS]; + fill_albedo_brick(&mut albedo, &mut owners, [0, 0], [0.5; 3], &[occ]); + let mut radiance = vec![0; BRICK_VOXELS * 4]; + fill_radiance_brick( + &mut radiance, + &albedo, + &owners, + [0, 0], + [-1.0, -1.0, 0.0], + [1.0; 3], + &[occ], + ); + let open = brick_index(7, 1, 0) * 4; + let behind = brick_index(1, 1, 3) * 4; + assert!(radiance[open] > radiance[behind]); + } + + #[test] + fn regional_uploads_are_bounded() { + let mut gpu = MockGpu::default(); + let mut vol = GiVolume::new(&mut gpu); + settle(&mut vol, &mut gpu); + assert!(gpu.log.iter().any(|call| matches!( + call, + MockCall::UpdateTexture3dRegion { + extent: [8, 64, 8], + .. + } + ))); + } +} diff --git a/client-rust/source/engine-render/src/gpu.rs b/client-rust/source/engine-render/src/gpu.rs new file mode 100644 index 00000000..c5f2f944 --- /dev/null +++ b/client-rust/source/engine-render/src/gpu.rs @@ -0,0 +1,725 @@ +//! The GPU backend contract. +//! +//! `engine-render` is `no_std` and platform-free: it expresses every draw as +//! calls on this trait. The `platform` crate provides the single concrete +//! implementation (`GlGpu`) for the compiled target (desktop GL or WebGL2). The +//! renderer is generic `Renderer<G: Gpu>`, so calls monomorphize with no dynamic +//! dispatch. Handles are opaque `u32` newtypes minted by the backend. + +use alloc::collections::BTreeMap; +#[cfg(feature = "std")] +use alloc::vec::Vec; + +macro_rules! handle { + ($name:ident) => { + #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] + pub struct $name(pub u32); + }; +} + +handle!(BufferId); +handle!(ProgramId); +handle!(TextureId); +handle!(RenderTargetId); + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum BufferUsage { + /// Uploaded once at load (mesh geometry). + Static, + /// Re-uploaded per frame (text/composite quads). + Dynamic, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum TextureFormat { + Rgba8, + Srgba8, + R8, + Rg8, + /// 16-bit half-float RGBA (HDR scene accumulation target). Render-target + /// only; `create_texture_3d`/`create_render_target_mrt` allocate it. + Rgba16F, + /// 32-bit depth (shadow map / RTT depth). + Depth, +} + +/// Backend capability probe (queried once at load). +#[derive(Clone, Copy, Debug)] +pub struct GpuCaps { + /// A half-float (RGBA16F) color attachment can be rendered to. + pub half_float_target: bool, + pub max_color_attachments: u32, + pub max_draw_buffers: u32, +} + +impl Default for GpuCaps { + fn default() -> Self { + Self { + half_float_target: false, + max_color_attachments: 4, + max_draw_buffers: 4, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GpuError { + ShaderCompile, + ProgramLink, + IncompleteFramebuffer, + InvalidResource, +} + +/// A cubic 3D texture (`size` per axis). Used by the VXGI volumes. +#[derive(Clone, Copy, Debug)] +pub struct Texture3dDesc { + pub size: u32, + pub format: TextureFormat, + /// Allocate a mip chain (radiance volume) with trilinear min filtering. + pub mips: bool, + /// Wrap X/Z for world-aligned toroidal volumes; Y always clamps. + pub wrap_xz: bool, +} + +/// A 2D texture array with equally sized RGBA8 layers. +#[derive(Clone, Copy, Debug)] +pub struct TextureArrayDesc { + pub width: u32, + pub height: u32, + pub layers: u32, + pub format: TextureFormat, + pub mipmaps: bool, +} + +/// Multi-render-target descriptor: `colors` lists the attachment formats +/// (`COLOR_ATTACHMENT0..`), `depth` allocates a sampleable D24. +#[derive(Clone, Copy, Debug)] +pub struct MrtDesc { + pub width: u32, + pub height: u32, + pub colors: &'static [TextureFormat], + pub depth: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Filter { + Nearest, + Linear, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum MinFilter { + Nearest, + Linear, + NearestMipmapNearest, + LinearMipmapLinear, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Wrap { + ClampToEdge, + Repeat, +} + +#[derive(Clone, Copy, Debug)] +pub struct TextureDesc { + pub width: u32, + pub height: u32, + pub format: TextureFormat, + pub mag_filter: Filter, + pub min_filter: MinFilter, + pub wrap_s: Wrap, + pub wrap_t: Wrap, + pub mipmaps: bool, +} + +#[derive(Clone, Copy, Debug)] +pub struct RenderTargetDesc { + pub width: u32, + pub height: u32, + /// Allocate a sampleable color attachment (RTT cameras). Depth-only targets + /// (shadow maps) set this false. + pub color: bool, + /// Allocate a sampleable depth attachment. + pub depth: bool, + pub filter: Filter, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PassTarget { + Screen, + RenderTarget(RenderTargetId), +} + +/// Pixel rectangle (GL viewport convention: origin bottom-left). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct RectPx { + pub x: i32, + pub y: i32, + pub w: i32, + pub h: i32, +} + +#[derive(Clone, Copy, PartialEq, Debug, Default)] +pub struct ClearSpec { + pub color: Option<[f32; 4]>, + pub depth: Option<f32>, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Cull { + None, + Back, + Front, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PipelineState { + pub depth_test: bool, + pub depth_write: bool, + pub cull: Cull, + /// Draw only back faces of the depth pass to reduce shadow acne (front-face + /// culling in the shadow pass). Ordinary passes use `cull` directly. + pub color_write: bool, + /// Enable alpha blending. Straight (src_alpha, 1-src_alpha) unless + /// `additive`, which uses (src_alpha, one) for glow/spark accumulation. + pub blend: bool, + pub additive: bool, +} + +impl Default for PipelineState { + fn default() -> Self { + Self { + depth_test: true, + depth_write: true, + cull: Cull::Back, + color_write: true, + blend: false, + additive: false, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct ForwardLight { + pub position: [f32; 3], + pub radius: f32, + pub color: [f32; 3], + pub intensity: f32, +} + +/// A shader uniform value. No UBOs in v1 — plain uniforms only. +#[derive(Clone, Copy, Debug)] +pub enum UniformValue { + Float(f32), + Vec3([f32; 3]), + Vec2([f32; 2]), + Vec4([f32; 4]), + Mat4([f32; 16]), + Int(i32), + /// Texture unit index for a sampler uniform. + Sampler(i32), +} + +#[derive(Clone, Copy, Debug)] +pub struct Uniform { + pub name: &'static str, + pub value: UniformValue, +} + +/// GPU scalar representation for one vertex attribute. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VertexFormat { + F32, + U8, + U8Norm, + U16Norm, +} + +/// One vertex attribute: shader location, component count, representation, and byte offset. +#[derive(Clone, Copy, Debug)] +pub struct VertexAttr { + pub location: u32, + pub components: u32, + pub format: VertexFormat, + pub offset: u32, +} + +#[derive(Clone, Copy, Debug)] +pub struct VertexLayout { + pub stride: u32, + pub attrs: &'static [VertexAttr], +} + +const fn f32_attr(location: u32, components: u32, offset: u32) -> VertexAttr { + VertexAttr { + location, + components, + format: VertexFormat::F32, + offset, + } +} + +/// Interleaved `pos:3, normal:3, uv:2` — the mesh vertex format. +pub const MESH_LAYOUT: VertexLayout = VertexLayout { + stride: 32, + attrs: &[f32_attr(0, 3, 0), f32_attr(1, 3, 12), f32_attr(2, 2, 24)], +}; + +/// Interleaved `pos:2, uv:2` — composite/text quads in NDC. +pub const QUAD_LAYOUT: VertexLayout = VertexLayout { + stride: 16, + attrs: &[f32_attr(0, 2, 0), f32_attr(1, 2, 8)], +}; + +/// Interleaved `pos:2, uv:2, color:4` — immediate-mode UI quads in NDC. +pub const UI_LAYOUT: VertexLayout = VertexLayout { + stride: 32, + attrs: &[f32_attr(0, 2, 0), f32_attr(1, 2, 8), f32_attr(2, 4, 16)], +}; + +/// Interleaved `pos:3, uv:2, color:4` — world-space particle billboards. +pub const PARTICLE_LAYOUT: VertexLayout = VertexLayout { + stride: 36, + attrs: &[f32_attr(0, 3, 0), f32_attr(1, 2, 12), f32_attr(2, 4, 20)], +}; + +/// Legacy interleaved `pos:3, normal:3, uv:2, joints:4, weights:4`. +pub const SKINNED_MESH_LAYOUT: VertexLayout = VertexLayout { + stride: 64, + attrs: &[ + f32_attr(0, 3, 0), + f32_attr(1, 3, 12), + f32_attr(2, 2, 24), + f32_attr(5, 4, 32), + f32_attr(6, 4, 48), + ], +}; + +/// Packed glTF static vertex with tangent and normalized U8 color. +pub const GLTF_MESH_LAYOUT: VertexLayout = VertexLayout { + stride: 52, + attrs: &[ + f32_attr(0, 3, 0), + f32_attr(1, 3, 12), + f32_attr(2, 2, 24), + f32_attr(3, 4, 32), + VertexAttr { + location: 4, + components: 4, + format: VertexFormat::U8Norm, + offset: 48, + }, + ], +}; + +/// Packed glTF skinned vertex with U8 joints and normalized U16 weights. +pub const GLTF_SKINNED_MESH_LAYOUT: VertexLayout = VertexLayout { + stride: 64, + attrs: &[ + f32_attr(0, 3, 0), + f32_attr(1, 3, 12), + f32_attr(2, 2, 24), + f32_attr(3, 4, 32), + VertexAttr { + location: 4, + components: 4, + format: VertexFormat::U8Norm, + offset: 48, + }, + VertexAttr { + location: 5, + components: 4, + format: VertexFormat::U8, + offset: 52, + }, + VertexAttr { + location: 6, + components: 4, + format: VertexFormat::U16Norm, + offset: 56, + }, + ], +}; + +/// Per-instance model matrix, four `vec4` columns at locations 7..=10. +pub const INSTANCE_MAT4_LAYOUT: VertexLayout = VertexLayout { + stride: 64, + attrs: &[ + f32_attr(7, 4, 0), + f32_attr(8, 4, 16), + f32_attr(9, 4, 32), + f32_attr(10, 4, 48), + ], +}; + +/// Per-instance point-light data (divisor 1): `pos_radius` = world xyz + radius, +/// `color_intensity` = linear rgb + intensity. Consumed by the deferred +/// point-light volume program. +pub const POINT_LIGHT_INSTANCE_LAYOUT: VertexLayout = VertexLayout { + stride: 32, + attrs: &[f32_attr(5, 4, 0), f32_attr(6, 4, 16)], +}; + +/// The backend contract. All resource creation happens at load; the per-frame +/// path is `begin_pass`/`set_pipeline`/`set_uniforms`/`bind_texture`/`draw`/ +/// `end_pass` and allocates nothing on the Rust heap. +pub trait Gpu { + fn create_buffer(&mut self, data: &[u8], usage: BufferUsage) -> BufferId; + /// Create an index (element-array) buffer. WebGL2 fixes a buffer's target on + /// first bind, so index buffers must be created bound to ELEMENT_ARRAY_BUFFER + /// (a vertex buffer can't later be rebound as an index buffer). Backends that + /// don't distinguish targets fall back to `create_buffer`. + fn create_index_buffer(&mut self, data: &[u8], usage: BufferUsage) -> BufferId { + self.create_buffer(data, usage) + } + fn update_buffer(&mut self, id: BufferId, data: &[u8]); + fn create_program(&mut self, vert_src: &str, frag_src: &str) -> ProgramId; + fn create_texture(&mut self, desc: &TextureDesc, data: Option<&[u8]>) -> TextureId; + /// Replace a complete RGBA8/SRGBA8 2D texture at mip level zero. + fn update_texture(&mut self, _id: TextureId, _desc: &TextureDesc, _data: &[u8]) {} + /// Create a 2D texture array. Layer payloads are tightly packed in ascending order. + fn create_texture_array( + &mut self, + _desc: &TextureArrayDesc, + _data: Option<&[u8]>, + ) -> TextureId { + TextureId(0) + } + /// Bind a 2D texture array to a sampler slot. + fn bind_texture_array(&mut self, _slot: u32, _tex: TextureId) {} + fn create_render_target(&mut self, desc: &RenderTargetDesc) -> RenderTargetId; + /// The sampleable color texture of a render target (RTT compositing). + fn render_target_color(&self, rt: RenderTargetId) -> Option<TextureId>; + /// The sampleable depth texture of a render target (shadow sampling). + fn render_target_depth(&self, rt: RenderTargetId) -> Option<TextureId>; + /// Actual attachment dimensions. Camera projection and viewport must use + /// these dimensions; screen-size heuristics distort non-square portraits. + fn render_target_size(&self, rt: RenderTargetId) -> Option<(u32, u32)>; + + fn begin_pass(&mut self, target: PassTarget, viewport: RectPx, clear: ClearSpec); + fn set_pipeline(&mut self, program: ProgramId, state: &PipelineState); + fn set_uniforms(&mut self, uniforms: &[Uniform]); + fn bind_texture(&mut self, slot: u32, tex: TextureId); + fn draw( + &mut self, + vertices: BufferId, + indices: Option<BufferId>, + layout: &VertexLayout, + count: u32, + ); + /// Upload the joint palette (`u_joints` mat4 array) for the next skinned + /// draw. Default no-op for backends that don't skin (tests/headless). + fn set_joints(&mut self, _mats: &[[f32; 16]]) {} + /// Upload nearest point lights for the active forward material program. + fn set_forward_lights(&mut self, _lights: &[ForwardLight]) {} + /// Instanced indexed draw: `instance_buf` holds one record per instance in + /// `instance_layout` (attribute divisor 1); `layout` describes the shared + /// vertex buffer. Default no-op; only the GL backend implements it. + #[allow(clippy::too_many_arguments)] + fn draw_instanced( + &mut self, + _vertices: BufferId, + _indices: Option<BufferId>, + _layout: &VertexLayout, + _index_count: u32, + _instance_buf: BufferId, + _instance_layout: &VertexLayout, + _instances: u32, + ) { + } + /// Return and clear the first backend resource/program error. + fn take_error(&mut self) -> Option<GpuError> { + None + } + /// Backend capabilities (half-float target support). Queried once at load. + fn caps(&self) -> GpuCaps { + GpuCaps::default() + } + /// Create a cubic 3D texture (VXGI volume). `data`, if present, is the full + /// `size^3 * 4` byte payload (RGBA8) uploaded at level 0. + fn create_texture_3d(&mut self, _desc: &Texture3dDesc, _data: Option<&[u8]>) -> TextureId { + TextureId(0) + } + /// Upload a tightly packed 3D subvolume at mip level zero. + fn update_texture_3d_region( + &mut self, + _id: TextureId, + _offset: [u32; 3], + _extent: [u32; 3], + _data: &[u8], + ) { + } + /// Regenerate the mip chain of a 3D texture. + fn generate_mipmaps_3d(&mut self, _id: TextureId) {} + /// Bind a 3D texture to a sampler slot. + fn bind_texture_3d(&mut self, _slot: u32, _tex: TextureId) {} + /// Create a multi-render-target (deferred G-buffer / HDR scene target). + fn create_render_target_mrt(&mut self, _desc: &MrtDesc) -> RenderTargetId { + RenderTargetId(0) + } + /// The `index`-th sampleable color attachment of an MRT render target. + fn render_target_color_n(&self, _rt: RenderTargetId, _index: usize) -> Option<TextureId> { + None + } + /// Free an FBO and its attachments (G-buffer / scene RT recreation on resize). + fn delete_render_target(&mut self, _rt: RenderTargetId) {} + /// Block until submitted GPU work completes. Used only by opt-in timing. + fn finish(&mut self) {} + fn end_pass(&mut self); +} + +/// A headless recording backend for unit tests. Records every call so tests can +/// assert pass ordering, cull decisions, and viewport math without a real GL +/// context. Available only with the `std` feature (tests/tools). +#[cfg(feature = "std")] +#[derive(Default)] +pub struct MockGpu { + next: u32, + pub log: Vec<MockCall>, + pub error: Option<GpuError>, + pub caps: GpuCaps, + render_target_sizes: BTreeMap<u32, (u32, u32)>, +} + +#[cfg(feature = "std")] +#[derive(Clone, Debug, PartialEq)] +pub enum MockCall { + BeginPass { + target: PassTarget, + viewport: RectPx, + }, + SetPipeline { + program: ProgramId, + state: PipelineState, + }, + UniformFloat { + name: &'static str, + value: f32, + }, + Draw { + count: u32, + }, + DrawInstanced { + instances: u32, + }, + EndPass, + CreateTexture3d, + CreateTextureArray, + UpdateTexture, + UpdateTexture3dRegion { + id: TextureId, + offset: [u32; 3], + extent: [u32; 3], + }, + ForwardLights(Vec<ForwardLight>), + GenMips3d, + CreateMrt, + DeleteRenderTarget, +} + +#[cfg(feature = "std")] +impl MockGpu { + fn mint(&mut self) -> u32 { + self.next += 1; + self.next + } + pub fn resources_created(&self) -> u32 { + self.next + } + + pub fn draw_calls(&self) -> usize { + self.log + .iter() + .filter(|c| matches!(c, MockCall::Draw { .. })) + .count() + } + + pub fn pass_targets(&self) -> Vec<PassTarget> { + self.log + .iter() + .filter_map(|c| match c { + MockCall::BeginPass { target, .. } => Some(*target), + _ => None, + }) + .collect() + } +} + +#[cfg(feature = "std")] +impl Gpu for MockGpu { + fn create_buffer(&mut self, _data: &[u8], _usage: BufferUsage) -> BufferId { + BufferId(self.mint()) + } + fn update_buffer(&mut self, _id: BufferId, _data: &[u8]) {} + fn create_program(&mut self, _v: &str, _f: &str) -> ProgramId { + ProgramId(self.mint()) + } + fn create_texture(&mut self, _d: &TextureDesc, _data: Option<&[u8]>) -> TextureId { + TextureId(self.mint()) + } + fn update_texture(&mut self, _id: TextureId, _desc: &TextureDesc, _data: &[u8]) { + self.log.push(MockCall::UpdateTexture); + } + fn create_texture_array(&mut self, _d: &TextureArrayDesc, _data: Option<&[u8]>) -> TextureId { + self.log.push(MockCall::CreateTextureArray); + TextureId(self.mint()) + } + fn bind_texture_array(&mut self, _slot: u32, _tex: TextureId) {} + fn create_render_target(&mut self, d: &RenderTargetDesc) -> RenderTargetId { + let id = RenderTargetId(self.mint()); + self.render_target_sizes.insert(id.0, (d.width, d.height)); + id + } + fn render_target_color(&self, rt: RenderTargetId) -> Option<TextureId> { + Some(TextureId(rt.0 + 100_000)) + } + fn render_target_depth(&self, rt: RenderTargetId) -> Option<TextureId> { + Some(TextureId(rt.0 + 200_000)) + } + fn render_target_size(&self, rt: RenderTargetId) -> Option<(u32, u32)> { + self.render_target_sizes.get(&rt.0).copied() + } + fn begin_pass(&mut self, target: PassTarget, viewport: RectPx, _clear: ClearSpec) { + self.log.push(MockCall::BeginPass { target, viewport }); + } + fn set_pipeline(&mut self, program: ProgramId, state: &PipelineState) { + self.log.push(MockCall::SetPipeline { + program, + state: *state, + }); + } + fn set_forward_lights(&mut self, lights: &[ForwardLight]) { + self.log.push(MockCall::ForwardLights(lights.to_vec())); + } + fn set_uniforms(&mut self, uniforms: &[Uniform]) { + for uniform in uniforms { + if let UniformValue::Float(value) = uniform.value { + self.log.push(MockCall::UniformFloat { + name: uniform.name, + value, + }); + } + } + } + fn bind_texture(&mut self, _slot: u32, _tex: TextureId) {} + fn draw(&mut self, _v: BufferId, _i: Option<BufferId>, _l: &VertexLayout, count: u32) { + self.log.push(MockCall::Draw { count }); + } + fn draw_instanced( + &mut self, + _v: BufferId, + _i: Option<BufferId>, + _l: &VertexLayout, + _index_count: u32, + _instance_buf: BufferId, + _instance_layout: &VertexLayout, + instances: u32, + ) { + self.log.push(MockCall::DrawInstanced { instances }); + } + fn take_error(&mut self) -> Option<GpuError> { + self.error.take() + } + fn caps(&self) -> GpuCaps { + self.caps + } + fn create_texture_3d(&mut self, _d: &Texture3dDesc, _data: Option<&[u8]>) -> TextureId { + self.log.push(MockCall::CreateTexture3d); + TextureId(self.mint()) + } + fn update_texture_3d_region( + &mut self, + id: TextureId, + offset: [u32; 3], + extent: [u32; 3], + _data: &[u8], + ) { + self.log + .push(MockCall::UpdateTexture3dRegion { id, offset, extent }); + } + fn generate_mipmaps_3d(&mut self, _id: TextureId) { + self.log.push(MockCall::GenMips3d); + } + fn bind_texture_3d(&mut self, _slot: u32, _tex: TextureId) {} + fn create_render_target_mrt(&mut self, d: &MrtDesc) -> RenderTargetId { + self.log.push(MockCall::CreateMrt); + let id = RenderTargetId(self.mint()); + self.render_target_sizes.insert(id.0, (d.width, d.height)); + id + } + fn render_target_color_n(&self, rt: RenderTargetId, index: usize) -> Option<TextureId> { + Some(TextureId(rt.0 + 100_000 + index as u32)) + } + fn delete_render_target(&mut self, rt: RenderTargetId) { + self.render_target_sizes.remove(&rt.0); + self.log.push(MockCall::DeleteRenderTarget); + } + fn end_pass(&mut self) { + self.log.push(MockCall::EndPass); + } +} + +/// A no-op backend for headless runtime gating: it runs the entire CPU render +/// path (ECS iteration, matrix math, draw-list/byte packing) while ignoring all +/// GPU work, so the alloc and frame-time gates need no window or GL driver. +/// `no_std`, always available. +#[derive(Default)] +pub struct NullGpu { + next: u32, + render_target_sizes: BTreeMap<u32, (u32, u32)>, +} + +impl NullGpu { + fn mint(&mut self) -> u32 { + self.next += 1; + self.next + } +} + +impl Gpu for NullGpu { + fn create_buffer(&mut self, _data: &[u8], _usage: BufferUsage) -> BufferId { + BufferId(self.mint()) + } + fn update_buffer(&mut self, _id: BufferId, _data: &[u8]) {} + fn create_program(&mut self, _v: &str, _f: &str) -> ProgramId { + ProgramId(self.mint()) + } + fn create_texture(&mut self, _d: &TextureDesc, _data: Option<&[u8]>) -> TextureId { + TextureId(self.mint()) + } + fn create_render_target(&mut self, d: &RenderTargetDesc) -> RenderTargetId { + let id = RenderTargetId(self.mint()); + self.render_target_sizes.insert(id.0, (d.width, d.height)); + id + } + fn render_target_color(&self, rt: RenderTargetId) -> Option<TextureId> { + Some(TextureId(rt.0)) + } + fn render_target_depth(&self, rt: RenderTargetId) -> Option<TextureId> { + Some(TextureId(rt.0)) + } + fn render_target_size(&self, rt: RenderTargetId) -> Option<(u32, u32)> { + self.render_target_sizes.get(&rt.0).copied() + } + fn begin_pass(&mut self, _t: PassTarget, _v: RectPx, _c: ClearSpec) {} + fn set_pipeline(&mut self, _p: ProgramId, _s: &PipelineState) {} + fn set_uniforms(&mut self, _u: &[Uniform]) {} + fn bind_texture(&mut self, _slot: u32, _tex: TextureId) {} + fn draw(&mut self, _v: BufferId, _i: Option<BufferId>, _l: &VertexLayout, _count: u32) {} + fn create_texture_3d(&mut self, _d: &Texture3dDesc, _data: Option<&[u8]>) -> TextureId { + TextureId(self.mint()) + } + fn create_render_target_mrt(&mut self, d: &MrtDesc) -> RenderTargetId { + let id = RenderTargetId(self.mint()); + self.render_target_sizes.insert(id.0, (d.width, d.height)); + id + } + fn render_target_color_n(&self, rt: RenderTargetId, _index: usize) -> Option<TextureId> { + Some(TextureId(rt.0)) + } + fn delete_render_target(&mut self, rt: RenderTargetId) { + self.render_target_sizes.remove(&rt.0); + } + fn end_pass(&mut self) {} +} diff --git a/client-rust/source/engine-render/src/lib.rs b/client-rust/source/engine-render/src/lib.rs new file mode 100644 index 00000000..24c09406 --- /dev/null +++ b/client-rust/source/engine-render/src/lib.rs @@ -0,0 +1,1327 @@ +//! Successor Rust client — render graph. +//! +//! `no_std` + `alloc`. Defines the [`gpu::Gpu`] backend contract, the ECS +//! render components, procedural primitives, block-glyph text layout, and the +//! [`renderer::Renderer`] frame passes (shadow -> ordered cameras with viewport +//! culling -> RTT composite -> text). Platform-free: the `platform` crate +//! supplies the concrete `Gpu`. + +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +pub mod components; +pub mod cursor; +pub mod environment; +pub mod font; +pub mod fx; +pub mod gi; +pub mod gpu; +pub mod model; +pub mod primitives; +pub mod renderer; +pub mod text; +pub mod ui; +pub mod weather; +pub mod window; + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::components::*; + use super::gpu::{ClearSpec, Cull, Gpu, GpuCaps, GpuError, MockCall, MockGpu, PassTarget}; + use super::renderer::{ + MaterialDesc, RenderConfigError, Renderer, RendererInitError, RendererLimits, + }; + use successor_engine_core::ecs::WorldOps; + use successor_engine_core::math::{vec3, Vec2, Vec3}; + use successor_engine_core::world; + + world! { pub struct RWorld { + transform: Transform, + mesh: MeshRenderer, + cutaway: HeightCutaway, + camera: Camera, + light: DirectionalLight, + point_light: PointLight, + composite: CompositeQuad, + text: TextOverlay, + } } + + #[test] + fn viewport_mask_visibility() { + assert!(super::renderer::visible_in(0b01, 0)); + assert!(!super::renderer::visible_in(0b01, 1)); + assert!(super::renderer::visible_in(0b11, 1)); + assert!(super::renderer::visible_in(0b101, 2)); + } + + fn setup_with_limits( + limits: RendererLimits, + ) -> (MockGpu, Renderer, RWorld, MeshId, MaterialId) { + let mut gpu = MockGpu::default(); + let mut r = Renderer::new(&mut gpu, limits).expect("renderer initialization failed"); + let (v, i) = super::primitives::cube(); + let mesh = r.upload_mesh(&mut gpu, &v, &i); + let mat = r.add_material_desc(MaterialDesc { + base_color: [0.8, 0.5, 0.2, 1.0], + ..MaterialDesc::default() + }); + (gpu, r, RWorld::new(), mesh, mat) + } + + fn setup() -> (MockGpu, Renderer, RWorld, MeshId, MaterialId) { + setup_with_limits(RendererLimits::default()) + } + #[test] + fn renderer_init_requires_four_mrt_attachments() { + let mut gpu = MockGpu::default(); + gpu.caps = GpuCaps { + max_color_attachments: 3, + max_draw_buffers: 4, + ..GpuCaps::default() + }; + assert!(matches!( + Renderer::new(&mut gpu, RendererLimits::default()), + Err(RendererInitError::InsufficientMrt) + )); + } + + #[test] + fn renderer_init_surfaces_backend_errors() { + let mut gpu = MockGpu::default(); + gpu.error = Some(GpuError::ShaderCompile); + assert!(matches!( + Renderer::new(&mut gpu, RendererLimits::default()), + Err(RendererInitError::Gpu(GpuError::ShaderCompile)) + )); + } + + #[test] + fn tuning_snapshot_resizes_shadow_target_and_rejects_nonfinite_values() { + let (mut gpu, mut renderer, _, _, _) = setup(); + gpu.log.clear(); + let mut settings = renderer.settings(); + settings.shadows.map_size = 4096; + settings.bloom_radius = 1.75; + settings.palette.enabled = true; + settings.palette.levels = 8; + renderer + .apply_settings(&mut gpu, settings) + .expect("valid settings apply"); + assert_eq!(renderer.settings().shadows.map_size, 4096); + assert_eq!(renderer.settings().palette.levels, 8); + assert_eq!( + gpu.log + .iter() + .filter(|call| matches!(call, MockCall::DeleteRenderTarget)) + .count(), + 1 + ); + + let before = renderer.settings(); + let mut invalid = before; + invalid.exposure = f32::NAN; + assert_eq!( + renderer.apply_settings(&mut gpu, invalid), + Err(RenderConfigError::InvalidSettings) + ); + assert_eq!(renderer.settings().exposure, before.exposure); + } + + #[test] + fn pass_order_shadow_cameras_composite_text() { + let (mut gpu, mut r, mut w, mesh, mat) = setup(); + + // Shadow-casting light. + let l = w.spawn(); + w.set_component( + l, + DirectionalLight { + dir: vec3(-0.4, -1.0, -0.3), + color: [1.0; 3], + cast_shadows: true, + }, + ); + + // Main screen camera (viewport 0) and minimap RTT camera (viewport 1). + let rt = gpu.create_render_target(&super::gpu::RenderTargetDesc { + width: 384, + height: 640, + color: true, + depth: true, + filter: super::gpu::Filter::Nearest, + }); + let cam_main = w.spawn(); + w.set_component( + cam_main, + Camera { + viewport_id: 0, + order: 0, + projection: Projection::Perspective { + fovy: 1.1, + near: 0.1, + far: 200.0, + }, + target: CamTarget::Screen(RectNorm::FULL), + clear: ClearSpec { + color: Some([0.0, 0.0, 0.0, 1.0]), + depth: Some(1.0), + }, + eye: vec3(0.0, 5.0, 10.0), + look_at: Vec3::ZERO, + up: Vec3::Y, + }, + ); + let cam_map = w.spawn(); + w.set_component( + cam_map, + Camera { + viewport_id: 1, + order: -1, // renders BEFORE main (lower order first) + projection: Projection::Ortho { + half_height: 20.0, + near: 0.1, + far: 200.0, + }, + target: CamTarget::Texture(rt), + clear: ClearSpec { + color: Some([0.1, 0.1, 0.1, 1.0]), + depth: Some(1.0), + }, + eye: vec3(0.0, 50.0, 0.0), + look_at: Vec3::ZERO, + up: vec3(0.0, 0.0, -1.0), + }, + ); + + // Two meshes: one visible in both viewports, one only in main. + let e_both = w.spawn(); + w.set_component(e_both, Transform::default()); + w.set_component( + e_both, + MeshRenderer { + mesh, + material: mat, + viewport_mask: 0b11, + ..Default::default() + }, + ); + let e_main = w.spawn(); + w.set_component( + e_main, + Transform { + pos: vec3(3.0, 0.0, 0.0), + ..Transform::default() + }, + ); + w.set_component( + e_main, + MeshRenderer { + mesh, + material: mat, + viewport_mask: 0b01, + ..Default::default() + }, + ); + + // Composite the minimap RT + one HUD text line. + let q = w.spawn(); + w.set_component( + q, + CompositeQuad { + source: rt, + rect: RectNorm { + x: 0.75, + y: 0.75, + w: 0.24, + h: 0.24, + }, + order: 0, + }, + ); + let t = w.spawn(); + w.set_component( + t, + TextOverlay::new("hp 100", Vec2 { x: 0.02, y: 0.05 }, [255, 255, 255, 255]), + ); + + r.render(&mut gpu, &mut w, 1280, 720) + .expect("render failed"); + + let targets = gpu.pass_targets(); + // Deferred sequence: shadow → RTT minimap → G-buffer → scene light → + // opaque copy → transparency → bloom → tonemap LDR → FXAA → overlays. + assert!( + matches!(targets[0], PassTarget::RenderTarget(_)), + "shadow pass first" + ); + assert!( + matches!(targets[1], PassTarget::RenderTarget(_)), + "RTT minimap camera second" + ); + assert!( + matches!(targets[2], PassTarget::RenderTarget(_)), + "G-buffer pass" + ); + assert!( + matches!(targets[3], PassTarget::RenderTarget(_)), + "deferred light → scene RT" + ); + let first_screen = targets + .iter() + .position(|target| *target == PassTarget::Screen) + .expect("FXAA screen pass"); + assert!( + targets[..first_screen] + .iter() + .all(|target| matches!(target, PassTarget::RenderTarget(_))), + "all deferred and post-process targets precede screen presentation" + ); + assert!( + targets[first_screen..] + .iter() + .all(|target| *target == PassTarget::Screen), + "FXAA, composite, and text are screen passes" + ); + assert_eq!( + first_screen, 10, + "FXAA follows tonemap and all linear passes" + ); + assert_eq!(targets[3], targets[5], "transparency composites into scene"); + assert_ne!(targets[3], targets[4], "opaque copy must not alias scene"); + assert_eq!( + targets[6], targets[8], + "vertical bloom returns to extract target" + ); + assert_ne!(targets[6], targets[7], "bloom blur ping-pongs"); + let begin_passes: Vec<_> = gpu + .log + .iter() + .filter_map(|call| match call { + MockCall::BeginPass { target, viewport } => Some((*target, *viewport)), + _ => None, + }) + .collect(); + assert_eq!( + (begin_passes[1].1.w, begin_passes[1].1.h), + (384, 640), + "RTT camera uses its attachment aspect instead of a square screen heuristic", + ); + assert_eq!(begin_passes[6].1.w, 640); + assert_eq!(begin_passes[6].1.h, 360); + assert_eq!(begin_passes[10].0, PassTarget::Screen); + + // G-buffer, scene, opaque copy, bloom pair, and LDR presentation target. + let mrt = gpu + .log + .iter() + .filter(|c| matches!(c, MockCall::CreateMrt)) + .count(); + assert_eq!(mrt, 6, "all deferred screen targets"); + + // Draw-call sanity: shadow (2 casters) + gbuffer main (2) + minimap (1) + // + light fullscreen (1) + tonemap (1) + composite (1) + text (1). + assert!(gpu.draw_calls() > 2 + 2 + 1 + 1 + 1 + 1); + } + + #[test] + fn resize_recreates_deferred_targets() { + let (mut gpu, mut r, mut w, mesh, mat) = setup(); + let l = w.spawn(); + w.set_component( + l, + DirectionalLight { + dir: vec3(-0.4, -1.0, -0.3), + color: [1.0; 3], + cast_shadows: true, + }, + ); + let cam = w.spawn(); + w.set_component( + cam, + Camera { + viewport_id: 0, + order: 0, + projection: Projection::Perspective { + fovy: 1.1, + near: 0.1, + far: 200.0, + }, + target: CamTarget::Screen(RectNorm::FULL), + clear: ClearSpec { + color: Some([0.0, 0.0, 0.0, 1.0]), + depth: Some(1.0), + }, + eye: vec3(0.0, 5.0, 10.0), + look_at: Vec3::ZERO, + up: Vec3::Y, + }, + ); + let e = w.spawn(); + w.set_component(e, Transform::default()); + w.set_component( + e, + MeshRenderer { + mesh, + material: mat, + viewport_mask: 0b01, + ..Default::default() + }, + ); + + r.render(&mut gpu, &mut w, 800, 600).expect("render failed"); + gpu.log.clear(); + // Same size → no target churn. + r.render(&mut gpu, &mut w, 800, 600).expect("render failed"); + assert_eq!( + gpu.log + .iter() + .filter(|c| matches!(c, MockCall::DeleteRenderTarget)) + .count(), + 0 + ); + assert_eq!( + gpu.log + .iter() + .filter(|c| matches!(c, MockCall::CreateMrt)) + .count(), + 0 + ); + gpu.log.clear(); + // Different size → old targets deleted, new ones created. + r.render(&mut gpu, &mut w, 1024, 768) + .expect("render failed"); + assert_eq!( + gpu.log + .iter() + .filter(|c| matches!(c, MockCall::DeleteRenderTarget)) + .count(), + 6 + ); + assert_eq!( + gpu.log + .iter() + .filter(|c| matches!(c, MockCall::CreateMrt)) + .count(), + 6 + ); + } + + #[test] + fn zero_sized_frame_is_a_no_op() { + let (mut gpu, mut renderer, mut world, _, _) = deferred_scene(); + gpu.log.clear(); + renderer + .render(&mut gpu, &mut world, 0, 720) + .expect("zero-sized frame"); + assert!(gpu.log.is_empty()); + } + + #[test] + fn failed_target_resize_is_reported_and_new_targets_are_discarded() { + let (mut gpu, mut renderer, mut world, _, _) = deferred_scene(); + gpu.log.clear(); + gpu.error = Some(GpuError::IncompleteFramebuffer); + assert_eq!( + renderer.render(&mut gpu, &mut world, 1280, 720), + Err(GpuError::IncompleteFramebuffer) + ); + assert_eq!( + gpu.log + .iter() + .filter(|call| matches!(call, MockCall::CreateMrt)) + .count(), + 6 + ); + assert_eq!( + gpu.log + .iter() + .filter(|call| matches!(call, MockCall::DeleteRenderTarget)) + .count(), + 6 + ); + gpu.log.clear(); + renderer + .render(&mut gpu, &mut world, 1280, 720) + .expect("retry after target failure"); + assert_eq!( + gpu.log + .iter() + .filter(|call| matches!(call, MockCall::CreateMrt)) + .count(), + 6 + ); + } + + fn deferred_scene() -> (MockGpu, Renderer, RWorld, MeshId, MaterialId) { + deferred_scene_with_limits(RendererLimits::default()) + } + + fn deferred_scene_with_limits( + limits: RendererLimits, + ) -> (MockGpu, Renderer, RWorld, MeshId, MaterialId) { + let (mut gpu, r, mut w, mesh, mat) = setup_with_limits(limits); + let l = w.spawn(); + w.set_component( + l, + DirectionalLight { + dir: vec3(-0.4, -1.0, -0.3), + color: [1.0; 3], + cast_shadows: true, + }, + ); + let cam = w.spawn(); + w.set_component( + cam, + Camera { + viewport_id: 0, + order: 0, + projection: Projection::Perspective { + fovy: 1.1, + near: 0.1, + far: 200.0, + }, + target: CamTarget::Screen(RectNorm::FULL), + clear: ClearSpec { + color: Some([0.0, 0.0, 0.0, 1.0]), + depth: Some(1.0), + }, + eye: vec3(0.0, 5.0, 10.0), + look_at: Vec3::ZERO, + up: Vec3::Y, + }, + ); + let e = w.spawn(); + w.set_component(e, Transform::default()); + w.set_component( + e, + MeshRenderer { + mesh, + material: mat, + viewport_mask: 0b01, + ..Default::default() + }, + ); + let _ = &mut gpu; + (gpu, r, w, mesh, mat) + } + + #[test] + fn mastered_shader_controls_are_emitted_as_runtime_uniforms() { + let (mut gpu, mut renderer, mut world, _, _) = deferred_scene(); + let mut settings = renderer.settings(); + settings.emissive_scalar = 2.25; + settings.ao_intensity = 1.75; + settings.exposure = 1.4; + settings.bloom_radius = 1.6; + settings.aa.edge_threshold = 0.08; + renderer + .apply_settings(&mut gpu, settings) + .expect("settings apply"); + gpu.log.clear(); + renderer + .render(&mut gpu, &mut world, 640, 480) + .expect("render"); + let has = |name: &'static str, expected: f32| { + gpu.log.iter().any(|call| { + matches!( + call, + MockCall::UniformFloat { name: actual, value } + if *actual == name && (*value - expected).abs() < 1.0e-6 + ) + }) + }; + assert!(has("u_emissiveScalar", 2.25)); + assert!(has("u_aoIntensity", 1.75)); + // Global mastering must not also be baked into material uniforms. + assert!(has("u_emissiveStrength", 1.0)); + assert!(has("u_aoStrength", 1.0)); + assert!(has("u_masterExposure", 1.4)); + assert!(has("u_edgeThreshold", 0.08)); + } + + #[test] + fn forward_materials_apply_master_ao_and_emissive_once() { + let (mut gpu, mut renderer, mut world, _, material) = deferred_scene(); + renderer.update_material_desc( + material, + MaterialDesc { + blend: true, + emissive_factor: [1.0, 0.5, 0.25], + emissive_strength: 2.0, + metallic: 0.35, + roughness: 0.42, + ior: 1.7, + specular: 0.6, + ..MaterialDesc::default() + }, + ); + let mut settings = renderer.settings(); + settings.emissive_scalar = 3.0; + settings.ao_intensity = 1.75; + renderer + .apply_settings(&mut gpu, settings) + .expect("settings apply"); + gpu.log.clear(); + renderer + .render(&mut gpu, &mut world, 640, 480) + .expect("render"); + let has = |name: &'static str, expected: f32| { + gpu.log.iter().any(|call| { + matches!( + call, + MockCall::UniformFloat { name: actual, value } + if *actual == name && (*value - expected).abs() < 1.0e-6 + ) + }) + }; + assert!(has("u_emissiveStrength", 6.0)); + assert!(has("u_aoIntensity", 1.75)); + assert!(has("u_metallic", 0.35)); + assert!(has("u_roughness", 0.42)); + assert!(has("u_ior", 1.7)); + let ratio = (1.7 - 1.0) / (1.7 + 1.0); + assert!(has("u_dielectricF0", ratio * ratio * 0.6)); + } + + #[test] + fn point_light_pass_only_when_lights_present() { + // No point lights → no instanced draw. + let (mut gpu, mut r, mut w, _, _) = deferred_scene(); + r.render(&mut gpu, &mut w, 640, 480).expect("render failed"); + assert_eq!( + gpu.log + .iter() + .filter(|c| matches!(c, MockCall::DrawInstanced { .. })) + .count(), + 0, + "no point-light volumes without lights" + ); + + // One point light → exactly one instanced draw of one instance. + let (mut gpu, mut r, mut w, _, _) = deferred_scene(); + let pl = w.spawn(); + w.set_component( + pl, + Transform { + pos: vec3(1.0, 1.0, 1.0), + ..Transform::default() + }, + ); + w.set_component( + pl, + PointLight { + color: [1.0, 0.8, 0.5], + intensity: 5.0, + radius: 4.0, + }, + ); + r.render(&mut gpu, &mut w, 640, 480).expect("render failed"); + let inst: Vec<u32> = gpu + .log + .iter() + .filter_map(|c| match c { + MockCall::DrawInstanced { instances } => Some(*instances), + _ => None, + }) + .collect(); + assert_eq!( + inst, + vec![1], + "one instanced point-light draw of 1 instance" + ); + } + + #[test] + fn point_lights_are_capped_ranked_and_filtered() { + let mut limits = RendererLimits::default(); + limits.max_point_lights = 2; + let (mut gpu, mut renderer, mut world, _, _) = deferred_scene_with_limits(limits); + for (index, z) in [10.5, 13.0, 20.0].into_iter().enumerate() { + let entity = world.spawn(); + world.set_component( + entity, + Transform { + pos: vec3(index as f32, 5.0, z), + ..Transform::default() + }, + ); + world.set_component( + entity, + PointLight { + color: [1.0, 0.5, 0.25], + intensity: 2.0, + radius: 1.0, + }, + ); + } + let invalid = world.spawn(); + world.set_component(invalid, Transform::default()); + world.set_component( + invalid, + PointLight { + color: [1.0; 3], + intensity: 0.0, + radius: 10.0, + }, + ); + renderer + .render(&mut gpu, &mut world, 640, 480) + .expect("render failed"); + let instances: Vec<u32> = gpu + .log + .iter() + .filter_map(|call| match call { + MockCall::DrawInstanced { instances } => Some(*instances), + _ => None, + }) + .collect(); + assert_eq!(instances, vec![2]); + } + + #[test] + fn point_light_ranking_is_independent_of_mesh_draw_capacity() { + let mut limits = RendererLimits::default(); + limits.max_draws = 1; + limits.max_point_lights = 1; + let (mut gpu, mut renderer, mut world, _, material) = deferred_scene_with_limits(limits); + renderer.update_material_desc( + material, + MaterialDesc { + blend: true, + ..MaterialDesc::default() + }, + ); + for (z, color) in [(30.0, [1.0, 0.0, 0.0]), (10.5, [0.0, 1.0, 0.0])] { + let entity = world.spawn(); + world.set_component( + entity, + Transform { + pos: vec3(0.0, 5.0, z), + ..Transform::default() + }, + ); + world.set_component( + entity, + PointLight { + color, + intensity: 1.0, + radius: 0.5, + }, + ); + } + + renderer + .render(&mut gpu, &mut world, 640, 480) + .expect("render"); + let selected = gpu + .log + .iter() + .filter_map(|call| match call { + MockCall::ForwardLights(lights) => Some(lights), + _ => None, + }) + .next_back() + .expect("forward light selection"); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].color, [0.0, 1.0, 0.0]); + } + + #[test] + fn camera_cap_is_filtered_deterministic_and_shared_with_transparency() { + let mut limits = RendererLimits::default(); + limits.max_point_lights = 2; + let (mut gpu, mut renderer, mut world, mesh, _) = deferred_scene_with_limits(limits); + let transparent = renderer.add_material_desc(MaterialDesc { + base_color: [1.0, 1.0, 1.0, 0.5], + blend: true, + ..MaterialDesc::default() + }); + let draw = world.spawn(); + world.set_component(draw, Transform::default()); + world.set_component( + draw, + MeshRenderer { + mesh, + material: transparent, + viewport_mask: 0b01, + ..Default::default() + }, + ); + + for (position, color, intensity, radius) in [ + (vec3(0.0, 5.0, 10.0), [0.2, 0.4, 0.8], 3.0, 0.5), + (vec3(0.0, 5.0, 12.0), [0.9, 0.3, 0.1], 5.0, 0.5), + (Vec3::ZERO, [0.1, 1.0, 0.2], 9.0, 0.5), + (vec3(0.0, 5.0, 10.0), [0.0; 3], 10.0, 10.0), + ] { + let entity = world.spawn(); + world.set_component( + entity, + Transform { + pos: position, + ..Transform::default() + }, + ); + world.set_component( + entity, + PointLight { + color, + intensity, + radius, + }, + ); + } + + renderer + .render(&mut gpu, &mut world, 640, 480) + .expect("first render"); + let selected = gpu + .log + .iter() + .filter_map(|call| match call { + MockCall::ForwardLights(lights) => Some(lights.clone()), + _ => None, + }) + .next_back() + .expect("transparent light upload"); + assert_eq!( + selected, + vec![ + crate::gpu::ForwardLight { + position: [0.0, 5.0, 10.0], + radius: 0.5, + color: [0.2, 0.4, 0.8], + intensity: 3.0, + }, + crate::gpu::ForwardLight { + position: [0.0, 5.0, 12.0], + radius: 0.5, + color: [0.9, 0.3, 0.1], + intensity: 5.0, + }, + ] + ); + assert_eq!( + gpu.log + .iter() + .filter_map(|call| match call { + MockCall::DrawInstanced { instances } => Some(*instances), + _ => None, + }) + .collect::<Vec<_>>(), + vec![2], + ); + + gpu.log.clear(); + renderer + .render(&mut gpu, &mut world, 640, 480) + .expect("repeat render"); + assert_eq!( + gpu.log + .iter() + .filter(|call| matches!(call, MockCall::ForwardLights(_))) + .count(), + 1, + "camera scratch must not retain a prior frame", + ); + } + + #[test] + fn equal_distance_light_cap_uses_entity_identity_as_the_tie_break() { + let mut limits = RendererLimits::default(); + limits.max_point_lights = 2; + let (mut gpu, mut renderer, mut world, mesh, _) = deferred_scene_with_limits(limits); + let transparent = renderer.add_material_desc(MaterialDesc { + blend: true, + ..MaterialDesc::default() + }); + let draw = world.spawn(); + world.set_component( + draw, + Transform { + pos: vec3(0.0, 5.0, 10.0), + ..Transform::default() + }, + ); + world.set_component( + draw, + MeshRenderer { + mesh, + material: transparent, + viewport_mask: 0b01, + ..Default::default() + }, + ); + for position in [[1.0, 5.0, 10.0], [-1.0, 5.0, 10.0], [0.0, 6.0, 10.0]] { + let entity = world.spawn(); + world.set_component( + entity, + Transform { + pos: vec3(position[0], position[1], position[2]), + ..Transform::default() + }, + ); + world.set_component( + entity, + PointLight { + color: [1.0; 3], + intensity: 1.0, + radius: 0.25, + }, + ); + } + renderer + .render(&mut gpu, &mut world, 640, 480) + .expect("tied lights"); + let selected = gpu + .log + .iter() + .filter_map(|call| match call { + MockCall::ForwardLights(lights) => Some(lights), + _ => None, + }) + .next_back() + .expect("forward lights"); + assert_eq!(selected.len(), 2); + assert_eq!(selected[0].position, [1.0, 5.0, 10.0]); + assert_eq!(selected[1].position, [-1.0, 5.0, 10.0]); + } + + #[test] + fn opaque_materials_write_depth_and_honor_cull_mode() { + let (mut gpu, mut renderer, mut world, mesh, _) = deferred_scene(); + let double_sided = renderer.add_material_desc(MaterialDesc { + double_sided: true, + ..MaterialDesc::default() + }); + let entity = world.spawn(); + world.set_component(entity, Transform::default()); + world.set_component( + entity, + MeshRenderer { + mesh, + material: double_sided, + viewport_mask: 0b01, + ..Default::default() + }, + ); + + renderer + .render(&mut gpu, &mut world, 640, 480) + .expect("opaque render"); + let opaque_states: Vec<_> = gpu + .log + .iter() + .filter_map(|call| match call { + MockCall::SetPipeline { state, .. } + if state.depth_test + && state.depth_write + && state.color_write + && !state.blend => + { + Some(*state) + } + _ => None, + }) + .collect(); + assert!( + opaque_states.iter().any(|state| state.cull == Cull::Back), + "single-sided opaque material must back-face cull" + ); + assert!( + opaque_states.iter().any(|state| state.cull == Cull::None), + "double-sided opaque material must disable culling" + ); + assert!( + opaque_states + .iter() + .all(|state| state.depth_test && state.depth_write && !state.blend), + "opaque material pipelines must test/write depth without blending" + ); + } + + #[test] + fn transparent_meshes_draw_far_to_near_across_meshes() { + let (mut gpu, mut renderer, mut world, near_mesh, _) = deferred_scene(); + let (vertices, indices) = super::primitives::cube(); + let far_mesh = renderer.upload_mesh(&mut gpu, &vertices, &indices[..3]); + let transparent = renderer.add_material_desc(MaterialDesc { + base_color: [0.5, 0.7, 1.0, 0.5], + blend: true, + ..MaterialDesc::default() + }); + for (mesh, z) in [(far_mesh, -20.0), (near_mesh, 8.0)] { + let entity = world.spawn(); + world.set_component( + entity, + Transform { + pos: vec3(0.0, 0.0, z), + ..Transform::default() + }, + ); + world.set_component( + entity, + MeshRenderer { + mesh, + material: transparent, + viewport_mask: 0b01, + ..Default::default() + }, + ); + } + + renderer + .render(&mut gpu, &mut world, 640, 480) + .expect("transparent render"); + let mesh_draw_counts: Vec<_> = gpu + .log + .iter() + .filter_map(|call| match call { + MockCall::Draw { count } if *count == 3 || *count == 36 => Some(*count), + _ => None, + }) + .collect(); + assert!( + mesh_draw_counts.ends_with(&[3, 36]), + "transparent draws must be globally sorted far-to-near: {mesh_draw_counts:?}" + ); + } + + #[test] + fn transparent_draw_receives_nearest_thirty_two_point_lights() { + let (mut gpu, mut renderer, mut world, mesh, _) = deferred_scene(); + let transparent = renderer.add_material_desc(MaterialDesc { + base_color: [1.0, 1.0, 1.0, 0.5], + blend: true, + ..MaterialDesc::default() + }); + let entity = world.spawn(); + world.set_component(entity, Transform::default()); + world.set_component( + entity, + MeshRenderer { + mesh, + material: transparent, + viewport_mask: 0b01, + ..Default::default() + }, + ); + for index in 0..33 { + let light = world.spawn(); + world.set_component( + light, + Transform { + pos: vec3(index as f32, 0.0, 0.0), + ..Transform::default() + }, + ); + world.set_component( + light, + PointLight { + color: [1.0, 0.5, 0.25], + intensity: 2.0, + radius: 10.0, + }, + ); + } + + renderer + .render(&mut gpu, &mut world, 640, 480) + .expect("transparent point lights"); + let lights = gpu + .log + .iter() + .filter_map(|call| match call { + MockCall::ForwardLights(lights) => Some(lights), + _ => None, + }) + .next_back() + .expect("forward light upload"); + assert_eq!(lights.len(), 32); + assert_eq!(lights.first().expect("nearest").position, [0.0, 0.0, 0.0]); + assert_eq!( + lights.last().expect("furthest selected").position, + [31.0, 0.0, 0.0] + ); + } + + #[test] + fn point_light_scale_scales_gathered_lights_and_filters_at_zero() { + let (mut gpu, mut renderer, mut world, mesh, _) = deferred_scene(); + let transparent = renderer.add_material_desc(MaterialDesc { + base_color: [1.0, 1.0, 1.0, 0.5], + blend: true, + ..MaterialDesc::default() + }); + let entity = world.spawn(); + world.set_component(entity, Transform::default()); + world.set_component( + entity, + MeshRenderer { + mesh, + material: transparent, + viewport_mask: 0b01, + ..Default::default() + }, + ); + let light = world.spawn(); + world.set_component( + light, + Transform { + pos: vec3(0.0, 1.0, 0.0), + ..Transform::default() + }, + ); + world.set_component( + light, + PointLight { + color: [1.0, 0.5, 0.25], + intensity: 2.0, + radius: 10.0, + }, + ); + + let mut settings = renderer.settings(); + settings.point_light_scale = 1.5; + renderer + .apply_settings(&mut gpu, settings) + .expect("settings apply"); + renderer + .render(&mut gpu, &mut world, 640, 480) + .expect("scaled render"); + let lights = gpu + .log + .iter() + .filter_map(|call| match call { + MockCall::ForwardLights(lights) => Some(lights), + _ => None, + }) + .next_back() + .expect("forward light upload"); + assert_eq!(lights.len(), 1); + assert_eq!(lights[0].intensity, 3.0); + + // Zero scale suppresses every gathered point light. + let mut settings = renderer.settings(); + settings.point_light_scale = 0.0; + renderer + .apply_settings(&mut gpu, settings) + .expect("settings apply"); + gpu.log.clear(); + renderer + .render(&mut gpu, &mut world, 640, 480) + .expect("zero-scaled render"); + assert_eq!( + gpu.log + .iter() + .filter(|c| matches!(c, MockCall::DrawInstanced { .. })) + .count(), + 0, + "zero point-light scale draws no light volumes" + ); + let lights = gpu + .log + .iter() + .filter_map(|call| match call { + MockCall::ForwardLights(lights) => Some(lights), + _ => None, + }) + .next_back() + .expect("forward light upload"); + assert!(lights.is_empty(), "zero scale gathers no lights"); + } + + #[test] + fn transmission_without_alpha_blend_uses_sampled_depth_pipeline() { + let (mut gpu, mut renderer, mut world, mesh, _) = deferred_scene(); + let transmissive = renderer.add_material_desc(MaterialDesc { + transmission: 0.9, + ior: 1.45, + ..MaterialDesc::default() + }); + let entity = world.spawn(); + world.set_component(entity, Transform::default()); + world.set_component( + entity, + MeshRenderer { + mesh, + material: transmissive, + viewport_mask: 0b01, + ..Default::default() + }, + ); + + renderer + .render(&mut gpu, &mut world, 640, 480) + .expect("transmission render"); + assert!( + gpu.log.iter().any(|call| matches!( + call, + MockCall::SetPipeline { + state, + .. + } if !state.depth_test && !state.depth_write && state.blend + )), + "transmission must use sorted blending with sampled opaque depth" + ); + } + + #[test] + fn rgba8_bloom_preserves_scene_linear_threshold_and_applies_presentation_gain() { + let (mut gpu, mut renderer, mut world, _, _) = deferred_scene(); + renderer.set_bloom(2.0, 0.5).expect("valid bloom"); + renderer + .render(&mut gpu, &mut world, 640, 480) + .expect("RGBA8 fallback render"); + assert!(gpu.log.iter().any(|call| matches!( + call, + MockCall::UniformFloat { + name: "u_threshold", + value + } if (*value - 0.5).abs() < f32::EPSILON + ))); + assert!(gpu.log.iter().any(|call| matches!( + call, + MockCall::UniformFloat { + name: "u_invExposure", + value + } if (*value - 4.0).abs() < f32::EPSILON + ))); + assert!(gpu.log.iter().any(|call| matches!( + call, + MockCall::UniformFloat { + name: "u_bloomIntensity", + value + } if (*value - 1.0).abs() < f32::EPSILON + ))); + } + + #[test] + fn camera_motion_does_not_schedule_gi_work() { + let (mut gpu, mut r, mut w, _, _) = deferred_scene(); + for _ in 0..64 { + r.render(&mut gpu, &mut w, 640, 480).expect("render failed"); + if r.gi_is_idle() { + break; + } + } + assert!(r.gi_is_idle()); + let before = r.gi_work_counters(); + gpu.log.clear(); + { + let mut cameras = w.query1::<Camera>(); + let (_, camera) = cameras.next().expect("screen camera"); + camera.eye = vec3(8.0, 7.0, 12.0); + camera.look_at = vec3(-8.0, 0.0, 4.0); + } + r.render(&mut gpu, &mut w, 640, 480).expect("render failed"); + assert_eq!(r.gi_work_counters(), before); + assert!(!gpu.log.iter().any(|call| matches!( + call, + MockCall::UpdateTexture3dRegion { .. } | MockCall::GenMips3d + ))); + } + + fn shadow_pass_draws(gpu: &MockGpu) -> usize { + let start = gpu + .log + .iter() + .position(|call| matches!(call, MockCall::BeginPass { .. })) + .unwrap(); + let end = gpu.log[start..] + .iter() + .position(|call| matches!(call, MockCall::EndPass)) + .map(|offset| start + offset) + .unwrap(); + gpu.log[start..end] + .iter() + .filter(|call| matches!(call, MockCall::Draw { .. })) + .count() + } + + #[test] + fn zero_viewport_mask_skips_camera_and_shadow_passes() { + let (mut gpu, mut renderer, mut world, mesh, material) = deferred_scene(); + let hidden = world.spawn(); + world.set_component(hidden, Transform::default()); + world.set_component( + hidden, + MeshRenderer { + mesh, + material, + viewport_mask: 0, + ..Default::default() + }, + ); + gpu.log.clear(); + renderer + .render(&mut gpu, &mut world, 640, 480) + .expect("render failed"); + assert_eq!( + shadow_pass_draws(&gpu), + 1, + "fully hidden entities must not leave depth-only shadows" + ); + } + + #[test] + fn skinned_mesh_draws_in_shadow_pass() { + let (mut gpu, mut r, mut w, mesh, mat) = deferred_scene(); + let static_entity = w.spawn(); + w.set_component(static_entity, Transform::default()); + w.set_component( + static_entity, + MeshRenderer { + mesh, + material: mat, + viewport_mask: 1, + ..Default::default() + }, + ); + let skinned_vertices = vec![0.0f32; 16 * 3]; + let skinned = r.upload_skinned_mesh(&mut gpu, &skinned_vertices, &[0, 1, 2]); + r.begin_skin_frame(); + let offset = r.push_skin_palette(&[[ + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ]]); + let pawn = w.spawn(); + w.set_component(pawn, Transform::default()); + w.set_component( + pawn, + MeshRenderer { + mesh: skinned, + material: mat, + viewport_mask: 1, + skin: SkinRef { offset, count: 1 }, + }, + ); + gpu.log.clear(); + r.render(&mut gpu, &mut w, 640, 480).expect("render failed"); + assert_eq!(shadow_pass_draws(&gpu), 3); + } + + #[test] + fn invalid_skin_ref_is_not_drawn() { + let (mut gpu, mut r, mut w, _, mat) = deferred_scene(); + let skinned_vertices = vec![0.0f32; 16 * 3]; + let skinned = r.upload_skinned_mesh(&mut gpu, &skinned_vertices, &[0, 1, 2]); + let pawn = w.spawn(); + w.set_component(pawn, Transform::default()); + w.set_component( + pawn, + MeshRenderer { + mesh: skinned, + material: mat, + viewport_mask: 1, + skin: SkinRef { + offset: 9, + count: 1, + }, + }, + ); + gpu.log.clear(); + r.render(&mut gpu, &mut w, 640, 480).expect("render failed"); + assert_eq!(shadow_pass_draws(&gpu), 1); + } +} diff --git a/client-rust/source/engine-render/src/model.rs b/client-rust/source/engine-render/src/model.rs new file mode 100644 index 00000000..4738e242 --- /dev/null +++ b/client-rust/source/engine-render/src/model.rs @@ -0,0 +1,588 @@ +//! Shared GLB mesh/material upload path. + +use alloc::collections::BTreeMap; +use alloc::vec; +use alloc::vec::Vec; + +use successor_engine_core::glb::{AlphaMode, GlbDocument, GlbPrimitive, TextureRef}; +use successor_engine_core::image::{decode_image, RgbaImage}; + +use crate::components::{MaterialId, MeshId}; +use crate::gpu::{Filter, Gpu, GpuError, MinFilter, TextureDesc, TextureFormat, TextureId, Wrap}; +use crate::renderer::{MaterialDesc, Renderer}; + +#[derive(Clone, Copy, Debug)] +pub struct UploadedPrimitive { + pub mesh: MeshId, + pub material: MaterialId, + pub source_mesh: usize, + pub source_primitive: usize, +} + +#[derive(Clone, Debug, Default)] +pub struct UploadedModel { + pub primitives: Vec<UploadedPrimitive>, + pub node_meshes: Vec<Option<usize>>, + pub node_skins: Vec<Option<usize>>, + pub material_emissive_texture_means: Vec<Option<[f32; 3]>>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ModelUploadError { + Image, + TextureIndex, + ImageIndex, + VertexCount, + JointPalette, + Gpu(GpuError), +} + +#[derive(Clone, Copy)] +struct TextureCacheEntry { + source: usize, + sampler: Option<usize>, + srgb: bool, + uploaded: TextureId, +} + +struct PreparedPrimitive { + source_mesh: usize, + source_primitive: usize, + vertices: Vec<u8>, + indices: Vec<u32>, +} + +/// CPU-side validation and image decode completed before any renderer or GPU +/// resource is registered. Callers may inspect emissive means and reject an +/// invalid model without retaining partial uploads. +pub struct PreparedModel { + primitives: Vec<PreparedPrimitive>, + decoded_images: Vec<Option<RgbaImage>>, + pub material_emissive_texture_means: Vec<Option<[f32; 3]>>, +} +/// PawnForge's shipped humanoid rigs use 50 joints. Keeping the shader palette +/// at that authored maximum leaves room for the other vertex uniforms on +/// WebGL2 implementations that expose only the required 256 uniform vectors. +const MAX_SKIN_JOINTS: usize = 50; + +pub fn prepare_glb(document: &GlbDocument) -> Result<PreparedModel, ModelUploadError> { + if document + .skins + .iter() + .any(|skin| skin.joints.len() > MAX_SKIN_JOINTS) + { + return Err(ModelUploadError::JointPalette); + } + let mut prepared = PreparedModel { + primitives: Vec::new(), + decoded_images: vec![None; document.images.len()], + material_emissive_texture_means: vec![None; document.materials.len()], + }; + for (mesh_index, mesh) in document.meshes.iter().enumerate() { + for (primitive_index, primitive) in mesh.primitives.iter().enumerate() { + let (vertices, indices) = pack_primitive(primitive)?; + if let Some((material_index, material)) = primitive.material.and_then(|index| { + document + .materials + .get(index) + .map(|material| (index, material)) + }) { + for reference in [ + material.base_color_texture, + material.metallic_roughness_texture, + material.normal_texture, + material.occlusion_texture, + ] { + prepare_texture(document, &mut prepared.decoded_images, reference, false)?; + } + prepared.material_emissive_texture_means[material_index] = prepare_texture( + document, + &mut prepared.decoded_images, + material.emissive_texture, + true, + )?; + } + prepared.primitives.push(PreparedPrimitive { + source_mesh: mesh_index, + source_primitive: primitive_index, + vertices, + indices, + }); + } + } + Ok(prepared) +} + +pub fn upload_glb<G: Gpu>( + renderer: &mut Renderer, + gpu: &mut G, + document: &GlbDocument, +) -> Result<UploadedModel, ModelUploadError> { + let prepared = prepare_glb(document)?; + upload_prepared_glb(renderer, gpu, document, prepared) +} + +pub fn upload_prepared_glb<G: Gpu>( + renderer: &mut Renderer, + gpu: &mut G, + document: &GlbDocument, + prepared: PreparedModel, +) -> Result<UploadedModel, ModelUploadError> { + let PreparedModel { + primitives, + decoded_images, + material_emissive_texture_means, + } = prepared; + let mut uploaded = UploadedModel { + primitives: Vec::with_capacity(primitives.len()), + node_meshes: document.nodes.iter().map(|node| node.mesh).collect(), + node_skins: document.nodes.iter().map(|node| node.skin).collect(), + material_emissive_texture_means, + }; + let mut texture_cache: Vec<TextureCacheEntry> = Vec::new(); + for prepared in primitives { + let primitive = + &document.meshes[prepared.source_mesh].primitives[prepared.source_primitive]; + let mesh_id = renderer.upload_gltf_mesh( + gpu, + &prepared.vertices, + &prepared.indices, + !primitive.joints.is_empty(), + !primitive.colors.is_empty(), + ); + let material = match primitive + .material + .and_then(|index| document.materials.get(index)) + { + Some(source) => { + let base_color_texture = upload_texture( + document, + &decoded_images, + gpu, + &mut texture_cache, + source.base_color_texture, + true, + )?; + let metallic_roughness_texture = upload_texture( + document, + &decoded_images, + gpu, + &mut texture_cache, + source.metallic_roughness_texture, + false, + )?; + let normal_texture = upload_texture( + document, + &decoded_images, + gpu, + &mut texture_cache, + source.normal_texture, + false, + )?; + let occlusion_texture = upload_texture( + document, + &decoded_images, + gpu, + &mut texture_cache, + source.occlusion_texture, + false, + )?; + let emissive_texture = upload_texture( + document, + &decoded_images, + gpu, + &mut texture_cache, + source.emissive_texture, + true, + )?; + renderer.add_material_desc(MaterialDesc { + base_color: source.base_color, + base_color_texture, + metallic_roughness_texture, + normal_texture, + occlusion_texture, + emissive_texture, + metallic: source.metallic, + roughness: source.roughness, + normal_scale: source.normal_scale, + occlusion_strength: source.occlusion_strength, + emissive_factor: source.emissive_factor, + emissive_strength: source.emissive_strength, + clearcoat: source.clearcoat, + clearcoat_roughness: source.clearcoat_roughness, + specular: source.specular, + ior: source.ior, + transmission: source.transmission, + alpha_cutoff: source.alpha_cutoff, + double_sided: source.double_sided, + blend: source.alpha_mode == AlphaMode::Blend || source.transmission > 0.0, + terrain: None, + }) + } + None => renderer.add_material_desc(MaterialDesc::default()), + }; + uploaded.primitives.push(UploadedPrimitive { + mesh: mesh_id, + material, + source_mesh: prepared.source_mesh, + source_primitive: prepared.source_primitive, + }); + } + if let Some(error) = gpu.take_error() { + return Err(ModelUploadError::Gpu(error)); + } + Ok(uploaded) +} + +fn prepare_texture( + document: &GlbDocument, + decoded_images: &mut [Option<RgbaImage>], + reference: Option<TextureRef>, + emissive: bool, +) -> Result<Option<[f32; 3]>, ModelUploadError> { + let Some(reference) = reference else { + return Ok(None); + }; + if reference.tex_coord != 0 { + return Err(ModelUploadError::TextureIndex); + } + let texture = document + .textures + .get(reference.texture) + .ok_or(ModelUploadError::TextureIndex)?; + let image = document + .images + .get(texture.source) + .ok_or(ModelUploadError::ImageIndex)?; + let decoded = decoded_images + .get_mut(texture.source) + .ok_or(ModelUploadError::ImageIndex)?; + if decoded.is_none() { + *decoded = Some( + decode_image(&image.mime_type, &image.bytes).map_err(|_| ModelUploadError::Image)?, + ); + } + Ok(emissive + .then(|| emissive_texture_mean_linear(&decoded.as_ref().expect("decoded image").pixels))) +} + +fn upload_texture<G: Gpu>( + document: &GlbDocument, + decoded_images: &[Option<RgbaImage>], + gpu: &mut G, + cache: &mut Vec<TextureCacheEntry>, + reference: Option<TextureRef>, + srgb: bool, +) -> Result<Option<TextureId>, ModelUploadError> { + let Some(reference) = reference else { + return Ok(None); + }; + if reference.tex_coord != 0 { + return Err(ModelUploadError::TextureIndex); + } + let texture = document + .textures + .get(reference.texture) + .ok_or(ModelUploadError::TextureIndex)?; + if let Some(entry) = cache.iter().find(|entry| { + entry.source == texture.source && entry.sampler == texture.sampler && entry.srgb == srgb + }) { + return Ok(Some(entry.uploaded)); + } + let decoded = decoded_images + .get(texture.source) + .and_then(Option::as_ref) + .ok_or(ModelUploadError::ImageIndex)?; + let sampler = texture + .sampler + .and_then(|index| document.texture_samplers.get(index)); + let mag_filter = match sampler.map(|value| value.mag_filter) { + Some(successor_engine_core::glb::MagFilter::Nearest) => Filter::Nearest, + _ => Filter::Linear, + }; + let min_filter = match sampler.map(|value| value.min_filter) { + Some(successor_engine_core::glb::MinFilter::NearestMipmapNearest) => { + MinFilter::NearestMipmapNearest + } + _ => MinFilter::LinearMipmapLinear, + }; + let map_wrap = |value| match value { + Some(successor_engine_core::glb::WrapMode::ClampToEdge) => Wrap::ClampToEdge, + _ => Wrap::Repeat, + }; + let uploaded = gpu.create_texture( + &TextureDesc { + width: decoded.width, + height: decoded.height, + format: if srgb { + TextureFormat::Srgba8 + } else { + TextureFormat::Rgba8 + }, + mag_filter, + min_filter, + wrap_s: map_wrap(sampler.map(|value| value.wrap_s)), + wrap_t: map_wrap(sampler.map(|value| value.wrap_t)), + mipmaps: true, + }, + Some(&decoded.pixels), + ); + cache.push(TextureCacheEntry { + source: texture.source, + sampler: texture.sampler, + srgb, + uploaded, + }); + Ok(Some(uploaded)) +} + +pub fn emissive_texture_mean_linear(pixels: &[u8]) -> [f32; 3] { + let mut sum = [0.0; 3]; + let mut count = 0.0; + for rgba in pixels.chunks_exact(4) { + if rgba[0] == 0 && rgba[1] == 0 && rgba[2] == 0 { + continue; + } + for channel in 0..3 { + let c = rgba[channel] as f32 / 255.0; + sum[channel] += if c <= 0.04045 { + c / 12.92 + } else { + libm::powf((c + 0.055) / 1.055, 2.4) + }; + } + count += 1.0; + } + if count == 0.0 { + [0.0; 3] + } else { + [sum[0] / count, sum[1] / count, sum[2] / count] + } +} +fn pack_primitive(primitive: &GlbPrimitive) -> Result<(Vec<u8>, Vec<u32>), ModelUploadError> { + let count = primitive.positions.len(); + if count == 0 + || (!primitive.normals.is_empty() && primitive.normals.len() != count) + || (!primitive.uvs.is_empty() && primitive.uvs.len() != count) + || (!primitive.joints.is_empty() && primitive.joints.len() != count) + || (!primitive.weights.is_empty() && primitive.weights.len() != count) + { + return Err(ModelUploadError::VertexCount); + } + let positions = primitive.morphed_positions(); + let mut source_normals = primitive.normals.clone(); + let mut source_tangents = primitive.tangents.clone(); + for (target_index, target) in primitive.morph_targets.iter().enumerate() { + let weight = primitive + .morph_weights + .get(target_index) + .copied() + .unwrap_or(0.0); + if weight == 0.0 { + continue; + } + for (value, delta) in source_normals.iter_mut().zip(&target.normals) { + for axis in 0..3 { + value[axis] += delta[axis] * weight; + } + } + for (value, delta) in source_tangents.iter_mut().zip(&target.tangents) { + for axis in 0..3 { + value[axis] += delta[axis] * weight; + } + } + } + let normals = if source_normals.is_empty() { + generate_normals(&positions, &primitive.indices) + } else { + source_normals + }; + let mut vertex_sources: Vec<usize> = (0..count).collect(); + let (tangents, packed_indices) = + if source_tangents.is_empty() && primitive.uvs.len() == positions.len() { + let corners = successor_engine_core::glb::generate_mikktspace_corner_tangents( + &positions, + &normals, + &primitive.uvs, + &primitive.indices, + ) + .map_err(|_| ModelUploadError::VertexCount)?; + let mut remap: BTreeMap<(u32, [u32; 4]), u32> = BTreeMap::new(); + let mut unique_tangents = Vec::new(); + let mut remapped_indices = Vec::with_capacity(primitive.indices.len()); + vertex_sources.clear(); + for (source, tangent) in primitive.indices.iter().copied().zip(corners) { + let encoded = tangent.map(f32::to_bits); + let next = remap.len() as u32; + let destination = *remap.entry((source, encoded)).or_insert_with(|| { + vertex_sources.push(source as usize); + unique_tangents.push(tangent); + next + }); + remapped_indices.push(destination); + } + (unique_tangents, remapped_indices) + } else { + (source_tangents, primitive.indices.clone()) + }; + let skinned = !primitive.joints.is_empty(); + let stride = if skinned { 64 } else { 52 }; + let mut vertices = Vec::with_capacity(vertex_sources.len() * stride); + for (packed_index, &source_index) in vertex_sources.iter().enumerate() { + for value in positions[source_index] + .into_iter() + .chain(normals[source_index]) + .chain(*primitive.uvs.get(source_index).unwrap_or(&[0.0, 0.0])) + .chain(*tangents.get(packed_index).unwrap_or(&[1.0, 0.0, 0.0, 1.0])) + { + vertices.extend_from_slice(&value.to_ne_bytes()); + } + vertices.extend_from_slice( + &primitive + .colors + .get(source_index) + .copied() + .unwrap_or([255; 4]), + ); + if skinned { + for joint in primitive.joints[source_index] { + let joint = u8::try_from(joint).map_err(|_| ModelUploadError::JointPalette)?; + vertices.push(joint); + } + for weight in primitive.weights[source_index] { + let encoded = libm::roundf(weight.clamp(0.0, 1.0) * 65535.0) as u16; + vertices.extend_from_slice(&encoded.to_ne_bytes()); + } + } + } + Ok((vertices, packed_indices)) +} + +fn generate_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> { + let mut normals = alloc::vec![[0.0f32; 3]; positions.len()]; + for triangle in indices.chunks_exact(3) { + let Some(&a) = positions.get(triangle[0] as usize) else { + continue; + }; + let Some(&b) = positions.get(triangle[1] as usize) else { + continue; + }; + let Some(&c) = positions.get(triangle[2] as usize) else { + continue; + }; + let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + let face = [ + ab[1] * ac[2] - ab[2] * ac[1], + ab[2] * ac[0] - ab[0] * ac[2], + ab[0] * ac[1] - ab[1] * ac[0], + ]; + for vertex in triangle { + if let Some(normal) = normals.get_mut(*vertex as usize) { + normal[0] += face[0]; + normal[1] += face[1]; + normal[2] += face[2]; + } + } + } + for normal in &mut normals { + let length = + libm::sqrtf(normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]); + if length > 1.0e-8 { + normal[0] /= length; + normal[1] /= length; + normal[2] /= length; + } else { + *normal = [0.0, 1.0, 0.0]; + } + } + normals +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + use crate::gpu::{VertexFormat, GLTF_MESH_LAYOUT, GLTF_SKINNED_MESH_LAYOUT}; + + #[test] + fn packed_static_layout_keeps_u8_color_without_float_inflation() { + let primitive = GlbPrimitive { + positions: alloc::vec![[1.0, 2.0, 3.0]], + normals: alloc::vec![[0.0, 1.0, 0.0]], + uvs: alloc::vec![[0.25, 0.75]], + tangents: alloc::vec![[1.0, 0.0, 0.0, -1.0]], + colors: alloc::vec![[17, 34, 51, 68]], + indices: alloc::vec![0], + ..GlbPrimitive::default() + }; + let (vertices, indices) = pack_primitive(&primitive).expect("pack"); + assert_eq!(vertices.len(), GLTF_MESH_LAYOUT.stride as usize); + assert_eq!(&vertices[48..52], &[17, 34, 51, 68]); + assert_eq!(indices, alloc::vec![0]); + assert_eq!(GLTF_MESH_LAYOUT.attrs[4].format, VertexFormat::U8Norm); + } + + #[test] + fn packed_skin_layout_uses_u8_joints_and_normalized_u16_weights() { + let primitive = GlbPrimitive { + positions: alloc::vec![[0.0; 3]], + normals: alloc::vec![[0.0, 1.0, 0.0]], + uvs: alloc::vec![[0.0; 2]], + tangents: alloc::vec![[1.0, 0.0, 0.0, 1.0]], + joints: alloc::vec![[1, 2, 3, 51]], + weights: alloc::vec![[1.0, 0.5, 0.0, 0.25]], + indices: alloc::vec![0], + ..GlbPrimitive::default() + }; + let (vertices, _) = pack_primitive(&primitive).expect("pack"); + assert_eq!(vertices.len(), GLTF_SKINNED_MESH_LAYOUT.stride as usize); + assert_eq!(&vertices[52..56], &[1, 2, 3, 51]); + assert_eq!(u16::from_ne_bytes([vertices[56], vertices[57]]), u16::MAX); + assert_eq!(u16::from_ne_bytes([vertices[58], vertices[59]]), 32_768); + assert_eq!(GLTF_SKINNED_MESH_LAYOUT.attrs[5].format, VertexFormat::U8); + assert_eq!( + GLTF_SKINNED_MESH_LAYOUT.attrs[6].format, + VertexFormat::U16Norm + ); + } + + #[test] + fn generated_corner_tangent_discontinuities_remap_indices() { + let primitive = GlbPrimitive { + positions: alloc::vec![ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [1.0, 1.0, 0.0], + [0.0, 1.0, 0.0], + ], + normals: alloc::vec![[0.0, 0.0, 1.0]; 4], + uvs: alloc::vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]], + indices: alloc::vec![0, 1, 2, 0, 2, 3], + ..GlbPrimitive::default() + }; + let (vertices, indices) = pack_primitive(&primitive).expect("pack"); + assert_eq!(indices.len(), primitive.indices.len()); + assert_eq!(vertices.len() % GLTF_MESH_LAYOUT.stride as usize, 0); + assert!(vertices.len() / GLTF_MESH_LAYOUT.stride as usize >= 4); + } + + #[test] + fn emissive_texture_mean_is_linear_and_ignores_black_texels() { + let pixels = [0, 0, 0, 255, 255, 128, 0, 255]; + let mean = emissive_texture_mean_linear(&pixels); + assert!((mean[0] - 1.0).abs() < 1.0e-6); + assert!((mean[1] - 0.215_860_53).abs() < 1.0e-6); + assert_eq!(mean[2], 0.0); + } + #[test] + fn upload_surfaces_backend_errors() { + let mut gpu = crate::gpu::MockGpu::default(); + let mut renderer = + Renderer::new(&mut gpu, crate::renderer::RendererLimits::default()).expect("renderer"); + gpu.error = Some(GpuError::InvalidResource); + assert!(matches!( + upload_glb(&mut renderer, &mut gpu, &GlbDocument::default()), + Err(ModelUploadError::Gpu(GpuError::InvalidResource)) + )); + } +} diff --git a/client-rust/source/engine-render/src/primitives.rs b/client-rust/source/engine-render/src/primitives.rs new file mode 100644 index 00000000..b83e1cd1 --- /dev/null +++ b/client-rust/source/engine-render/src/primitives.rs @@ -0,0 +1,126 @@ +//! Procedural mesh builders. Vertex format matches `gpu::MESH_LAYOUT`: +//! interleaved `pos:3, normal:3, uv:2` (8 floats/vertex). Returns +//! `(vertices, indices)` for indexed drawing. + +use alloc::vec::Vec; +use libm::{cosf, sinf}; + +pub type Mesh = (Vec<f32>, Vec<u32>); + +fn push_v(v: &mut Vec<f32>, p: [f32; 3], n: [f32; 3], uv: [f32; 2]) { + v.extend_from_slice(&[p[0], p[1], p[2], n[0], n[1], n[2], uv[0], uv[1]]); +} + +/// Unit cube centered at the origin (edge length 1), per-face normals. +pub fn cube() -> Mesh { + let mut v = Vec::with_capacity(24 * 8); + let mut idx = Vec::with_capacity(36); + // (normal, u-axis, v-axis) for each of the 6 faces. + let faces: [([f32; 3], [f32; 3], [f32; 3]); 6] = [ + ([0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), // +Z + ([0.0, 0.0, -1.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), // -Z + ([1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]), // +X + ([-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]), // -X + ([0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]), // +Y + ([0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]), // -Y + ]; + for (n, uax, vax) in faces { + let base = (v.len() / 8) as u32; + for (su, sv, uv) in [ + (-0.5, -0.5, [0.0, 0.0]), + (0.5, -0.5, [1.0, 0.0]), + (0.5, 0.5, [1.0, 1.0]), + (-0.5, 0.5, [0.0, 1.0]), + ] { + let p = [ + n[0] * 0.5 + uax[0] * su + vax[0] * sv, + n[1] * 0.5 + uax[1] * su + vax[1] * sv, + n[2] * 0.5 + uax[2] * su + vax[2] * sv, + ]; + push_v(&mut v, p, n, uv); + } + idx.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); + } + (v, idx) +} + +/// Flat plane on the XZ ground plane (y=0), centered, `size` on a side, normal +Y. +pub fn plane(size: f32) -> Mesh { + let h = size * 0.5; + let n = [0.0, 1.0, 0.0]; + let mut v = Vec::with_capacity(4 * 8); + push_v(&mut v, [-h, 0.0, -h], n, [0.0, 0.0]); + push_v(&mut v, [h, 0.0, -h], n, [1.0, 0.0]); + push_v(&mut v, [h, 0.0, h], n, [1.0, 1.0]); + push_v(&mut v, [-h, 0.0, h], n, [0.0, 1.0]); + // Wind CCW as seen from +Y so the front face (and its +Y normal) is up, + // which survives back-face culling for a camera looking down at the ground. + (v, alloc::vec![0, 2, 1, 0, 3, 2]) +} + +/// Capsule along +Y: two hemispheres of `radius` joined by a cylinder so the +/// total height is `height`. `segments` = longitude divisions; `rings` = per +/// hemisphere latitude divisions. +pub fn capsule(radius: f32, height: f32, segments: u32, rings: u32) -> Mesh { + let seg = segments.max(3); + let rings = rings.max(1); + let cyl_half = ((height - 2.0 * radius) * 0.5).max(0.0); + let mut v: Vec<f32> = Vec::new(); + let mut idx: Vec<u32> = Vec::new(); + let two_pi = core::f32::consts::PI * 2.0; + let half_pi = core::f32::consts::FRAC_PI_2; + + // Build latitude rings from bottom (-Y) to top (+Y). + // Bottom hemisphere latitudes: -pi/2 .. 0 ; top hemisphere: 0 .. pi/2. + let mut ring_rows: Vec<(f32, f32)> = Vec::new(); // (y_center_offset, lat) + for i in 0..=rings { + let lat = -half_pi + (i as f32 / rings as f32) * half_pi; // bottom cap + ring_rows.push((-cyl_half, lat)); + } + for i in 0..=rings { + let lat = (i as f32 / rings as f32) * half_pi; // top cap + ring_rows.push((cyl_half, lat)); + } + + let cols = seg + 1; + for (y_off, lat) in &ring_rows { + let cy = sinf(*lat) * radius + *y_off; + let cr = cosf(*lat) * radius; + for j in 0..cols { + let lon = (j as f32 / seg as f32) * two_pi; + let x = cosf(lon) * cr; + let z = sinf(lon) * cr; + // Normal points radially from the capsule axis segment. + let nx = cosf(lon) * cosf(*lat); + let ny = sinf(*lat); + let nz = sinf(lon) * cosf(*lat); + push_v( + &mut v, + [x, cy, z], + [nx, ny, nz], + [j as f32 / seg as f32, (cy + height) / (2.0 * height)], + ); + } + } + + let rows = ring_rows.len() as u32; + for r in 0..rows - 1 { + for c in 0..seg { + let a = r * cols + c; + let b = (r + 1) * cols + c; + idx.extend_from_slice(&[a, b, a + 1, a + 1, b, b + 1]); + } + } + (v, idx) +} + +/// A full-screen (or sub-rect) textured quad in NDC. Format: `pos:2, uv:2` +/// (`gpu::QUAD_LAYOUT`). `rect` is in NDC [-1,1]. +pub fn ndc_quad(x0: f32, y0: f32, x1: f32, y1: f32) -> Mesh { + let mut v = Vec::with_capacity(4 * 4); + v.extend_from_slice(&[x0, y0, 0.0, 0.0]); + v.extend_from_slice(&[x1, y0, 1.0, 0.0]); + v.extend_from_slice(&[x1, y1, 1.0, 1.0]); + v.extend_from_slice(&[x0, y1, 0.0, 1.0]); + (v, alloc::vec![0, 1, 2, 0, 2, 3]) +} diff --git a/client-rust/source/engine-render/src/renderer.rs b/client-rust/source/engine-render/src/renderer.rs new file mode 100644 index 00000000..3b9685e0 --- /dev/null +++ b/client-rust/source/engine-render/src/renderer.rs @@ -0,0 +1,3304 @@ +//! The frame renderer: shadow pass -> ordered camera passes (screen or RTT, +//! culled by viewport mask) -> composite RTT quads -> text overlays. +//! +//! All per-frame collections are reused scratch fields (`cameras`, `quad`, +//! `uniforms`), so after warmup the render path performs no heap allocation — +//! the property the `alloc-count` gate enforces. + +use alloc::vec::Vec; + +use successor_engine_core::ecs::{HasStorage, WorldOps}; +use successor_engine_core::math::{Mat4, Vec3}; + +use crate::components::{ + CamTarget, Camera, CompositeQuad, DirectionalLight, HeightCutaway, MeshRenderer, PointLight, + Projection, RectNorm, TextOverlay, Transform, +}; +use crate::gi::{GiOccluder, GiVolume, GiWorkCounters}; +use crate::gpu::{ + BufferId, BufferUsage, ClearSpec, Cull, Filter, ForwardLight, Gpu, GpuCaps, GpuError, MrtDesc, + PassTarget, PipelineState, ProgramId, RectPx, RenderTargetDesc, RenderTargetId, TextureDesc, + TextureFormat, Uniform, UniformValue, VertexLayout, GLTF_MESH_LAYOUT, GLTF_SKINNED_MESH_LAYOUT, + INSTANCE_MAT4_LAYOUT, MESH_LAYOUT, PARTICLE_LAYOUT, POINT_LIGHT_INSTANCE_LAYOUT, QUAD_LAYOUT, + SKINNED_MESH_LAYOUT, UI_LAYOUT, +}; +use crate::text; + +/// Render quality tier. Presets over ONE deferred code path (shadow filtering, +/// GI cone count, shadow map size, HDR target); selected at load. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum RenderQuality { + Low, + Medium, + High, +} + +impl RenderQuality { + fn shadow_size(self) -> u32 { + match self { + RenderQuality::Low => 1024, + _ => 2048, + } + } + /// Diffuse GI cone count (0 disables VXGI → hemisphere ambient). + fn gi_cones(self) -> u32 { + match self { + RenderQuality::Low => 0, + RenderQuality::Medium => 4, + RenderQuality::High => 6, + } + } +} + +/// Bounds every renderable world must satisfy. A world built with the `world!` +/// macro listing the render components implements this automatically. +pub trait RenderWorld: + WorldOps + + HasStorage<Transform> + + HasStorage<MeshRenderer> + + HasStorage<HeightCutaway> + + HasStorage<Camera> + + HasStorage<DirectionalLight> + + HasStorage<PointLight> + + HasStorage<CompositeQuad> + + HasStorage<TextOverlay> +{ +} + +impl<W> RenderWorld for W where + W: WorldOps + + HasStorage<Transform> + + HasStorage<MeshRenderer> + + HasStorage<HeightCutaway> + + HasStorage<Camera> + + HasStorage<DirectionalLight> + + HasStorage<PointLight> + + HasStorage<CompositeQuad> + + HasStorage<TextOverlay> +{ +} + +#[derive(Clone, Copy)] +struct MeshGpu { + vbo: BufferId, + ebo: BufferId, + index_count: u32, + skinned: bool, + layout: VertexLayout, + has_vertex_color: bool, + has_tangent: bool, +} + +#[derive(Clone, Copy)] +struct Material { + desc: MaterialDesc, +} + +#[derive(Clone, Copy)] +struct DrawRecord { + entity_index: u64, + entity_generation: u64, + mesh: MeshRenderer, + transform: Transform, + cutaway: HeightCutaway, +} + +#[derive(Clone, Copy)] +struct SceneLight { + entity_index: u64, + entity_generation: u64, + light: ForwardLight, + distance2: f32, +} +#[derive(Clone, Copy, Debug)] +pub struct TerrainMaterialDesc { + pub control_texture: crate::gpu::TextureId, + pub albedo_tiles: crate::gpu::TextureId, + pub nrma_tiles: crate::gpu::TextureId, + pub world_origin: [f32; 2], + pub world_size: f32, + pub tile_scale: f32, + pub normal_strength: f32, + /// `0` desert, `1` forest. Kept explicit so both vertex displacement and + /// fragment material state use the same biome profile. + pub biome: i32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct InstanceBatchId(pub u32); + +#[derive(Clone, Copy)] +struct InstanceBatch { + mesh: crate::components::MeshId, + material: crate::components::MaterialId, + buffer: BufferId, + count: u32, + capacity: u32, + viewport_mask: u32, + center: Vec3, + max_distance: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct MaterialDesc { + pub base_color: [f32; 4], + pub base_color_texture: Option<crate::gpu::TextureId>, + pub metallic_roughness_texture: Option<crate::gpu::TextureId>, + pub normal_texture: Option<crate::gpu::TextureId>, + pub occlusion_texture: Option<crate::gpu::TextureId>, + pub emissive_texture: Option<crate::gpu::TextureId>, + pub metallic: f32, + pub roughness: f32, + pub normal_scale: f32, + pub occlusion_strength: f32, + pub emissive_factor: [f32; 3], + pub emissive_strength: f32, + pub clearcoat: f32, + pub clearcoat_roughness: f32, + pub specular: f32, + pub ior: f32, + pub transmission: f32, + pub alpha_cutoff: f32, + pub double_sided: bool, + pub blend: bool, + pub terrain: Option<TerrainMaterialDesc>, +} + +impl Default for MaterialDesc { + fn default() -> Self { + Self { + base_color: [1.0; 4], + base_color_texture: None, + metallic_roughness_texture: None, + normal_texture: None, + occlusion_texture: None, + emissive_texture: None, + metallic: 1.0, + roughness: 1.0, + normal_scale: 1.0, + occlusion_strength: 1.0, + emissive_factor: [0.0; 3], + emissive_strength: 1.0, + clearcoat: 0.0, + clearcoat_roughness: 0.0, + specular: 1.0, + ior: 1.5, + transmission: 0.0, + alpha_cutoff: 0.5, + double_sided: false, + blend: false, + terrain: None, + } + } +} + +pub struct RendererLimits { + pub max_cameras: usize, + pub max_draws: usize, + /// Max floats in the dynamic quad scratch (composite + text per frame). + pub max_quad_floats: usize, + /// Max floats in the immediate-mode UI vertex buffer (UI_LAYOUT). + pub max_ui_floats: usize, + pub shadow_size: u32, + pub shadow_world_radius: f32, + /// Quality tier (shadow filtering, GI cones, HDR target). + pub quality: RenderQuality, + /// Maximum point lights considered by each camera. + pub max_point_lights: usize, + /// Maximum nearest point lights supplied to one transparent draw. + pub max_forward_lights: usize, +} + +impl Default for RendererLimits { + fn default() -> Self { + Self { + max_cameras: 16, + max_draws: 8192, + max_quad_floats: 64 * 1024, + max_ui_floats: 256 * 1024, + shadow_size: RenderQuality::Medium.shadow_size(), + shadow_world_radius: 48.0, + quality: RenderQuality::Medium, + max_point_lights: 256, + max_forward_lights: 32, + } + } +} + +/// Grade parameters applied by the tonemap pass (defaults = neutral). +#[derive(Clone, Copy)] +struct Grade { + bone_tint: [f32; 3], + desaturate: f32, + scene_darken: f32, + black_lift: f32, +} + +impl Default for Grade { + fn default() -> Self { + Self { + bone_tint: [1.0, 1.0, 1.0], + desaturate: 0.0, + scene_darken: 1.0, + black_lift: 0.0, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct BloomSettings { + pub threshold: f32, + pub intensity: f32, +} + +/// Presentation gain applied after bloom extraction and blur. +const BLOOM_INTENSITY_GAIN: f32 = 2.0; + +impl Default for BloomSettings { + fn default() -> Self { + Self { + threshold: 1.0, + intensity: 0.0, + } + } +} + +/// User-mastered directional sun controls. Azimuth rotates around world Y; +/// elevation is the angle above the horizon. +#[derive(Clone, Copy, Debug)] +pub struct SunSettings { + pub azimuth_degrees: f32, + pub elevation_degrees: f32, + pub color: [f32; 3], + pub intensity: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct AaSettings { + pub enabled: bool, + pub edge_threshold_min: f32, + pub edge_threshold: f32, + pub subpixel_blend: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct ShadowSettings { + pub map_size: u32, + pub world_radius: f32, + pub depth_bias: f32, + pub normal_bias: f32, + pub penumbra: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct ColorGradeSettings { + pub saturation: f32, + pub contrast: f32, + pub gamma: f32, + pub temperature: f32, + pub tint: f32, + pub lift: [f32; 3], + pub color_gamma: [f32; 3], + pub gain: [f32; 3], +} + +#[derive(Clone, Copy, Debug)] +pub struct PaletteSettings { + pub enabled: bool, + pub levels: u32, + pub strength: f32, + pub dither: f32, +} + +/// Validated runtime controls. JSON ownership remains in the app shell so the +/// no_std renderer stays independent of serialization and filesystem policy. +#[derive(Clone, Copy, Debug)] +pub struct RendererSettings { + pub ambient_intensity: f32, + pub emissive_scalar: f32, + /// Linear master scale on every gathered point light's intensity. + pub point_light_scale: f32, + pub exposure: f32, + pub ao_intensity: f32, + pub bloom_threshold: f32, + pub bloom_intensity: f32, + pub bloom_radius: f32, + pub sun: SunSettings, + pub aa: AaSettings, + pub shadows: ShadowSettings, + pub color_grade: ColorGradeSettings, + pub palette: PaletteSettings, +} + +impl Default for RendererSettings { + fn default() -> Self { + Self { + ambient_intensity: 0.28, + emissive_scalar: 1.0, + point_light_scale: 1.0, + exposure: 1.0, + ao_intensity: 1.0, + bloom_threshold: 1.0, + bloom_intensity: 0.0, + bloom_radius: 1.0, + sun: SunSettings { + azimuth_degrees: -45.0, + elevation_degrees: 55.0, + color: [1.0, 0.97, 0.9], + intensity: 1.0, + }, + aa: AaSettings { + enabled: true, + edge_threshold_min: 0.0312, + edge_threshold: 0.125, + subpixel_blend: 0.75, + }, + shadows: ShadowSettings { + map_size: 2048, + world_radius: 48.0, + depth_bias: 0.0015, + normal_bias: 1.5, + penumbra: 40.0, + }, + color_grade: ColorGradeSettings { + saturation: 1.0, + contrast: 1.0, + gamma: 1.0, + temperature: 0.0, + tint: 0.0, + lift: [0.0; 3], + color_gamma: [1.0; 3], + gain: [1.0; 3], + }, + palette: PaletteSettings { + enabled: false, + levels: 16, + strength: 0.0, + dither: 0.0, + }, + } + } +} + +impl RendererSettings { + fn valid(self) -> bool { + let finite3 = |v: [f32; 3]| v.into_iter().all(f32::is_finite); + matches!(self.shadows.map_size, 512 | 1024 | 2048 | 4096) + && self.ambient_intensity.is_finite() + && self.emissive_scalar.is_finite() + && self.point_light_scale.is_finite() + && self.point_light_scale >= 0.0 + && self.exposure.is_finite() + && self.ao_intensity.is_finite() + && self.bloom_threshold.is_finite() + && self.bloom_intensity.is_finite() + && self.bloom_radius.is_finite() + && self.sun.azimuth_degrees.is_finite() + && self.sun.elevation_degrees.is_finite() + && finite3(self.sun.color) + && self.sun.intensity.is_finite() + && self.aa.edge_threshold_min.is_finite() + && self.aa.edge_threshold.is_finite() + && self.aa.subpixel_blend.is_finite() + && self.shadows.world_radius.is_finite() + && self.shadows.depth_bias.is_finite() + && self.shadows.normal_bias.is_finite() + && self.shadows.penumbra.is_finite() + && self.color_grade.saturation.is_finite() + && self.color_grade.contrast.is_finite() + && self.color_grade.gamma.is_finite() + && self.color_grade.temperature.is_finite() + && self.color_grade.tint.is_finite() + && finite3(self.color_grade.lift) + && finite3(self.color_grade.color_gamma) + && finite3(self.color_grade.gain) + && (2..=64).contains(&self.palette.levels) + && self.palette.strength.is_finite() + && self.palette.dither.is_finite() + } + + fn sun_light(self) -> DirectionalLight { + let azimuth = self.sun.azimuth_degrees * core::f32::consts::PI / 180.0; + let elevation = self.sun.elevation_degrees * core::f32::consts::PI / 180.0; + let horizontal = libm::cosf(elevation); + DirectionalLight { + dir: Vec3 { + x: horizontal * libm::sinf(azimuth), + y: -libm::sinf(elevation), + z: horizontal * libm::cosf(azimuth), + }, + color: [ + self.sun.color[0] * self.sun.intensity, + self.sun.color[1] * self.sun.intensity, + self.sun.color[2] * self.sun.intensity, + ], + cast_shadows: true, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RenderConfigError { + InvalidBloom, + InvalidSettings, + Gpu(GpuError), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RendererInitError { + InsufficientMrt, + Gpu(GpuError), +} + +/// A screen-sized deferred render target plus the dimensions it was built for. +struct SizedRt { + rt: RenderTargetId, + w: u32, + h: u32, +} + +pub struct Renderer { + // Forward programs (RTT cameras: minimap/portraits) — unchanged. + mesh_prog: ProgramId, + mesh_skinned_prog: ProgramId, + depth_prog: ProgramId, + depth_skinned_prog: ProgramId, + terrain_depth_prog: ProgramId, + instance_depth_prog: ProgramId, + composite_prog: ProgramId, + text_prog: ProgramId, + // Deferred programs. + gbuffer_prog: ProgramId, + gbuffer_skinned_prog: ProgramId, + terrain_gbuffer_prog: ProgramId, + instance_gbuffer_prog: ProgramId, + light_prog: ProgramId, + tonemap_prog: ProgramId, + bloom_extract_prog: ProgramId, + bloom_blur_prog: ProgramId, + fxaa_prog: ProgramId, + copy_prog: ProgramId, + point_light_prog: ProgramId, + shadow_rt: RenderTargetId, + ui_prog: ProgramId, + ui_buf: BufferId, + ui_atlas: Option<crate::gpu::TextureId>, + particle_prog: ProgramId, + particle_buf: BufferId, + particle_tex: Option<crate::gpu::TextureId>, + white_tex: crate::gpu::TextureId, + normal_tex: crate::gpu::TextureId, + black_tex: crate::gpu::TextureId, + shadow_size: u32, + shadow_world_radius: f32, + quality: RenderQuality, + caps: GpuCaps, + dyn_buf: BufferId, + // Deferred screen targets (recreated on resize). + gbuffer_rt: Option<SizedRt>, + scene_rt: Option<SizedRt>, + ldr_rt: Option<SizedRt>, + bloom_extract_rt: Option<SizedRt>, + scene_copy_rt: Option<SizedRt>, + bloom_blur_rt: Option<SizedRt>, + // Point-light volume resources. + pl_vbo: BufferId, + pl_ebo: BufferId, + pl_index_count: u32, + pl_inst_buf: BufferId, + pl_scratch: Vec<f32>, + // Voxel GI (None below Medium tier). + gi: Option<GiVolume>, + meshes: Vec<MeshGpu>, + materials: Vec<Material>, + instance_batches: Vec<InstanceBatch>, + ambient: f32, + grade: Grade, + bloom: BloomSettings, + settings: RendererSettings, + exposure: f32, + // reused scratch + cameras: Vec<Camera>, + comp_quads: Vec<CompositeQuad>, + overlays: Vec<TextOverlay>, + quad: Vec<f32>, + uniforms: Vec<Uniform>, + draw_scratch: Vec<usize>, + scene_draws: Vec<DrawRecord>, + selected_lights: Vec<SceneLight>, + forward_lights: Vec<ForwardLight>, + max_forward_lights: usize, + max_point_lights: usize, + shadow_view_proj: [f32; 16], + skin_arena: Vec<[f32; 16]>, + fog_color: [f32; 3], + fog_near: f32, + fog_far: f32, +} + +impl Renderer { + pub fn new<G: Gpu>(gpu: &mut G, limits: RendererLimits) -> Result<Self, RendererInitError> { + let caps = gpu.caps(); + if caps.max_color_attachments < 4 || caps.max_draw_buffers < 4 { + return Err(RendererInitError::InsufficientMrt); + } + let q = limits.quality; + let pbr_common = include_str!("../../../assets/shaders/pbr_common.glsl"); + let mesh_fragment = alloc::format!( + "{}\n{}", + pbr_common, + include_str!("../../../assets/shaders/mesh.frag") + ); + let mesh_prog = gpu.create_program( + include_str!("../../../assets/shaders/mesh.vert"), + &mesh_fragment, + ); + let mesh_skinned_prog = gpu.create_program( + include_str!("../../../assets/shaders/mesh_skinned.vert"), + &mesh_fragment, + ); + let depth_prog = gpu.create_program( + include_str!("../../../assets/shaders/depth.vert"), + include_str!("../../../assets/shaders/depth.frag"), + ); + let depth_skin_src = alloc::format!( + "#define SKINNED 1\n{}", + include_str!("../../../assets/shaders/depth.vert") + ); + let depth_skinned_prog = gpu.create_program( + &depth_skin_src, + include_str!("../../../assets/shaders/depth.frag"), + ); + let depth_instance_src = alloc::format!( + "#define INSTANCED 1\n{}", + include_str!("../../../assets/shaders/depth.vert") + ); + let instance_depth_prog = gpu.create_program( + &depth_instance_src, + include_str!("../../../assets/shaders/depth.frag"), + ); + let composite_prog = gpu.create_program( + include_str!("../../../assets/shaders/composite.vert"), + include_str!("../../../assets/shaders/composite.frag"), + ); + let text_prog = gpu.create_program( + include_str!("../../../assets/shaders/text.vert"), + include_str!("../../../assets/shaders/text.frag"), + ); + let ui_prog = gpu.create_program( + include_str!("../../../assets/shaders/ui.vert"), + include_str!("../../../assets/shaders/ui.frag"), + ); + let particle_prog = gpu.create_program( + include_str!("../../../assets/shaders/particles.vert"), + include_str!("../../../assets/shaders/particles.frag"), + ); + // Deferred programs. Tier `#define`s are prepended after the version + // header (create_program prepends `#version`), so they precede the body. + let gbuffer_prog = gpu.create_program( + include_str!("../../../assets/shaders/gbuffer.vert"), + include_str!("../../../assets/shaders/gbuffer.frag"), + ); + let gb_skin_src = alloc::format!( + "#define SKINNED 1\n{}", + include_str!("../../../assets/shaders/gbuffer.vert") + ); + let gbuffer_skinned_prog = gpu.create_program( + &gb_skin_src, + include_str!("../../../assets/shaders/gbuffer.frag"), + ); + let gb_instance_src = alloc::format!( + "#define INSTANCED 1\n{}", + include_str!("../../../assets/shaders/gbuffer.vert") + ); + let instance_gbuffer_prog = gpu.create_program( + &gb_instance_src, + include_str!("../../../assets/shaders/gbuffer.frag"), + ); + let terrain_depth_prog = gpu.create_program( + include_str!("../../../assets/shaders/terrain_depth.vert"), + include_str!("../../../assets/shaders/depth.frag"), + ); + let terrain_gbuffer_prog = gpu.create_program( + include_str!("../../../assets/shaders/terrain_gbuffer.vert"), + include_str!("../../../assets/shaders/terrain_gbuffer.frag"), + ); + let (taps, pcss, cones, spec) = match q { + RenderQuality::Low => (4, 0, 0, 0), + RenderQuality::Medium => (12, 0, 4, 0), + RenderQuality::High => (16, 1, 6, 1), + }; + let light_src = alloc::format!( + "#define SHADOW_TAPS {}\n#define PCSS {}\n#define GI_CONES {}\n#define GI_SPECULAR {}\n{}\n{}", + taps, + pcss, + cones, + spec, + pbr_common, + include_str!("../../../assets/shaders/deferred_light.frag") + ); + let light_prog = gpu.create_program( + include_str!("../../../assets/shaders/post.vert"), + &light_src, + ); + let tonemap_prog = gpu.create_program( + include_str!("../../../assets/shaders/post.vert"), + include_str!("../../../assets/shaders/tonemap.frag"), + ); + let bloom_extract_prog = gpu.create_program( + include_str!("../../../assets/shaders/post.vert"), + include_str!("../../../assets/shaders/bloom_extract.frag"), + ); + let bloom_blur_prog = gpu.create_program( + include_str!("../../../assets/shaders/post.vert"), + include_str!("../../../assets/shaders/bloom_blur.frag"), + ); + let fxaa_prog = gpu.create_program( + include_str!("../../../assets/shaders/post.vert"), + include_str!("../../../assets/shaders/fxaa.frag"), + ); + let copy_prog = gpu.create_program( + include_str!("../../../assets/shaders/post.vert"), + include_str!("../../../assets/shaders/copy.frag"), + ); + let point_fragment = alloc::format!( + "{}\n{}", + pbr_common, + include_str!("../../../assets/shaders/point_light.frag") + ); + let point_light_prog = gpu.create_program( + include_str!("../../../assets/shaders/point_light.vert"), + &point_fragment, + ); + let shadow_size = if matches!(limits.shadow_size, 512 | 1024 | 2048 | 4096) { + limits.shadow_size + } else { + q.shadow_size() + }; + let shadow_rt = gpu.create_render_target(&RenderTargetDesc { + width: shadow_size, + height: shadow_size, + color: false, + depth: true, + filter: Filter::Nearest, + }); + // Seed the dynamic buffer with its max capacity so per-frame updates + // never grow it (allocation stability). + let seed = alloc::vec![0u8; limits.max_quad_floats * 4]; + let dyn_buf = gpu.create_buffer(&seed, BufferUsage::Dynamic); + let ui_seed = alloc::vec![0u8; limits.max_ui_floats * 4]; + let ui_buf = gpu.create_buffer(&ui_seed, BufferUsage::Dynamic); + let particle_buf = gpu.create_buffer(&ui_seed, BufferUsage::Dynamic); + // Unit sphere for point-light bounding volumes. + let (pl_verts, pl_indices) = crate::primitives::capsule(1.0, 2.0, 8, 4); + let pl_vbo = gpu.create_buffer(f32_bytes(&pl_verts), BufferUsage::Static); + let pl_ebo = gpu.create_index_buffer(u32_bytes(&pl_indices), BufferUsage::Static); + let pl_inst_seed = alloc::vec![0u8; limits.max_point_lights * 8 * 4]; + let pl_inst_buf = gpu.create_buffer(&pl_inst_seed, BufferUsage::Dynamic); + let gi = if q.gi_cones() > 0 { + Some(GiVolume::new(gpu)) + } else { + None + }; + let default_texture = |gpu: &mut G, rgba: [u8; 4]| { + gpu.create_texture( + &TextureDesc { + width: 1, + height: 1, + format: TextureFormat::Rgba8, + mag_filter: Filter::Nearest, + min_filter: crate::gpu::MinFilter::Nearest, + wrap_s: crate::gpu::Wrap::ClampToEdge, + wrap_t: crate::gpu::Wrap::ClampToEdge, + mipmaps: false, + }, + Some(&rgba), + ) + }; + let white_tex = default_texture(gpu, [255, 255, 255, 255]); + let normal_tex = default_texture(gpu, [128, 128, 255, 255]); + let black_tex = default_texture(gpu, [0, 0, 0, 255]); + let mut settings = RendererSettings::default(); + settings.shadows.map_size = shadow_size; + settings.shadows.world_radius = limits.shadow_world_radius; + let renderer = Self { + mesh_prog, + mesh_skinned_prog, + depth_prog, + depth_skinned_prog, + terrain_depth_prog, + instance_depth_prog, + composite_prog, + text_prog, + gbuffer_prog, + gbuffer_skinned_prog, + terrain_gbuffer_prog, + instance_gbuffer_prog, + light_prog, + tonemap_prog, + point_light_prog, + bloom_extract_prog, + bloom_blur_prog, + fxaa_prog, + shadow_rt, + copy_prog, + ui_prog, + ui_buf, + ui_atlas: None, + particle_prog, + particle_buf, + particle_tex: None, + shadow_size, + shadow_world_radius: limits.shadow_world_radius, + quality: q, + caps, + dyn_buf, + gbuffer_rt: None, + white_tex, + normal_tex, + black_tex, + scene_rt: None, + ldr_rt: None, + bloom_extract_rt: None, + bloom_blur_rt: None, + exposure: 1.0, + pl_vbo, + pl_ebo, + scene_copy_rt: None, + pl_index_count: pl_indices.len() as u32, + pl_inst_buf, + pl_scratch: Vec::with_capacity(limits.max_point_lights * 8), + gi, + meshes: Vec::new(), + materials: Vec::new(), + instance_batches: Vec::new(), + ambient: settings.ambient_intensity, + grade: Grade::default(), + cameras: Vec::with_capacity(limits.max_cameras), + comp_quads: Vec::with_capacity(limits.max_cameras), + overlays: Vec::with_capacity(16), + quad: Vec::with_capacity(limits.max_quad_floats), + uniforms: Vec::with_capacity(64), + selected_lights: Vec::with_capacity(limits.max_point_lights), + forward_lights: Vec::with_capacity(limits.max_forward_lights), + max_forward_lights: limits.max_forward_lights.min(32), + max_point_lights: limits.max_point_lights, + draw_scratch: Vec::with_capacity(limits.max_draws), + scene_draws: Vec::with_capacity(limits.max_draws), + shadow_view_proj: Mat4::IDENTITY.to_cols_array(), + skin_arena: Vec::with_capacity(64 * 16), + fog_color: [0.788, 0.678, 0.510], + fog_near: 180.0, + fog_far: 320.0, + bloom: BloomSettings::default(), + settings, + }; + if let Some(error) = gpu.take_error() { + return Err(RendererInitError::Gpu(error)); + } + Ok(renderer) + } + + /// Upload the baked icon atlas (RGBA8; coverage in the alpha channel) that + /// the UI pass samples. Call once at load. + pub fn set_ui_atlas<G: Gpu>(&mut self, gpu: &mut G, width: u32, height: u32, rgba: &[u8]) { + let tex = gpu.create_texture( + &TextureDesc { + width, + height, + format: TextureFormat::Rgba8, + mag_filter: Filter::Linear, + min_filter: crate::gpu::MinFilter::Linear, + wrap_s: crate::gpu::Wrap::ClampToEdge, + wrap_t: crate::gpu::Wrap::ClampToEdge, + mipmaps: false, + }, + Some(rgba), + ); + self.ui_atlas = Some(tex); + } + + /// Draw an immediate-mode UI vertex buffer (`UI_LAYOUT`, NDC) over the + /// current framebuffer with alpha blending. `quads` is the quad count + /// (`buf` holds `quads * 6 * 8` floats). No-op until an atlas is uploaded. + pub fn render_ui<G: Gpu>( + &mut self, + gpu: &mut G, + buf: &[f32], + quads: u32, + screen_w: u32, + screen_h: u32, + ) { + if quads == 0 { + return; + } + let atlas = match self.ui_atlas { + Some(a) => a, + None => return, + }; + gpu.begin_pass( + PassTarget::Screen, + RectPx { + x: 0, + y: 0, + w: screen_w as i32, + h: screen_h as i32, + }, + ClearSpec::default(), + ); + gpu.set_pipeline( + self.ui_prog, + &PipelineState { + depth_test: false, + depth_write: false, + cull: Cull::None, + color_write: true, + blend: true, + additive: false, + }, + ); + gpu.bind_texture(0, atlas); + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_atlas", + value: UniformValue::Sampler(0), + }); + gpu.set_uniforms(&self.uniforms); + gpu.update_buffer(self.ui_buf, f32_bytes(buf)); + gpu.draw(self.ui_buf, None, &UI_LAYOUT, quads * 6); + gpu.end_pass(); + } + + /// Upload the shared glow sprite (RGBA8) the particle pass samples. + pub fn set_particle_atlas<G: Gpu>( + &mut self, + gpu: &mut G, + width: u32, + height: u32, + rgba: &[u8], + ) { + let tex = gpu.create_texture( + &TextureDesc { + width, + height, + format: TextureFormat::Rgba8, + mag_filter: Filter::Linear, + min_filter: crate::gpu::MinFilter::Linear, + wrap_s: crate::gpu::Wrap::ClampToEdge, + wrap_t: crate::gpu::Wrap::ClampToEdge, + mipmaps: false, + }, + Some(rgba), + ); + self.particle_tex = Some(tex); + } + + /// Draw a world-space particle billboard buffer (`PARTICLE_LAYOUT`) over the + /// current screen framebuffer, depth-testing against the scene but not + /// writing depth. `additive` selects the blend mode. No-op until a sprite is + /// uploaded. `buf` holds `quads * 6 * 9` floats. + #[allow(clippy::too_many_arguments)] + pub fn render_particles<G: Gpu>( + &mut self, + gpu: &mut G, + buf: &[f32], + quads: u32, + view_proj: &[f32; 16], + additive: bool, + screen_w: u32, + screen_h: u32, + ) { + if quads == 0 { + return; + } + let tex = match self.particle_tex { + Some(t) => t, + None => return, + }; + gpu.begin_pass( + PassTarget::Screen, + RectPx { + x: 0, + y: 0, + w: screen_w as i32, + h: screen_h as i32, + }, + ClearSpec::default(), + ); + gpu.set_pipeline( + self.particle_prog, + &PipelineState { + depth_test: true, + depth_write: false, + cull: Cull::None, + color_write: true, + blend: true, + additive, + }, + ); + gpu.bind_texture(0, tex); + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_tex", + value: UniformValue::Sampler(0), + }); + self.uniforms.push(Uniform { + name: "u_viewProj", + value: UniformValue::Mat4(*view_proj), + }); + gpu.set_uniforms(&self.uniforms); + gpu.update_buffer(self.particle_buf, f32_bytes(buf)); + gpu.draw(self.particle_buf, None, &PARTICLE_LAYOUT, quads * 6); + gpu.end_pass(); + } + + /// Set the tonemap color-grade parameters (environment day-night grade), + /// applied internally by `render`'s tonemap pass. `bone_tint` is linear rgb. + pub fn set_grade( + &mut self, + bone_tint: [f32; 3], + desaturate: f32, + scene_darken: f32, + black_lift: f32, + ) { + self.grade = Grade { + bone_tint, + desaturate, + scene_darken, + black_lift, + }; + } + + pub fn set_bloom(&mut self, threshold: f32, intensity: f32) -> Result<(), RenderConfigError> { + if !threshold.is_finite() || threshold < 0.0 || !intensity.is_finite() || intensity < 0.0 { + return Err(RenderConfigError::InvalidBloom); + } + self.bloom = BloomSettings { + threshold, + intensity, + }; + Ok(()) + } + + /// Apply a complete validated tuning snapshot. Only a shadow-map size + /// change recreates GPU resources; all other controls become uniforms on + /// the next frame and allocate nothing in the steady-state render loop. + pub fn apply_settings<G: Gpu>( + &mut self, + gpu: &mut G, + settings: RendererSettings, + ) -> Result<(), RenderConfigError> { + if !settings.valid() { + return Err(RenderConfigError::InvalidSettings); + } + if settings.shadows.map_size != self.shadow_size { + let next = gpu.create_render_target(&RenderTargetDesc { + width: settings.shadows.map_size, + height: settings.shadows.map_size, + color: false, + depth: true, + filter: Filter::Nearest, + }); + if let Some(error) = gpu.take_error() { + gpu.delete_render_target(next); + return Err(RenderConfigError::Gpu(error)); + } + let previous = core::mem::replace(&mut self.shadow_rt, next); + gpu.delete_render_target(previous); + self.shadow_size = settings.shadows.map_size; + } + self.shadow_world_radius = settings.shadows.world_radius; + self.ambient = settings.ambient_intensity; + self.bloom = BloomSettings { + threshold: settings.bloom_threshold, + intensity: settings.bloom_intensity, + }; + self.settings = settings; + Ok(()) + } + + pub fn settings(&self) -> RendererSettings { + self.settings + } + + /// Set the flat per-biome ground albedo the GI volume voxelizes (no-op below + /// Medium tier, where VXGI is disabled). + pub fn gi_set_ground_albedo(&mut self, rgb: [f32; 3]) { + if let Some(gi) = self.gi.as_mut() { + gi.set_ground_albedo(rgb); + } + } + + /// Replace the GI static occluder proxy set (no-op below Medium tier). + pub fn gi_set_occluders(&mut self, occ: &[GiOccluder]) { + if let Some(gi) = self.gi.as_mut() { + gi.set_occluders(occ); + } + } + + /// Set world-space GI coverage focus. Render cameras never imply GI focus. + pub fn gi_set_focus(&mut self, focus: [f32; 3]) { + if let Some(gi) = self.gi.as_mut() { + gi.set_focus(focus); + } + } + + pub fn upload_gltf_mesh<G: Gpu>( + &mut self, + gpu: &mut G, + vertices: &[u8], + indices: &[u32], + skinned: bool, + has_vertex_color: bool, + ) -> crate::components::MeshId { + let vbo = gpu.create_buffer(vertices, BufferUsage::Static); + let ebo = gpu.create_index_buffer(u32_bytes(indices), BufferUsage::Static); + self.meshes.push(MeshGpu { + vbo, + ebo, + index_count: indices.len() as u32, + skinned, + layout: if skinned { + GLTF_SKINNED_MESH_LAYOUT + } else { + GLTF_MESH_LAYOUT + }, + has_vertex_color, + has_tangent: true, + }); + crate::components::MeshId((self.meshes.len() - 1) as u32) + } + + pub fn gi_work_counters(&self) -> GiWorkCounters { + self.gi + .as_ref() + .map_or_else(GiWorkCounters::default, GiVolume::counters) + } + + pub fn gi_is_idle(&self) -> bool { + self.gi.as_ref().is_none_or(GiVolume::is_idle) + } + + /// Upload an indexed mesh (vertex format `MESH_LAYOUT`). Returns a handle + /// to store in a `MeshRenderer`. + pub fn upload_mesh<G: Gpu>( + &mut self, + gpu: &mut G, + vertices: &[f32], + indices: &[u32], + ) -> crate::components::MeshId { + let vbo = gpu.create_buffer(f32_bytes(vertices), BufferUsage::Static); + let ebo = gpu.create_index_buffer(u32_bytes(indices), BufferUsage::Static); + self.meshes.push(MeshGpu { + vbo, + ebo, + index_count: indices.len() as u32, + skinned: false, + layout: MESH_LAYOUT, + has_vertex_color: false, + has_tangent: false, + }); + crate::components::MeshId((self.meshes.len() - 1) as u32) + } + + /// Upload a skinned mesh (vertex format `SKINNED_MESH_LAYOUT`: 16 f32/vert). + pub fn upload_skinned_mesh<G: Gpu>( + &mut self, + gpu: &mut G, + vertices: &[f32], + indices: &[u32], + ) -> crate::components::MeshId { + let vbo = gpu.create_buffer(f32_bytes(vertices), BufferUsage::Static); + let ebo = gpu.create_index_buffer(u32_bytes(indices), BufferUsage::Static); + self.meshes.push(MeshGpu { + vbo, + ebo, + index_count: indices.len() as u32, + skinned: true, + layout: SKINNED_MESH_LAYOUT, + has_vertex_color: false, + has_tangent: false, + }); + crate::components::MeshId((self.meshes.len() - 1) as u32) + } + + /// Clear the per-frame joint palette arena. Call once before pushing this + /// frame's skinned poses. + pub fn begin_skin_frame(&mut self) { + self.skin_arena.clear(); + } + + /// Append a joint palette; returns the offset for a `SkinRef`. + pub fn push_skin_palette(&mut self, mats: &[[f32; 16]]) -> u32 { + let offset = self.skin_arena.len() as u32; + self.skin_arena.extend_from_slice(mats); + offset + } + + pub fn add_material_desc(&mut self, desc: MaterialDesc) -> crate::components::MaterialId { + self.materials.push(Material { desc }); + crate::components::MaterialId((self.materials.len() - 1) as u32) + } + + pub fn material_desc(&self, id: crate::components::MaterialId) -> Option<MaterialDesc> { + self.materials + .get(id.0 as usize) + .map(|material| material.desc) + } + + pub fn update_material_desc(&mut self, id: crate::components::MaterialId, desc: MaterialDesc) { + if let Some(material) = self.materials.get_mut(id.0 as usize) { + material.desc = desc; + } + } + + /// Allocate a fixed-capacity instanced mesh batch. Terrain streamers create + /// these once per pool slot and only replace matrix contents thereafter. + pub fn add_instance_batch<G: Gpu>( + &mut self, + gpu: &mut G, + mesh: crate::components::MeshId, + material: crate::components::MaterialId, + capacity: u32, + viewport_mask: u32, + max_distance: f32, + ) -> InstanceBatchId { + assert!(capacity > 0, "instance batch capacity must be nonzero"); + let seed = alloc::vec![0u8; capacity as usize * 64]; + let buffer = gpu.create_buffer(&seed, BufferUsage::Dynamic); + self.instance_batches.push(InstanceBatch { + mesh, + material, + buffer, + count: 0, + capacity, + viewport_mask, + center: Vec3::ZERO, + max_distance, + }); + InstanceBatchId((self.instance_batches.len() - 1) as u32) + } + + /// Replace a batch without reallocating it. Returns `false` for a stale ID + /// or an over-capacity update, preserving the previous batch contents. + pub fn update_instance_batch<G: Gpu>( + &mut self, + gpu: &mut G, + id: InstanceBatchId, + matrices: &[[f32; 16]], + center: [f32; 3], + ) -> bool { + let Some(batch) = self.instance_batches.get_mut(id.0 as usize) else { + return false; + }; + if matrices.len() > batch.capacity as usize { + return false; + } + if !matrices.is_empty() { + gpu.update_buffer(batch.buffer, mat4_bytes(matrices)); + } + batch.count = matrices.len() as u32; + batch.center = Vec3 { + x: center[0], + y: center[1], + z: center[2], + }; + true + } + + pub fn instance_batch_count(&self) -> usize { + self.instance_batches.len() + } + pub fn set_ambient(&mut self, a: f32) { + self.ambient = a; + } + + /// Per-biome distance fog: RGB color the far apron melts into, plus the + /// world-distance near/far band. Chosen so the in-frame iso view stays + /// clear and only the streamed apron dissolves (uniform-air doctrine). + pub fn set_fog(&mut self, color: [f32; 3], near: f32, far: f32) { + self.fog_color = color; + self.fog_near = near; + self.fog_far = far; + } + + /// Render one frame of `world` into a `screen_w x screen_h` framebuffer. + /// The first screen camera renders deferred (G-buffer → PBR sun + GI → point + /// lights → tonemap to screen); RTT cameras stay forward. Composite + text + /// close the frame on screen. + pub fn render<G: Gpu, W: RenderWorld>( + &mut self, + gpu: &mut G, + world: &mut W, + screen_w: u32, + screen_h: u32, + ) -> Result<(), GpuError> { + if screen_w == 0 || screen_h == 0 { + return Ok(()); + } + // --- gather lights --- + let mut main_light: Option<DirectionalLight> = None; + let mut shadow_light: Option<DirectionalLight> = None; + { + let mut q = world.query1::<DirectionalLight>(); + while let Some((_, l)) = q.next() { + if main_light.is_none() { + main_light = Some(*l); + } + if l.cast_shadows && shadow_light.is_none() { + shadow_light = Some(*l); + } + } + } + // Authored light entities declare whether a scene has a sun and casts + // shadows; mastered settings own its direction and radiance. + let tuned_sun = self.settings.sun_light(); + if main_light.is_some() { + main_light = Some(tuned_sun); + } + if shadow_light.is_some() { + shadow_light = Some(tuned_sun); + } + + // Sort cameras. Each camera scans the world light storage directly so + // the nearest-light result is independent of mesh draw capacity and + // ECS iteration prefixes. + self.cameras.clear(); + { + let mut q = world.query1::<Camera>(); + while let Some((_, c)) = q.next() { + self.cameras.push(*c); + } + } + self.cameras.sort_by_key(|c| c.order); + self.scene_draws.clear(); + { + let mut query = world.query2::<MeshRenderer, Transform>(); + while let Some((entity, mesh, transform)) = query.next() { + if self.scene_draws.len() == self.scene_draws.capacity() { + break; + } + self.scene_draws.push(DrawRecord { + entity_index: entity.index, + entity_generation: entity.generation, + mesh: *mesh, + transform: *transform, + cutaway: world + .get_component::<HeightCutaway>(entity) + .copied() + .unwrap_or_default(), + }); + } + } + + // --- shadow pass (texel-snapped ortho fit) --- + let use_shadow = shadow_light.is_some(); + if let Some(light) = shadow_light { + let center = self + .cameras + .iter() + .find(|c| matches!(c.target, CamTarget::Screen(_))) + .map(|c| c.look_at) + .unwrap_or(Vec3::ZERO); + self.shadow_view_proj = light_view_proj( + light.dir, + center, + self.shadow_world_radius, + self.shadow_size, + ); + gpu.begin_pass( + PassTarget::RenderTarget(self.shadow_rt), + RectPx { + x: 0, + y: 0, + w: self.shadow_size as i32, + h: self.shadow_size as i32, + }, + ClearSpec { + color: None, + depth: Some(1.0), + }, + ); + let depth_state = PipelineState { + depth_test: true, + depth_write: true, + cull: Cull::Front, + color_write: false, + blend: false, + additive: false, + }; + let skinned_depth_state = PipelineState { + cull: Cull::None, + ..depth_state + }; + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_lightViewProj", + value: UniformValue::Mat4(self.shadow_view_proj), + }); + gpu.set_pipeline(self.depth_prog, &depth_state); + gpu.set_uniforms(&self.uniforms); + gpu.set_pipeline(self.depth_skinned_prog, &skinned_depth_state); + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_lightViewProj", + value: UniformValue::Mat4(self.shadow_view_proj), + }); + gpu.set_uniforms(&self.uniforms); + self.draw_all_meshes(gpu, world, DrawMode::Depth, 0, None, Vec3::ZERO); + self.draw_instance_batches(gpu, DrawMode::Depth, 0, self.shadow_view_proj, Vec3::ZERO); + gpu.end_pass(); + } + + // The first screen camera (lowest order) renders deferred. + let deferred_idx = self + .cameras + .iter() + .position(|c| matches!(c.target, CamTarget::Screen(_))); + + if deferred_idx.is_some() { + self.ensure_screen_targets(gpu, screen_w, screen_h)?; + // GI update is driven only by explicit world focus and static scene + // inputs. Camera/view changes cannot invalidate the volume. + let ld = main_light.map(|l| l.dir).unwrap_or(DEFAULT_LIGHT_DIR); + let lc = main_light.map(|l| l.color).unwrap_or([1.0, 1.0, 1.0]); + if let Some(gi) = self.gi.as_mut() { + gi.step(gpu, [ld.x, ld.y, ld.z], lc); + } + } + + // --- camera passes --- + let cam_count = self.cameras.len(); + for ci in 0..cam_count { + let cam = self.cameras[ci]; + if Some(ci) == deferred_idx { + self.deferred_camera(gpu, world, cam, screen_w, screen_h, main_light, use_shadow); + } else { + self.forward_camera(gpu, world, cam, screen_w, screen_h, main_light, use_shadow); + } + } + + // Text sits with the scene. The 3D viewer composites deliberately do + // NOT run here: the original flushes its 2D queue and then draws each + // 3D viewer over the panel, so viewers are composited by + // `composite_viewers` after the UI pass. + self.text_pass(gpu, world, screen_w, screen_h); + match gpu.take_error() { + Some(error) => Err(error), + None => Ok(()), + } + } + + /// (Re)create the G-buffer, scene, and half-resolution bloom targets. + fn ensure_screen_targets<G: Gpu>( + &mut self, + gpu: &mut G, + w: u32, + h: u32, + ) -> Result<(), GpuError> { + let need = self + .gbuffer_rt + .as_ref() + .is_none_or(|s| s.w != w || s.h != h); + if !need { + return Ok(()); + } + let hdr = self.caps.half_float_target && self.quality != RenderQuality::Low; + let scene_fmt: &'static [TextureFormat] = if hdr { + &SCENE_HDR_FORMATS + } else { + &SCENE_LDR_FORMATS + }; + let gb = gpu.create_render_target_mrt(&MrtDesc { + width: w, + height: h, + colors: &GBUFFER_FORMATS, + depth: true, + }); + let scene = gpu.create_render_target_mrt(&MrtDesc { + width: w, + height: h, + colors: scene_fmt, + depth: false, + }); + let scene_copy = gpu.create_render_target_mrt(&MrtDesc { + width: w, + height: h, + colors: scene_fmt, + depth: false, + }); + let ldr = gpu.create_render_target_mrt(&MrtDesc { + width: w, + height: h, + colors: &SCENE_LDR_FORMATS, + depth: false, + }); + let bloom_w = w.div_ceil(2).max(1); + let bloom_h = h.div_ceil(2).max(1); + let bloom_extract = gpu.create_render_target_mrt(&MrtDesc { + width: bloom_w, + height: bloom_h, + colors: scene_fmt, + depth: false, + }); + let bloom_blur = gpu.create_render_target_mrt(&MrtDesc { + width: bloom_w, + height: bloom_h, + colors: scene_fmt, + depth: false, + }); + if let Some(error) = gpu.take_error() { + for target in [gb, scene, scene_copy, ldr, bloom_extract, bloom_blur] { + gpu.delete_render_target(target); + } + return Err(error); + } + for target in [ + self.gbuffer_rt.take(), + self.scene_rt.take(), + self.scene_copy_rt.take(), + self.ldr_rt.take(), + self.bloom_extract_rt.take(), + self.bloom_blur_rt.take(), + ] + .into_iter() + .flatten() + { + gpu.delete_render_target(target.rt); + } + self.exposure = if hdr { 1.0 } else { 0.25 }; + self.gbuffer_rt = Some(SizedRt { rt: gb, w, h }); + self.scene_rt = Some(SizedRt { rt: scene, w, h }); + self.scene_copy_rt = Some(SizedRt { + rt: scene_copy, + w, + h, + }); + self.ldr_rt = Some(SizedRt { rt: ldr, w, h }); + self.bloom_extract_rt = Some(SizedRt { + rt: bloom_extract, + w: bloom_w, + h: bloom_h, + }); + self.bloom_blur_rt = Some(SizedRt { + rt: bloom_blur, + w: bloom_w, + h: bloom_h, + }); + Ok(()) + } + fn select_lights_for_camera<W: RenderWorld>(&mut self, world: &mut W, eye: Vec3) { + self.selected_lights.clear(); + let scale = self.settings.point_light_scale; + let mut query = world.query2::<PointLight, Transform>(); + while let Some((entity, light, transform)) = query.next() { + let intensity = light.intensity * scale; + if !transform.pos.x.is_finite() + || !transform.pos.y.is_finite() + || !transform.pos.z.is_finite() + || !light.radius.is_finite() + || light.radius <= 0.0 + || !intensity.is_finite() + || intensity <= 0.0 + || light + .color + .iter() + .any(|value| !value.is_finite() || *value < 0.0) + || light.color.iter().all(|value| *value == 0.0) + { + continue; + } + let dx = transform.pos.x - eye.x; + let dy = transform.pos.y - eye.y; + let dz = transform.pos.z - eye.z; + let distance = libm::sqrtf(dx * dx + dy * dy + dz * dz) - light.radius; + let selected = SceneLight { + entity_index: entity.index, + entity_generation: entity.generation, + light: ForwardLight { + position: [transform.pos.x, transform.pos.y, transform.pos.z], + radius: light.radius, + color: light.color, + intensity, + }, + distance2: distance.max(0.0), + }; + if self.selected_lights.len() < self.max_point_lights { + self.selected_lights.push(selected); + } else if let Some((worst, current)) = + self.selected_lights + .iter() + .enumerate() + .max_by(|(_, left), (_, right)| { + left.distance2 + .total_cmp(&right.distance2) + .then_with(|| left.entity_index.cmp(&right.entity_index)) + .then_with(|| left.entity_generation.cmp(&right.entity_generation)) + }) + { + let better = selected.distance2 < current.distance2 + || (selected.distance2 == current.distance2 + && (selected.entity_index, selected.entity_generation) + < (current.entity_index, current.entity_generation)); + if better { + self.selected_lights[worst] = selected; + } + } + } + self.selected_lights.sort_unstable_by(|left, right| { + left.distance2 + .total_cmp(&right.distance2) + .then_with(|| left.entity_index.cmp(&right.entity_index)) + .then_with(|| left.entity_generation.cmp(&right.entity_generation)) + }); + } + + /// Deferred screen camera: G-buffer → sun light → point lights → tonemap. + #[allow(clippy::too_many_arguments)] + fn deferred_camera<G: Gpu, W: RenderWorld>( + &mut self, + gpu: &mut G, + world: &mut W, + cam: Camera, + screen_w: u32, + screen_h: u32, + main_light: Option<DirectionalLight>, + use_shadow: bool, + ) { + self.select_lights_for_camera(world, cam.eye); + let rect = match cam.target { + CamTarget::Screen(r) => r, + _ => return, + }; + let (gb_rt, scene_rt, ldr_rt, gw, gh) = + match (&self.gbuffer_rt, &self.scene_rt, &self.ldr_rt) { + (Some(g), Some(s), Some(ldr)) => (g.rt, s.rt, ldr.rt, g.w, g.h), + _ => return, + }; + let vp = viewport_px(rect, screen_w, screen_h); + let aspect = if vp.h != 0 { + vp.w as f32 / vp.h as f32 + } else { + 1.0 + }; + let view = Mat4::look_at(cam.eye, cam.look_at, cam.up); + let proj = projection_matrix(cam.projection, aspect); + let vp_mat = proj.mul(view); + let view_proj = vp_mat.to_cols_array(); + let inv_view_proj = vp_mat.inverse().to_cols_array(); + let full = RectPx { + x: 0, + y: 0, + w: gw as i32, + h: gh as i32, + }; + let ld = main_light.map(|l| l.dir).unwrap_or(DEFAULT_LIGHT_DIR); + let lc = main_light.map(|l| l.color).unwrap_or([1.0, 1.0, 1.0]); + + // --- G-buffer pass (full target) --- + let clear_color = cam.clear.color.unwrap_or([0.0, 0.0, 0.0, 1.0]); + gpu.begin_pass( + PassTarget::RenderTarget(gb_rt), + full, + ClearSpec { + color: Some(clear_color), + depth: Some(1.0), + }, + ); + gpu.set_pipeline(self.gbuffer_prog, &PipelineState::default()); + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_viewProj", + value: UniformValue::Mat4(view_proj), + }); + self.uniforms.push(Uniform { + name: "u_albedo", + value: UniformValue::Sampler(0), + }); + gpu.set_uniforms(&self.uniforms); + gpu.set_pipeline(self.gbuffer_skinned_prog, &PipelineState::default()); + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_viewProj", + value: UniformValue::Mat4(view_proj), + }); + self.uniforms.push(Uniform { + name: "u_albedo", + value: UniformValue::Sampler(0), + }); + gpu.set_uniforms(&self.uniforms); + gpu.set_pipeline(self.terrain_gbuffer_prog, &PipelineState::default()); + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_viewProj", + value: UniformValue::Mat4(view_proj), + }); + gpu.set_uniforms(&self.uniforms); + self.draw_all_meshes( + gpu, + world, + DrawMode::GBuffer, + cam.viewport_id, + None, + cam.eye, + ); + self.draw_instance_batches(gpu, DrawMode::GBuffer, cam.viewport_id, view_proj, cam.eye); + gpu.end_pass(); + + // --- deferred sun light pass → HDR scene target --- + gpu.begin_pass( + PassTarget::RenderTarget(scene_rt), + full, + ClearSpec { + color: Some([0.0, 0.0, 0.0, 1.0]), + depth: None, + }, + ); + gpu.set_pipeline( + self.light_prog, + &PipelineState { + depth_test: false, + depth_write: false, + cull: Cull::None, + color_write: true, + blend: false, + additive: false, + }, + ); + if let Some(t) = gpu.render_target_color_n(gb_rt, 0) { + gpu.bind_texture(0, t); + } + if let Some(t) = gpu.render_target_color_n(gb_rt, 1) { + gpu.bind_texture(1, t); + } + if let Some(t) = gpu.render_target_depth(gb_rt) { + gpu.bind_texture(2, t); + } + if let Some(t) = gpu.render_target_depth(self.shadow_rt) { + gpu.bind_texture(3, t); + } + if let Some(t) = gpu.render_target_color_n(gb_rt, 2) { + gpu.bind_texture(5, t); + } + if let Some(t) = gpu.render_target_color_n(gb_rt, 3) { + gpu.bind_texture(6, t); + } + let gi_binding = self.gi.as_ref().and_then(GiVolume::binding); + if let Some(gi) = self.gi.as_ref() { + gpu.bind_texture_3d(4, gi.radiance_texture()); + } + let world_texel = 2.0 * self.shadow_world_radius / self.shadow_size as f32; + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_gb0", + value: UniformValue::Sampler(0), + }); + self.uniforms.push(Uniform { + name: "u_gb1", + value: UniformValue::Sampler(1), + }); + self.uniforms.push(Uniform { + name: "u_gb2", + value: UniformValue::Sampler(5), + }); + self.uniforms.push(Uniform { + name: "u_gb3", + value: UniformValue::Sampler(6), + }); + self.uniforms.push(Uniform { + name: "u_depth", + value: UniformValue::Sampler(2), + }); + self.uniforms.push(Uniform { + name: "u_shadowMap", + value: UniformValue::Sampler(3), + }); + self.uniforms.push(Uniform { + name: "u_gi", + value: UniformValue::Sampler(4), + }); + self.uniforms.push(Uniform { + name: "u_invViewProj", + value: UniformValue::Mat4(inv_view_proj), + }); + self.uniforms.push(Uniform { + name: "u_lightViewProj", + value: UniformValue::Mat4(self.shadow_view_proj), + }); + self.uniforms.push(Uniform { + name: "u_lightDir", + value: UniformValue::Vec3([ld.x, ld.y, ld.z]), + }); + self.uniforms.push(Uniform { + name: "u_lightColor", + value: UniformValue::Vec3(lc), + }); + self.uniforms.push(Uniform { + name: "u_camEye", + value: UniformValue::Vec3([cam.eye.x, cam.eye.y, cam.eye.z]), + }); + self.uniforms.push(Uniform { + name: "u_ambient", + value: UniformValue::Float(self.ambient), + }); + self.uniforms.push(Uniform { + name: "u_fogColor", + value: UniformValue::Vec3(self.fog_color), + }); + self.uniforms.push(Uniform { + name: "u_fogNear", + value: UniformValue::Float(self.fog_near), + }); + self.uniforms.push(Uniform { + name: "u_fogFar", + value: UniformValue::Float(self.fog_far), + }); + self.uniforms.push(Uniform { + name: "u_shadowTexelUV", + value: UniformValue::Float(1.0 / self.shadow_size as f32), + }); + self.uniforms.push(Uniform { + name: "u_shadowWorldTexel", + value: UniformValue::Float(world_texel), + }); + self.uniforms.push(Uniform { + name: "u_sunPenumbraScale", + value: UniformValue::Float(self.settings.shadows.penumbra), + }); + self.uniforms.push(Uniform { + name: "u_shadowDepthBias", + value: UniformValue::Float(self.settings.shadows.depth_bias), + }); + self.uniforms.push(Uniform { + name: "u_shadowNormalBias", + value: UniformValue::Float(self.settings.shadows.normal_bias), + }); + self.uniforms.push(Uniform { + name: "u_emissiveScalar", + value: UniformValue::Float(self.settings.emissive_scalar), + }); + self.uniforms.push(Uniform { + name: "u_aoIntensity", + value: UniformValue::Float(self.settings.ao_intensity), + }); + self.uniforms.push(Uniform { + name: "u_giReady", + value: UniformValue::Int(if gi_binding.is_some() { 1 } else { 0 }), + }); + self.uniforms.push(Uniform { + name: "u_giOrigin", + value: UniformValue::Vec3(gi_binding.map_or([0.0; 3], |b| b.origin)), + }); + self.uniforms.push(Uniform { + name: "u_giValidMin", + value: UniformValue::Vec3(gi_binding.map_or([0.0; 3], |b| b.valid_min)), + }); + self.uniforms.push(Uniform { + name: "u_giValidMax", + value: UniformValue::Vec3(gi_binding.map_or([0.0; 3], |b| b.valid_max)), + }); + self.uniforms.push(Uniform { + name: "u_giBlend", + value: UniformValue::Float(gi_binding.map_or(0.0, |b| b.blend)), + }); + self.uniforms.push(Uniform { + name: "u_giCell", + value: UniformValue::Float(crate::gi::GI_CELL), + }); + self.uniforms.push(Uniform { + name: "u_giStrength", + value: UniformValue::Float(1.4), + }); + self.uniforms.push(Uniform { + name: "u_exposure", + value: UniformValue::Float(self.exposure), + }); + gpu.set_uniforms(&self.uniforms); + self.draw_fullscreen(gpu); + gpu.end_pass(); + // --- point-light volumes (additive into the HDR scene target) --- + self.point_light_pass( + gpu, + scene_rt, + full, + &view_proj, + &inv_view_proj, + cam.eye, + gw, + gh, + ); + let scene_copy_rt = self.scene_copy_rt.as_ref().expect("screen targets").rt; + gpu.begin_pass( + PassTarget::RenderTarget(scene_copy_rt), + full, + ClearSpec::default(), + ); + gpu.set_pipeline( + self.copy_prog, + &PipelineState { + depth_test: false, + depth_write: false, + cull: Cull::None, + color_write: true, + blend: false, + additive: false, + }, + ); + if let Some(texture) = gpu.render_target_color(scene_rt) { + gpu.bind_texture(0, texture); + } + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_source", + value: UniformValue::Sampler(0), + }); + gpu.set_uniforms(&self.uniforms); + self.draw_fullscreen(gpu); + gpu.end_pass(); + self.transparent_pass( + gpu, world, scene_rt, full, cam, view_proj, ld, lc, use_shadow, + ); + self.bloom_passes(gpu, scene_rt); + + // --- tonemap + grade → LDR target --- + gpu.begin_pass(PassTarget::RenderTarget(ldr_rt), full, ClearSpec::default()); + gpu.set_pipeline( + self.tonemap_prog, + &PipelineState { + depth_test: false, + depth_write: false, + cull: Cull::None, + color_write: true, + blend: false, + additive: false, + }, + ); + if let Some(t) = gpu.render_target_color(scene_rt) { + gpu.bind_texture(0, t); + } + if let Some(t) = gpu.render_target_depth(gb_rt) { + gpu.bind_texture(1, t); + } + if let Some(bloom) = self + .bloom_extract_rt + .as_ref() + .and_then(|target| gpu.render_target_color(target.rt)) + { + gpu.bind_texture(2, bloom); + } + let g = self.grade; + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_scene", + value: UniformValue::Sampler(0), + }); + self.uniforms.push(Uniform { + name: "u_depth", + value: UniformValue::Sampler(1), + }); + self.uniforms.push(Uniform { + name: "u_bloomTex", + value: UniformValue::Sampler(2), + }); + self.uniforms.push(Uniform { + name: "u_boneTint", + value: UniformValue::Vec3(g.bone_tint), + }); + self.uniforms.push(Uniform { + name: "u_desaturate", + value: UniformValue::Float(g.desaturate), + }); + self.uniforms.push(Uniform { + name: "u_sceneDarken", + value: UniformValue::Float(g.scene_darken), + }); + self.uniforms.push(Uniform { + name: "u_blackLift", + value: UniformValue::Float(g.black_lift), + }); + self.uniforms.push(Uniform { + name: "u_invExposure", + value: UniformValue::Float(1.0 / self.exposure), + }); + self.uniforms.push(Uniform { + name: "u_bloomIntensity", + value: UniformValue::Float(self.bloom.intensity * BLOOM_INTENSITY_GAIN), + }); + let master = self.settings.color_grade; + let palette = self.settings.palette; + self.uniforms.push(Uniform { + name: "u_masterExposure", + value: UniformValue::Float(self.settings.exposure), + }); + self.uniforms.push(Uniform { + name: "u_saturation", + value: UniformValue::Float(master.saturation), + }); + self.uniforms.push(Uniform { + name: "u_contrast", + value: UniformValue::Float(master.contrast), + }); + self.uniforms.push(Uniform { + name: "u_gamma", + value: UniformValue::Float(master.gamma), + }); + self.uniforms.push(Uniform { + name: "u_temperature", + value: UniformValue::Float(master.temperature), + }); + self.uniforms.push(Uniform { + name: "u_tint", + value: UniformValue::Float(master.tint), + }); + self.uniforms.push(Uniform { + name: "u_lift", + value: UniformValue::Vec3(master.lift), + }); + self.uniforms.push(Uniform { + name: "u_colorGamma", + value: UniformValue::Vec3(master.color_gamma), + }); + self.uniforms.push(Uniform { + name: "u_gain", + value: UniformValue::Vec3(master.gain), + }); + self.uniforms.push(Uniform { + name: "u_paletteEnabled", + value: UniformValue::Int(palette.enabled as i32), + }); + self.uniforms.push(Uniform { + name: "u_paletteLevels", + value: UniformValue::Float(palette.levels as f32), + }); + self.uniforms.push(Uniform { + name: "u_paletteStrength", + value: UniformValue::Float(palette.strength), + }); + self.uniforms.push(Uniform { + name: "u_paletteDither", + value: UniformValue::Float(palette.dither), + }); + gpu.set_uniforms(&self.uniforms); + self.draw_fullscreen(gpu); + gpu.end_pass(); + // --- FXAA 3.11 preset 12 → screen, restoring opaque depth --- + gpu.begin_pass(PassTarget::Screen, vp, ClearSpec::default()); + gpu.set_pipeline( + self.fxaa_prog, + &PipelineState { + depth_test: false, + depth_write: true, + cull: Cull::None, + color_write: true, + blend: false, + additive: false, + }, + ); + if let Some(texture) = gpu.render_target_color(ldr_rt) { + gpu.bind_texture(0, texture); + } + if let Some(depth) = gpu.render_target_depth(gb_rt) { + gpu.bind_texture(1, depth); + } + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_ldr", + value: UniformValue::Sampler(0), + }); + self.uniforms.push(Uniform { + name: "u_depth", + value: UniformValue::Sampler(1), + }); + self.uniforms.push(Uniform { + name: "u_invResolution", + value: UniformValue::Vec2([1.0 / gw as f32, 1.0 / gh as f32]), + }); + self.uniforms.push(Uniform { + name: "u_enabled", + value: UniformValue::Int(self.settings.aa.enabled as i32), + }); + self.uniforms.push(Uniform { + name: "u_edgeThresholdMin", + value: UniformValue::Float(self.settings.aa.edge_threshold_min), + }); + self.uniforms.push(Uniform { + name: "u_edgeThreshold", + value: UniformValue::Float(self.settings.aa.edge_threshold), + }); + self.uniforms.push(Uniform { + name: "u_subpixelBlend", + value: UniformValue::Float(self.settings.aa.subpixel_blend), + }); + gpu.set_uniforms(&self.uniforms); + self.draw_fullscreen(gpu); + gpu.end_pass(); + } + + fn bloom_passes<G: Gpu>(&mut self, gpu: &mut G, scene_rt: RenderTargetId) { + let (extract, blur, width, height) = match (&self.bloom_extract_rt, &self.bloom_blur_rt) { + (Some(extract), Some(blur)) => (extract.rt, blur.rt, extract.w, extract.h), + _ => return, + }; + let viewport = RectPx { + x: 0, + y: 0, + w: width as i32, + h: height as i32, + }; + gpu.begin_pass( + PassTarget::RenderTarget(extract), + viewport, + ClearSpec { + color: Some([0.0; 4]), + depth: None, + }, + ); + gpu.set_pipeline( + self.bloom_extract_prog, + &PipelineState { + depth_test: false, + depth_write: false, + cull: Cull::None, + color_write: true, + blend: false, + additive: false, + }, + ); + if let Some(scene) = gpu.render_target_color(scene_rt) { + gpu.bind_texture(0, scene); + } + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_scene", + value: UniformValue::Sampler(0), + }); + self.uniforms.push(Uniform { + name: "u_threshold", + value: UniformValue::Float(self.bloom.threshold * self.exposure), + }); + gpu.set_uniforms(&self.uniforms); + self.draw_fullscreen(gpu); + gpu.end_pass(); + + let radius = self.settings.bloom_radius; + for (source, target, direction) in [ + (extract, blur, [radius / width as f32, 0.0]), + (blur, extract, [0.0, radius / height as f32]), + ] { + gpu.begin_pass( + PassTarget::RenderTarget(target), + viewport, + ClearSpec { + color: Some([0.0; 4]), + depth: None, + }, + ); + gpu.set_pipeline( + self.bloom_blur_prog, + &PipelineState { + depth_test: false, + depth_write: false, + cull: Cull::None, + color_write: true, + blend: false, + additive: false, + }, + ); + if let Some(texture) = gpu.render_target_color(source) { + gpu.bind_texture(0, texture); + } + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_source", + value: UniformValue::Sampler(0), + }); + self.uniforms.push(Uniform { + name: "u_direction", + value: UniformValue::Vec2(direction), + }); + gpu.set_uniforms(&self.uniforms); + self.draw_fullscreen(gpu); + gpu.end_pass(); + } + } + + /// Point-light volume pass: gather lights, additive PBR into `scene_rt`. + #[allow(clippy::too_many_arguments)] + fn point_light_pass<G: Gpu>( + &mut self, + gpu: &mut G, + scene_rt: RenderTargetId, + full: RectPx, + view_proj: &[f32; 16], + inv_view_proj: &[f32; 16], + cam_eye: Vec3, + gw: u32, + gh: u32, + ) { + self.pl_scratch.clear(); + let count = self.selected_lights.len() as u32; + for light in &self.selected_lights { + self.pl_scratch.extend_from_slice(&[ + light.light.position[0], + light.light.position[1], + light.light.position[2], + light.light.radius, + light.light.color[0], + light.light.color[1], + light.light.color[2], + light.light.intensity, + ]); + } + if count == 0 { + return; + } + gpu.begin_pass( + PassTarget::RenderTarget(scene_rt), + full, + ClearSpec::default(), + ); + gpu.set_pipeline( + self.point_light_prog, + &PipelineState { + depth_test: false, + depth_write: false, + cull: Cull::Front, + color_write: true, + blend: true, + additive: true, + }, + ); + if let Some(t) = gpu.render_target_color_n(self.gbuffer_rt.as_ref().unwrap().rt, 0) { + gpu.bind_texture(0, t); + } + if let Some(t) = gpu.render_target_color_n(self.gbuffer_rt.as_ref().unwrap().rt, 1) { + gpu.bind_texture(1, t); + } + if let Some(t) = gpu.render_target_depth(self.gbuffer_rt.as_ref().unwrap().rt) { + gpu.bind_texture(2, t); + } + if let Some(t) = gpu.render_target_color_n(self.gbuffer_rt.as_ref().unwrap().rt, 3) { + gpu.bind_texture(3, t); + } + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_viewProj", + value: UniformValue::Mat4(*view_proj), + }); + self.uniforms.push(Uniform { + name: "u_invViewProj", + value: UniformValue::Mat4(*inv_view_proj), + }); + self.uniforms.push(Uniform { + name: "u_gb0", + value: UniformValue::Sampler(0), + }); + self.uniforms.push(Uniform { + name: "u_gb1", + value: UniformValue::Sampler(1), + }); + self.uniforms.push(Uniform { + name: "u_gb3", + value: UniformValue::Sampler(3), + }); + self.uniforms.push(Uniform { + name: "u_depth", + value: UniformValue::Sampler(2), + }); + self.uniforms.push(Uniform { + name: "u_camEye", + value: UniformValue::Vec3([cam_eye.x, cam_eye.y, cam_eye.z]), + }); + self.uniforms.push(Uniform { + name: "u_screenSize", + value: UniformValue::Vec2([gw as f32, gh as f32]), + }); + self.uniforms.push(Uniform { + name: "u_exposure", + value: UniformValue::Float(self.exposure), + }); + gpu.set_uniforms(&self.uniforms); + gpu.update_buffer(self.pl_inst_buf, f32_bytes(&self.pl_scratch)); + gpu.draw_instanced( + self.pl_vbo, + Some(self.pl_ebo), + &MESH_LAYOUT, + self.pl_index_count, + self.pl_inst_buf, + &POINT_LIGHT_INSTANCE_LAYOUT, + count, + ); + gpu.end_pass(); + } + + #[allow(clippy::too_many_arguments)] + fn transparent_pass<G: Gpu, W: RenderWorld>( + &mut self, + gpu: &mut G, + world: &mut W, + scene_rt: RenderTargetId, + full: RectPx, + cam: Camera, + view_proj: [f32; 16], + light_dir: Vec3, + light_color: [f32; 3], + use_shadow: bool, + ) { + gpu.begin_pass( + PassTarget::RenderTarget(scene_rt), + full, + ClearSpec::default(), + ); + for program in [self.mesh_prog, self.mesh_skinned_prog] { + gpu.set_pipeline(program, &PipelineState::default()); + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_viewProj", + value: UniformValue::Mat4(view_proj), + }); + self.uniforms.push(Uniform { + name: "u_lightViewProj", + value: UniformValue::Mat4(self.shadow_view_proj), + }); + self.uniforms.push(Uniform { + name: "u_lightDir", + value: UniformValue::Vec3([light_dir.x, light_dir.y, light_dir.z]), + }); + self.uniforms.push(Uniform { + name: "u_sceneCopy", + value: UniformValue::Sampler(6), + }); + self.uniforms.push(Uniform { + name: "u_opaqueDepth", + value: UniformValue::Sampler(7), + }); + self.uniforms.push(Uniform { + name: "u_screenSize", + value: UniformValue::Vec2([full.w as f32, full.h as f32]), + }); + self.uniforms.push(Uniform { + name: "u_transparentPass", + value: UniformValue::Int(1), + }); + if let Some(copy) = + gpu.render_target_color(self.scene_copy_rt.as_ref().expect("screen targets").rt) + { + gpu.bind_texture(6, copy); + } + if let Some(depth) = + gpu.render_target_depth(self.gbuffer_rt.as_ref().expect("screen targets").rt) + { + gpu.bind_texture(7, depth); + } + self.uniforms.push(Uniform { + name: "u_lightColor", + value: UniformValue::Vec3(light_color), + }); + self.uniforms.push(Uniform { + name: "u_ambient", + value: UniformValue::Float(self.ambient), + }); + self.uniforms.push(Uniform { + name: "u_shadowMap", + value: UniformValue::Sampler(0), + }); + self.uniforms.push(Uniform { + name: "u_useShadow", + value: UniformValue::Int(use_shadow as i32), + }); + self.uniforms.push(Uniform { + name: "u_albedo", + value: UniformValue::Sampler(1), + }); + self.uniforms.push(Uniform { + name: "u_camEye", + value: UniformValue::Vec3([cam.eye.x, cam.eye.y, cam.eye.z]), + }); + self.uniforms.push(Uniform { + name: "u_fogColor", + value: UniformValue::Vec3(self.fog_color), + }); + self.uniforms.push(Uniform { + name: "u_fogNear", + value: UniformValue::Float(self.fog_near), + }); + self.uniforms.push(Uniform { + name: "u_fogFar", + value: UniformValue::Float(self.fog_far), + }); + gpu.set_uniforms(&self.uniforms); + if let Some(depth_tex) = gpu.render_target_depth(self.shadow_rt) { + gpu.bind_texture(0, depth_tex); + } + } + self.draw_all_meshes( + gpu, + world, + DrawMode::Transparent, + cam.viewport_id, + None, + cam.eye, + ); + gpu.end_pass(); + } + + /// Forward camera (RTT minimap/portraits): unchanged lit forward path. + #[allow(clippy::too_many_arguments)] + fn forward_camera<G: Gpu, W: RenderWorld>( + &mut self, + gpu: &mut G, + world: &mut W, + cam: Camera, + screen_w: u32, + screen_h: u32, + main_light: Option<DirectionalLight>, + use_shadow: bool, + ) { + self.select_lights_for_camera(world, cam.eye); + let (target, vp) = match cam.target { + CamTarget::Screen(rect) => (PassTarget::Screen, viewport_px(rect, screen_w, screen_h)), + CamTarget::Texture(rt) => { + let (width, height) = gpu.render_target_size(rt).unwrap_or((256, 256)); + ( + PassTarget::RenderTarget(rt), + RectPx { + x: 0, + y: 0, + w: width as i32, + h: height as i32, + }, + ) + } + }; + let aspect = if vp.h != 0 { + vp.w as f32 / vp.h as f32 + } else { + 1.0 + }; + let view = Mat4::look_at(cam.eye, cam.look_at, cam.up); + let proj = projection_matrix(cam.projection, aspect); + let view_proj = proj.mul(view).to_cols_array(); + let ld = main_light.map(|l| l.dir).unwrap_or(DEFAULT_LIGHT_DIR); + let lc = main_light.map(|l| l.color).unwrap_or([1.0, 1.0, 1.0]); + + gpu.begin_pass(target, vp, cam.clear); + for prog in [self.mesh_prog, self.mesh_skinned_prog] { + gpu.set_pipeline(prog, &PipelineState::default()); + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_viewProj", + value: UniformValue::Mat4(view_proj), + }); + self.uniforms.push(Uniform { + name: "u_lightViewProj", + value: UniformValue::Mat4(self.shadow_view_proj), + }); + self.uniforms.push(Uniform { + name: "u_lightDir", + value: UniformValue::Vec3([ld.x, ld.y, ld.z]), + }); + self.uniforms.push(Uniform { + name: "u_lightColor", + value: UniformValue::Vec3(lc), + }); + self.uniforms.push(Uniform { + name: "u_ambient", + value: UniformValue::Float(self.ambient), + }); + self.uniforms.push(Uniform { + name: "u_shadowMap", + value: UniformValue::Sampler(0), + }); + self.uniforms.push(Uniform { + name: "u_useShadow", + value: UniformValue::Int(if use_shadow { 1 } else { 0 }), + }); + self.uniforms.push(Uniform { + name: "u_albedo", + value: UniformValue::Sampler(1), + }); + self.uniforms.push(Uniform { + name: "u_camEye", + value: UniformValue::Vec3([cam.eye.x, cam.eye.y, cam.eye.z]), + }); + self.uniforms.push(Uniform { + name: "u_screenSize", + value: UniformValue::Vec2([vp.w as f32, vp.h as f32]), + }); + self.uniforms.push(Uniform { + name: "u_transparentPass", + value: UniformValue::Int(0), + }); + self.uniforms.push(Uniform { + name: "u_fogColor", + value: UniformValue::Vec3(self.fog_color), + }); + self.uniforms.push(Uniform { + name: "u_fogNear", + value: UniformValue::Float(self.fog_near), + }); + self.uniforms.push(Uniform { + name: "u_fogFar", + value: UniformValue::Float(self.fog_far), + }); + gpu.set_uniforms(&self.uniforms); + if let Some(depth_tex) = gpu.render_target_depth(self.shadow_rt) { + gpu.bind_texture(0, depth_tex); + } + } + self.draw_all_meshes( + gpu, + world, + DrawMode::Forward, + cam.viewport_id, + None, + cam.eye, + ); + gpu.end_pass(); + } + + /// Draw a fullscreen NDC quad from the reused dynamic buffer. + fn draw_fullscreen<G: Gpu>(&mut self, gpu: &mut G) { + self.quad.clear(); + self.quad.extend_from_slice(&FS_QUAD); + gpu.update_buffer(self.dyn_buf, f32_bytes(&self.quad)); + gpu.draw(self.dyn_buf, None, &QUAD_LAYOUT, 6); + } + + fn draw_all_meshes<G: Gpu, W: RenderWorld>( + &mut self, + gpu: &mut G, + _world: &mut W, + mode: DrawMode, + viewport_id: u8, + _want_skinned: Option<bool>, + camera_eye: Vec3, + ) { + let visible_only = !matches!(mode, DrawMode::Depth); + self.draw_scratch.clear(); + let meshes = &self.meshes; + let materials = &self.materials; + for (scene_index, record) in self.scene_draws.iter().enumerate() { + let mesh = record.mesh; + if meshes.get(mesh.mesh.0 as usize).is_none() { + continue; + } + if mesh.viewport_mask == 0 + || (visible_only && (mesh.viewport_mask & (1u32 << viewport_id)) == 0) + { + continue; + } + let material = materials.get(mesh.material.0 as usize).copied(); + let desc = material.map(|value| value.desc).unwrap_or_default(); + let transparent = desc.blend || desc.transmission > 0.0; + if (matches!(mode, DrawMode::Depth | DrawMode::GBuffer) && transparent) + || (matches!(mode, DrawMode::Transparent) && !transparent) + { + continue; + } + self.draw_scratch.push(scene_index); + } + if matches!(mode, DrawMode::Transparent) { + self.draw_scratch.sort_unstable_by(|left, right| { + let left_record = self.scene_draws[*left]; + let right_record = self.scene_draws[*right]; + let left_delta = left_record.transform.pos.sub(camera_eye); + let right_delta = right_record.transform.pos.sub(camera_eye); + right_delta + .dot(right_delta) + .total_cmp(&left_delta.dot(left_delta)) + .then_with(|| left_record.entity_index.cmp(&right_record.entity_index)) + .then_with(|| { + left_record + .entity_generation + .cmp(&right_record.entity_generation) + }) + .then_with(|| { + left_record + .mesh + .material + .0 + .cmp(&right_record.mesh.material.0) + }) + }); + } + for draw_index in 0..self.draw_scratch.len() { + let record = self.scene_draws[self.draw_scratch[draw_index]]; + let mr = record.mesh; + let tr = record.transform; + let mesh = match self.meshes.get(mr.mesh.0 as usize) { + Some(mesh) => *mesh, + None => continue, + }; + let model = Mat4::from_trs(tr.pos, tr.rot, tr.scale).to_cols_array(); + let material = self.materials.get(mr.material.0 as usize).copied(); + let material_desc = material.map(|value| value.desc).unwrap_or_default(); + let transparent = material_desc.blend || material_desc.transmission > 0.0; + let program = match mode { + DrawMode::Depth => { + if material_desc.terrain.is_some() { + self.terrain_depth_prog + } else if mesh.skinned { + self.depth_skinned_prog + } else { + self.depth_prog + } + } + DrawMode::Forward | DrawMode::Transparent => { + if mesh.skinned { + self.mesh_skinned_prog + } else { + self.mesh_prog + } + } + DrawMode::GBuffer => { + if material_desc.terrain.is_some() { + self.terrain_gbuffer_prog + } else if mesh.skinned { + self.gbuffer_skinned_prog + } else { + self.gbuffer_prog + } + } + }; + gpu.set_pipeline( + program, + &PipelineState { + depth_test: !matches!(mode, DrawMode::Transparent), + depth_write: !matches!(mode, DrawMode::Transparent) && !transparent, + cull: if material_desc.double_sided { + Cull::None + } else { + Cull::Back + }, + color_write: true, + blend: matches!(mode, DrawMode::Transparent) || material_desc.blend, + additive: false, + }, + ); + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_model", + value: UniformValue::Mat4(model), + }); + self.uniforms.push(Uniform { + name: "u_cutaway", + value: UniformValue::Vec2([ + record.cutaway.cutoff_y, + record.cutaway.amount.clamp(0.0, 1.0), + ]), + }); + if matches!(mode, DrawMode::Forward | DrawMode::Transparent) { + for light in &mut self.selected_lights { + let dx = light.light.position[0] - tr.pos.x; + let dy = light.light.position[1] - tr.pos.y; + let dz = light.light.position[2] - tr.pos.z; + light.distance2 = dx * dx + dy * dy + dz * dz; + } + self.selected_lights.sort_unstable_by(|left, right| { + left.distance2 + .total_cmp(&right.distance2) + .then_with(|| left.entity_index.cmp(&right.entity_index)) + .then_with(|| left.entity_generation.cmp(&right.entity_generation)) + }); + self.forward_lights.clear(); + for light in self.selected_lights.iter().take(self.max_forward_lights) { + self.forward_lights.push(light.light); + } + gpu.set_forward_lights(&self.forward_lights); + } + self.uniforms.push(Uniform { + name: "u_hasVertexColor", + value: UniformValue::Int(mesh.has_vertex_color as i32), + }); + self.uniforms.push(Uniform { + name: "u_hasTangent", + value: UniformValue::Int(mesh.has_tangent as i32), + }); + let mut albedo_tex = None; + match mode { + DrawMode::Depth => { + if let Some(terrain) = material_desc.terrain { + self.uniforms.push(Uniform { + name: "u_terrainControl", + value: UniformValue::Sampler(0), + }); + self.uniforms.push(Uniform { + name: "u_terrainOrigin", + value: UniformValue::Vec2(terrain.world_origin), + }); + self.uniforms.push(Uniform { + name: "u_terrainWorldSize", + value: UniformValue::Float(terrain.world_size), + }); + gpu.bind_texture(0, terrain.control_texture); + } + } + DrawMode::Forward | DrawMode::Transparent => { + self.uniforms.push(Uniform { + name: "u_transmission", + value: UniformValue::Float(material_desc.transmission), + }); + let ior = material_desc.ior.max(1.0); + let dielectric_ratio = (ior - 1.0) / (ior + 1.0); + self.uniforms.push(Uniform { + name: "u_ior", + value: UniformValue::Float(ior), + }); + self.uniforms.push(Uniform { + name: "u_metallic", + value: UniformValue::Float(material_desc.metallic), + }); + self.uniforms.push(Uniform { + name: "u_roughness", + value: UniformValue::Float(material_desc.roughness), + }); + self.uniforms.push(Uniform { + name: "u_dielectricF0", + value: UniformValue::Float( + dielectric_ratio * dielectric_ratio * material_desc.specular, + ), + }); + self.uniforms.push(Uniform { + name: "u_clearcoat", + value: UniformValue::Float(material_desc.clearcoat), + }); + self.uniforms.push(Uniform { + name: "u_clearcoatRoughness", + value: UniformValue::Float(material_desc.clearcoat_roughness), + }); + let mat = self.materials.get(mr.material.0 as usize).copied(); + let color = mat + .map(|m| m.desc.base_color) + .unwrap_or([0.8, 0.8, 0.8, 1.0]); + albedo_tex = mat.and_then(|m| m.desc.base_color_texture); + self.uniforms.push(Uniform { + name: "u_color", + value: UniformValue::Vec4(color), + }); + self.uniforms.push(Uniform { + name: "u_hasTex", + value: UniformValue::Int(if albedo_tex.is_some() { 1 } else { 0 }), + }); + self.uniforms.push(Uniform { + name: "u_aoTex", + value: UniformValue::Sampler(2), + }); + self.uniforms.push(Uniform { + name: "u_emissiveTex", + value: UniformValue::Sampler(3), + }); + self.uniforms.push(Uniform { + name: "u_hasAoTex", + value: UniformValue::Int(material_desc.occlusion_texture.is_some() as i32), + }); + self.uniforms.push(Uniform { + name: "u_hasEmissiveTex", + value: UniformValue::Int(material_desc.emissive_texture.is_some() as i32), + }); + self.uniforms.push(Uniform { + name: "u_aoStrength", + value: UniformValue::Float( + material_desc.occlusion_strength.clamp(0.0, 1.0), + ), + }); + self.uniforms.push(Uniform { + name: "u_aoIntensity", + value: UniformValue::Float(self.settings.ao_intensity), + }); + self.uniforms.push(Uniform { + name: "u_emissiveFactor", + value: UniformValue::Vec3(material_desc.emissive_factor), + }); + self.uniforms.push(Uniform { + name: "u_emissiveStrength", + value: UniformValue::Float( + material_desc.emissive_strength * self.settings.emissive_scalar, + ), + }); + gpu.bind_texture(2, material_desc.occlusion_texture.unwrap_or(self.white_tex)); + gpu.bind_texture(3, material_desc.emissive_texture.unwrap_or(self.black_tex)); + self.uniforms.push(Uniform { + name: "u_pointCount", + value: UniformValue::Int(self.forward_lights.len() as i32), + }); + } + DrawMode::GBuffer => { + if let Some(terrain) = material_desc.terrain { + albedo_tex = Some(terrain.control_texture); + self.uniforms.push(Uniform { + name: "u_terrainControl", + value: UniformValue::Sampler(0), + }); + self.uniforms.push(Uniform { + name: "u_terrainAlbedo", + value: UniformValue::Sampler(1), + }); + self.uniforms.push(Uniform { + name: "u_terrainNrma", + value: UniformValue::Sampler(2), + }); + self.uniforms.push(Uniform { + name: "u_terrainOrigin", + value: UniformValue::Vec2(terrain.world_origin), + }); + self.uniforms.push(Uniform { + name: "u_terrainWorldSize", + value: UniformValue::Float(terrain.world_size), + }); + self.uniforms.push(Uniform { + name: "u_terrainBiome", + value: UniformValue::Int(terrain.biome), + }); + self.uniforms.push(Uniform { + name: "u_terrainTileScale", + value: UniformValue::Float(terrain.tile_scale), + }); + self.uniforms.push(Uniform { + name: "u_terrainNormalStrength", + value: UniformValue::Float(terrain.normal_strength), + }); + self.uniforms.push(Uniform { + name: "u_camEye", + value: UniformValue::Vec3([camera_eye.x, camera_eye.y, camera_eye.z]), + }); + gpu.bind_texture_array(1, terrain.albedo_tiles); + gpu.bind_texture_array(2, terrain.nrma_tiles); + } else { + let mat = self.materials.get(mr.material.0 as usize).copied(); + let color = mat + .map(|m| m.desc.base_color) + .unwrap_or([0.8, 0.8, 0.8, 1.0]); + albedo_tex = mat.and_then(|m| m.desc.base_color_texture); + let metallic = mat.map(|m| m.desc.metallic).unwrap_or(1.0); + let roughness = mat.map(|m| m.desc.roughness).unwrap_or(1.0); + self.uniforms.push(Uniform { + name: "u_color", + value: UniformValue::Vec4(color), + }); + self.uniforms.push(Uniform { + name: "u_hasTex", + value: UniformValue::Int(if albedo_tex.is_some() { 1 } else { 0 }), + }); + self.uniforms.push(Uniform { + name: "u_metallic", + value: UniformValue::Float(metallic), + }); + self.uniforms.push(Uniform { + name: "u_roughness", + value: UniformValue::Float(roughness), + }); + let desc = mat.map(|material| material.desc).unwrap_or_default(); + let texture_uniforms = [ + ("u_mrTex", desc.metallic_roughness_texture, 1), + ("u_normalTex", desc.normal_texture, 2), + ("u_aoTex", desc.occlusion_texture, 3), + ("u_emissiveTex", desc.emissive_texture, 4), + ]; + for (name, texture, slot) in texture_uniforms { + self.uniforms.push(Uniform { + name, + value: UniformValue::Sampler(slot), + }); + let fallback = match slot { + 2 => self.normal_tex, + 4 => self.black_tex, + _ => self.white_tex, + }; + gpu.bind_texture(slot as u32, texture.unwrap_or(fallback)); + } + self.uniforms.push(Uniform { + name: "u_hasMrTex", + value: UniformValue::Int( + desc.metallic_roughness_texture.is_some() as i32 + ), + }); + self.uniforms.push(Uniform { + name: "u_hasNormalTex", + value: UniformValue::Int(desc.normal_texture.is_some() as i32), + }); + self.uniforms.push(Uniform { + name: "u_hasAoTex", + value: UniformValue::Int(desc.occlusion_texture.is_some() as i32), + }); + self.uniforms.push(Uniform { + name: "u_hasEmissiveTex", + value: UniformValue::Int(desc.emissive_texture.is_some() as i32), + }); + self.uniforms.push(Uniform { + name: "u_normalScale", + value: UniformValue::Float(desc.normal_scale), + }); + // Keep authored occlusion/emission in the G-buffer. + // Their global mastering controls are applied once in + // the deferred lighting pass, not baked into the + // material and multiplied a second time. + self.uniforms.push(Uniform { + name: "u_aoStrength", + value: UniformValue::Float(desc.occlusion_strength.clamp(0.0, 1.0)), + }); + self.uniforms.push(Uniform { + name: "u_emissiveFactor", + value: UniformValue::Vec3(desc.emissive_factor), + }); + self.uniforms.push(Uniform { + name: "u_emissiveStrength", + value: UniformValue::Float(desc.emissive_strength), + }); + self.uniforms.push(Uniform { + name: "u_clearcoat", + value: UniformValue::Float(desc.clearcoat), + }); + self.uniforms.push(Uniform { + name: "u_clearcoatRoughness", + value: UniformValue::Float(desc.clearcoat_roughness), + }); + let ior = desc.ior.max(1.0); + let ratio = (ior - 1.0) / (ior + 1.0); + self.uniforms.push(Uniform { + name: "u_dielectricF0", + value: UniformValue::Float(ratio * ratio * desc.specular), + }); + self.uniforms.push(Uniform { + name: "u_alphaCutoff", + value: UniformValue::Float(if desc.blend { + 0.0 + } else { + desc.alpha_cutoff + }), + }); + } + } + } + gpu.set_uniforms(&self.uniforms); + let albedo = albedo_tex.unwrap_or(self.white_tex); + gpu.bind_texture( + if matches!(mode, DrawMode::GBuffer) { + 0 + } else { + 1 + }, + albedo, + ); + if mesh.skinned { + let o = mr.skin.offset as usize; + let c = mr.skin.count as usize; + let Some(end) = o.checked_add(c) else { + continue; + }; + if c == 0 || end > self.skin_arena.len() { + continue; + } + gpu.set_joints(&self.skin_arena[o..end]); + } + gpu.draw(mesh.vbo, Some(mesh.ebo), &mesh.layout, mesh.index_count); + } + } + + fn draw_instance_batches<G: Gpu>( + &mut self, + gpu: &mut G, + mode: DrawMode, + viewport_id: u8, + view_proj: [f32; 16], + camera_eye: Vec3, + ) { + if !matches!(mode, DrawMode::Depth | DrawMode::GBuffer) { + return; + } + for index in 0..self.instance_batches.len() { + let batch = self.instance_batches[index]; + if batch.count == 0 || !visible_in(batch.viewport_mask, viewport_id) { + continue; + } + if matches!(mode, DrawMode::GBuffer) && batch.max_distance > 0.0 { + let delta = batch.center.sub(camera_eye); + if delta.dot(delta) > batch.max_distance * batch.max_distance { + continue; + } + } + let Some(mesh) = self.meshes.get(batch.mesh.0 as usize).copied() else { + continue; + }; + let Some(material) = self.materials.get(batch.material.0 as usize).copied() else { + continue; + }; + let desc = material.desc; + let program = if matches!(mode, DrawMode::Depth) { + self.instance_depth_prog + } else { + self.instance_gbuffer_prog + }; + gpu.set_pipeline( + program, + &PipelineState { + depth_test: true, + depth_write: true, + cull: if desc.double_sided { + Cull::None + } else { + Cull::Back + }, + color_write: matches!(mode, DrawMode::GBuffer), + blend: false, + additive: false, + }, + ); + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_model", + value: UniformValue::Mat4(Mat4::IDENTITY.to_cols_array()), + }); + if matches!(mode, DrawMode::Depth) { + self.uniforms.push(Uniform { + name: "u_lightViewProj", + value: UniformValue::Mat4(view_proj), + }); + } else { + self.uniforms.push(Uniform { + name: "u_viewProj", + value: UniformValue::Mat4(view_proj), + }); + self.uniforms.push(Uniform { + name: "u_hasVertexColor", + value: UniformValue::Int(0), + }); + self.uniforms.push(Uniform { + name: "u_hasTangent", + value: UniformValue::Int(0), + }); + self.uniforms.push(Uniform { + name: "u_color", + value: UniformValue::Vec4(desc.base_color), + }); + self.uniforms.push(Uniform { + name: "u_metallic", + value: UniformValue::Float(desc.metallic), + }); + self.uniforms.push(Uniform { + name: "u_roughness", + value: UniformValue::Float(desc.roughness), + }); + self.uniforms.push(Uniform { + name: "u_clearcoat", + value: UniformValue::Float(desc.clearcoat), + }); + self.uniforms.push(Uniform { + name: "u_clearcoatRoughness", + value: UniformValue::Float(desc.clearcoat_roughness), + }); + let ior = desc.ior.max(1.0); + let ratio = (ior - 1.0) / (ior + 1.0); + self.uniforms.push(Uniform { + name: "u_dielectricF0", + value: UniformValue::Float(ratio * ratio * desc.specular), + }); + } + gpu.set_uniforms(&self.uniforms); + gpu.draw_instanced( + mesh.vbo, + Some(mesh.ebo), + &mesh.layout, + mesh.index_count, + batch.buffer, + &INSTANCE_MAT4_LAYOUT, + batch.count, + ); + } + } + + /// Repaint render-target quads over a later UI stage. Connected windows use + /// this after their opaque chrome so portraits cannot sit behind world HUD. + pub fn render_composites_overlay<G: Gpu, W: RenderWorld>( + &mut self, + gpu: &mut G, + world: &mut W, + screen_w: u32, + screen_h: u32, + ) { + self.composite_pass(gpu, world, screen_w, screen_h, i16::MIN..=i16::MAX); + } + + /// Repaint only the quads whose `order` falls inside `band`. + /// + /// The original client renders each 3D viewer where its widget sits in the + /// UI queue: panels below are flushed, the viewer draws, and later panels + /// paint over it. Splitting the UI stream and calling this per window band + /// reproduces that ordering, so several viewers stay live at once without + /// any of them punching through the windows stacked above. + pub fn render_composites_overlay_band<G: Gpu, W: RenderWorld>( + &mut self, + gpu: &mut G, + world: &mut W, + screen_w: u32, + screen_h: u32, + band: core::ops::RangeInclusive<i16>, + ) { + self.composite_pass(gpu, world, screen_w, screen_h, band); + } + + fn composite_pass<G: Gpu, W: RenderWorld>( + &mut self, + gpu: &mut G, + world: &mut W, + screen_w: u32, + screen_h: u32, + band: core::ops::RangeInclusive<i16>, + ) { + self.comp_quads.clear(); + { + let mut q = world.query1::<CompositeQuad>(); + while let Some((_, cq)) = q.next() { + if band.contains(&cq.order) { + self.comp_quads.push(*cq); + } + } + } + if self.comp_quads.is_empty() { + return; + } + self.comp_quads.sort_by_key(|q| q.order); + gpu.begin_pass( + PassTarget::Screen, + RectPx { + x: 0, + y: 0, + w: screen_w as i32, + h: screen_h as i32, + }, + ClearSpec::default(), + ); + gpu.set_pipeline( + self.composite_prog, + &PipelineState { + depth_test: false, + depth_write: false, + cull: Cull::None, + color_write: true, + blend: true, + additive: false, + }, + ); + for i in 0..self.comp_quads.len() { + let cq = self.comp_quads[i]; + self.quad.clear(); + push_quad_ndc(&mut self.quad, cq.rect); + gpu.update_buffer(self.dyn_buf, f32_bytes(&self.quad)); + if let Some(tex) = gpu.render_target_color(cq.source) { + gpu.bind_texture(0, tex); + } + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_tex", + value: UniformValue::Sampler(0), + }); + gpu.set_uniforms(&self.uniforms); + gpu.draw(self.dyn_buf, None, &QUAD_LAYOUT, 6); + } + gpu.end_pass(); + } + + fn text_pass<G: Gpu, W: RenderWorld>( + &mut self, + gpu: &mut G, + world: &mut W, + screen_w: u32, + screen_h: u32, + ) { + // Cell size in NDC: ~9px wide, ~18px tall. + let cell_w = 9.0 / screen_w as f32 * 2.0; + let cell_h = 18.0 / screen_h as f32 * 2.0; + let mut any = false; + self.overlays.clear(); + { + let mut q = world.query1::<TextOverlay>(); + while let Some((_, t)) = q.next() { + self.overlays.push(*t); + } + } + if self.overlays.is_empty() { + return; + } + for i in 0..self.overlays.len() { + let ov = self.overlays[i]; + self.quad.clear(); + let x_ndc = ov.pos.x * 2.0 - 1.0; + let y_ndc = 1.0 - ov.pos.y * 2.0; + let n = + text::push_text_quads(ov.as_str(), x_ndc, y_ndc, cell_w, cell_h, &mut self.quad); + if n == 0 { + continue; + } + if !any { + gpu.begin_pass( + PassTarget::Screen, + RectPx { + x: 0, + y: 0, + w: screen_w as i32, + h: screen_h as i32, + }, + ClearSpec::default(), + ); + gpu.set_pipeline( + self.text_prog, + &PipelineState { + depth_test: false, + depth_write: false, + cull: Cull::None, + color_write: true, + blend: false, + additive: false, + }, + ); + any = true; + } + gpu.update_buffer(self.dyn_buf, f32_bytes(&self.quad)); + let c = ov.rgba; + self.uniforms.clear(); + self.uniforms.push(Uniform { + name: "u_color", + value: UniformValue::Vec4([ + c[0] as f32 / 255.0, + c[1] as f32 / 255.0, + c[2] as f32 / 255.0, + c[3] as f32 / 255.0, + ]), + }); + gpu.set_uniforms(&self.uniforms); + gpu.draw(self.dyn_buf, None, &QUAD_LAYOUT, n * 6); + } + if any { + gpu.end_pass(); + } + } +} + +/// Mesh draw variant: shadow depth, forward RTT, deferred G-buffer, or scene transparency. +#[derive(Clone, Copy)] +enum DrawMode { + Depth, + Forward, + GBuffer, + Transparent, +} +static GBUFFER_FORMATS: [TextureFormat; 4] = [ + TextureFormat::Rgba8, + TextureFormat::Rgba8, + TextureFormat::Rgba8, + TextureFormat::Rgba8, +]; + +const DEFAULT_LIGHT_DIR: Vec3 = Vec3 { + x: -0.4, + y: -1.0, + z: -0.3, +}; + +/// Deferred target attachment format tables (`'static` for `MrtDesc::colors`). +static SCENE_HDR_FORMATS: [TextureFormat; 1] = [TextureFormat::Rgba16F]; +static SCENE_LDR_FORMATS: [TextureFormat; 1] = [TextureFormat::Rgba8]; + +/// Fullscreen NDC quad (pos2, uv2) for deferred light/tonemap passes. +const FS_QUAD: [f32; 24] = [ + -1.0, -1.0, 0.0, 0.0, 1.0, -1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, -1.0, -1.0, 0.0, 0.0, 1.0, 1.0, + 1.0, 1.0, -1.0, 1.0, 0.0, 1.0, +]; + +fn projection_matrix(p: Projection, aspect: f32) -> Mat4 { + match p { + Projection::Perspective { fovy, near, far } => Mat4::perspective(fovy, aspect, near, far), + Projection::Ortho { + half_height, + near, + far, + } => { + let hw = half_height * aspect; + Mat4::ortho(-hw, hw, -half_height, half_height, near, far) + } + } +} + +fn light_view_proj(dir: Vec3, center: Vec3, radius: f32, shadow_size: u32) -> [f32; 16] { + let d = dir.normalize(); + let distance = radius * 2.0; + let up_ref = if d.y.abs() > 0.99 { + Vec3 { + x: 0.0, + y: 0.0, + z: 1.0, + } + } else { + Vec3::Y + }; + // Texel-snap the center along the light's right/up axes to stabilize the map + // (kills shadow shimmer as the camera moves). + let right = d.cross(up_ref).normalize(); + let up = right.cross(d).normalize(); + let texel = 2.0 * radius / shadow_size as f32; + let cr = center.dot(right); + let cu = center.dot(up); + let sr = libm::roundf(cr / texel) * texel; + let su = libm::roundf(cu / texel) * texel; + let center = center.add(right.scale(sr - cr)).add(up.scale(su - cu)); + let eye = center.sub(d.scale(distance)); + let view = Mat4::look_at(eye, center, up); + let proj = Mat4::ortho( + -radius, + radius, + -radius, + radius, + 0.1, + distance + radius * 2.0, + ); + proj.mul(view).to_cols_array() +} + +fn viewport_px(rect: RectNorm, w: u32, h: u32) -> RectPx { + RectPx { + x: (rect.x * w as f32) as i32, + y: (rect.y * h as f32) as i32, + w: (rect.w * w as f32) as i32, + h: (rect.h * h as f32) as i32, + } +} + +fn push_quad_ndc(out: &mut Vec<f32>, rect: RectNorm) { + let x0 = rect.x * 2.0 - 1.0; + let y0 = rect.y * 2.0 - 1.0; + let x1 = (rect.x + rect.w) * 2.0 - 1.0; + let y1 = (rect.y + rect.h) * 2.0 - 1.0; + out.extend_from_slice(&[x0, y0, 0.0, 0.0]); + out.extend_from_slice(&[x1, y0, 1.0, 0.0]); + out.extend_from_slice(&[x1, y1, 1.0, 1.0]); + out.extend_from_slice(&[x0, y0, 0.0, 0.0]); + out.extend_from_slice(&[x1, y1, 1.0, 1.0]); + out.extend_from_slice(&[x0, y1, 0.0, 1.0]); +} + +fn f32_bytes(s: &[f32]) -> &[u8] { + // SAFETY: f32 has no padding/invalid bit patterns; reinterpreting as bytes + // for GPU upload is sound and the lifetime is tied to `s`. + unsafe { core::slice::from_raw_parts(s.as_ptr() as *const u8, core::mem::size_of_val(s)) } +} + +fn mat4_bytes(matrices: &[[f32; 16]]) -> &[u8] { + // SAFETY: arrays of f32 are contiguous and have no padding or invalid bit + // patterns; the returned view cannot outlive `matrices`. + unsafe { + core::slice::from_raw_parts( + matrices.as_ptr() as *const u8, + core::mem::size_of_val(matrices), + ) + } +} + +fn u32_bytes(s: &[u32]) -> &[u8] { + // SAFETY: as above for u32. + unsafe { core::slice::from_raw_parts(s.as_ptr() as *const u8, core::mem::size_of_val(s)) } +} + +/// Visibility rule exposed for tests and game code: does `mask` include `vp`? +pub fn visible_in(mask: u32, viewport_id: u8) -> bool { + (mask & (1u32 << viewport_id)) != 0 +} diff --git a/client-rust/source/engine-render/src/text.rs b/client-rust/source/engine-render/src/text.rs new file mode 100644 index 00000000..287874d6 --- /dev/null +++ b/client-rust/source/engine-render/src/text.rs @@ -0,0 +1,85 @@ +//! Screen-space text layout using the baked 5×7 bitmap font. +//! +//! Each glyph is drawn as one small quad per lit pixel (`font::glyph`), reusing +//! the solid-quad text shader (`gpu::QUAD_LAYOUT`: `pos:2, uv:2`, NDC). This +//! yields readable HUD text with no atlas texture and no per-frame heap +//! allocation (quads accumulate into a caller-owned, reused buffer). + +use crate::font::{glyph, GLYPH_H, GLYPH_W}; +use alloc::vec::Vec; + +/// Append pixel quads for `text` starting at NDC `(x, y)` (top-left of the first +/// glyph cell), where `cell_w`/`cell_h` are the full advance-cell size in NDC +/// (glyph = 5×7 pixels plus a 1-pixel gap → 6×8 cell). Whitespace and unmapped +/// characters advance without emitting quads. Returns the number of quads +/// (lit pixels) appended. +pub fn push_text_quads( + text: &str, + x: f32, + y: f32, + cell_w: f32, + cell_h: f32, + out: &mut Vec<f32>, +) -> u32 { + // Sub-cell pixel size: 6 columns (5 glyph + 1 gap), 8 rows (7 glyph + 1 gap). + let px = cell_w / (GLYPH_W as f32 + 1.0); + let py = cell_h / (GLYPH_H as f32 + 1.0); + let mut cursor = x; + let mut count = 0u32; + for ch in text.chars() { + if let Some(rows) = glyph(ch) { + for (r, row) in rows.iter().enumerate() { + let bits = *row; + for c in 0..GLYPH_W { + // bit (GLYPH_W-1 - c) is column c (bit 4 = leftmost). + if bits & (1 << (GLYPH_W - 1 - c)) != 0 { + let x0 = cursor + c as f32 * px; + let x1 = x0 + px; + let y1 = y - r as f32 * py; + let y0 = y1 - py; + out.extend_from_slice(&[x0, y0, 0.0, 0.0]); + out.extend_from_slice(&[x1, y0, 1.0, 0.0]); + out.extend_from_slice(&[x1, y1, 1.0, 1.0]); + out.extend_from_slice(&[x0, y0, 0.0, 0.0]); + out.extend_from_slice(&[x1, y1, 1.0, 1.0]); + out.extend_from_slice(&[x0, y1, 0.0, 1.0]); + count += 1; + } + } + } + } + cursor += cell_w; + } + count +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + + #[test] + fn emits_pixel_quads_and_skips_spaces() { + let mut buf = Vec::new(); + // 'A' has 15 lit pixels in the 5×7 table; space emits none. + let lit_a: u32 = crate::font::glyph('A') + .unwrap() + .iter() + .map(|r| r.count_ones()) + .sum(); + let n = push_text_quads("A A", 0.0, 1.0, 0.06, 0.08, &mut buf); + assert_eq!(n, lit_a * 2, "two 'A's, space contributes nothing"); + assert_eq!(buf.len() as u32, n * 6 * 4, "6 verts * 4 floats per quad"); + } + + #[test] + fn advances_by_cell_per_char() { + // First lit pixel of "1" (top of stem at column 2) should sit within the + // first cell; the second char starts a full cell to the right. + let mut buf = Vec::new(); + let n = push_text_quads("11", 0.0, 1.0, 0.06, 0.08, &mut buf); + assert!(n > 0); + // x of the last vertex group must be >= one cell width in. + let last_x = buf[buf.len() - 4 * 6]; + assert!(last_x >= 0.06, "second glyph advanced by a cell"); + } +} diff --git a/client-rust/source/engine-render/src/ui.rs b/client-rust/source/engine-render/src/ui.rs new file mode 100644 index 00000000..1d4e2f83 --- /dev/null +++ b/client-rust/source/engine-render/src/ui.rs @@ -0,0 +1,929 @@ +//! Immediate-mode UI draw-list builder. +//! +//! Widgets accumulate into a single reused vertex buffer (`UI_LAYOUT`: +//! `pos:2, uv:2, color:4`, NDC) so a whole frame's chrome — panels, borders, +//! text, and icons — draws in one blended pass over the 3D scene. Solid quads +//! carry `uv.x = -1` (the shader ignores the atlas); icon quads carry atlas UVs. +//! Coordinates are supplied in top-left-origin screen pixels and converted to +//! NDC here. The buffer is caller-owned and cleared per frame → no per-frame +//! heap growth once warmed. + +use crate::font::{glyph, text_advance, RasterFont, GLYPH_H, GLYPH_W}; +use alloc::vec::Vec; + +/// Layout of the baked icon atlas (from `icons.json`). +#[derive(Clone, Copy, Debug)] +pub struct AtlasMeta { + pub cell: u32, + pub cols: u32, + pub width: u32, + pub height: u32, +} + +impl AtlasMeta { + /// Cell → UV rect `(u0, v0, u1, v1)`. + pub fn uv(&self, col: u32, row: u32) -> (f32, f32, f32, f32) { + let cw = self.cell as f32 / self.width as f32; + let ch = self.cell as f32 / self.height as f32; + let u0 = col as f32 * cw; + let v0 = row as f32 * ch; + (u0, v0, u0 + cw, v0 + ch) + } +} + +/// Fixed-cost immediate-mode skin for a nine-slice rectangle. +/// The center is painted first, then corners, then edges; each region stretches +/// independently and no geometry is allocated after warmup. +#[derive(Clone, Copy, Debug)] +pub struct RectangleStyle { + pub north: [u8; 4], + pub south: [u8; 4], + pub east: [u8; 4], + pub west: [u8; 4], + pub center: [u8; 4], + pub north_east: [u8; 4], + pub north_west: [u8; 4], + pub south_east: [u8; 4], + pub south_west: [u8; 4], + pub west_width: f32, + pub east_width: f32, + pub north_height: f32, + pub south_height: f32, +} + +pub struct UiBuilder { + pub buf: Vec<f32>, + pub quads: u32, + sw: f32, + sh: f32, + atlas: AtlasMeta, + font: Option<RasterFont>, + // Input for this frame (screen px + button edges). + mx: f32, + my: f32, + mdown: bool, + mpressed: bool, + mreleased: bool, + prev_down: bool, + input_enabled: bool, +} + +impl UiBuilder { + pub fn new(atlas: AtlasMeta) -> Self { + Self { + buf: Vec::with_capacity(64 * 1024), + quads: 0, + sw: 1.0, + sh: 1.0, + atlas, + font: None, + mx: 0.0, + my: 0.0, + mdown: false, + mpressed: false, + mreleased: false, + prev_down: false, + input_enabled: true, + } + } + + /// Attach a startup-rasterized font packed into the UI icon atlas. + pub fn new_with_font(atlas: AtlasMeta, font: RasterFont) -> Self { + let mut ui = Self::new(atlas); + ui.font = Some(font); + ui + } + + /// Feed this frame's pointer state (screen px, left-button held). Call + /// before `begin`; edges (`pressed`/`released`) are derived from the + /// previous frame's held state. + pub fn set_input(&mut self, mouse_x: f32, mouse_y: f32, mouse_down: bool) { + self.mx = mouse_x; + self.my = mouse_y; + self.mpressed = mouse_down && !self.prev_down; + self.mreleased = !mouse_down && self.prev_down; + self.mdown = mouse_down; + self.prev_down = mouse_down; + } + + pub fn mouse(&self) -> (f32, f32) { + (self.mx, self.my) + } + + /// Temporarily suppress widget interaction while still drawing the same + /// immediate-mode layer. Hosts use this when a modal overlay owns input. + pub fn set_input_enabled(&mut self, enabled: bool) { + self.input_enabled = enabled; + } + + /// Reset for a new frame at the given framebuffer size (pixels). + pub fn begin(&mut self, screen_w: u32, screen_h: u32) { + self.buf.clear(); + self.quads = 0; + self.sw = screen_w.max(1) as f32; + self.sh = screen_h.max(1) as f32; + } + + #[inline] + fn ndc_x(&self, px: f32) -> f32 { + px / self.sw * 2.0 - 1.0 + } + #[inline] + fn ndc_y(&self, py: f32) -> f32 { + 1.0 - py / self.sh * 2.0 + } + + /// Emit one quad. `u0<0` ⇒ solid color (atlas ignored). + fn push_quad(&mut self, x: f32, y: f32, w: f32, h: f32, uv: (f32, f32, f32, f32), c: [u8; 4]) { + let x0 = self.ndc_x(x); + let x1 = self.ndc_x(x + w); + let y0 = self.ndc_y(y + h); // bottom (larger py → smaller ndc) + let y1 = self.ndc_y(y); // top + let (u0, v0, u1, v1) = uv; + let col = [ + c[0] as f32 / 255.0, + c[1] as f32 / 255.0, + c[2] as f32 / 255.0, + c[3] as f32 / 255.0, + ]; + // v flips: uv.v0 is the top of the cell → maps to y1 (screen top). + let mut push = |px: f32, py: f32, u: f32, v: f32| { + self.buf + .extend_from_slice(&[px, py, u, v, col[0], col[1], col[2], col[3]]); + }; + push(x0, y0, u0, v1); + push(x1, y0, u1, v1); + push(x1, y1, u1, v0); + push(x0, y0, u0, v1); + push(x1, y1, u1, v0); + push(x0, y1, u0, v0); + self.quads += 1; + } + + fn push_solid_quad(&mut self, points: [(f32, f32); 4], c: [u8; 4]) { + let col = [ + c[0] as f32 / 255.0, + c[1] as f32 / 255.0, + c[2] as f32 / 255.0, + c[3] as f32 / 255.0, + ]; + for index in [0usize, 1, 2, 0, 2, 3] { + let (x, y) = points[index]; + self.buf.extend_from_slice(&[ + self.ndc_x(x), + self.ndc_y(y), + -1.0, + -1.0, + col[0], + col[1], + col[2], + col[3], + ]); + } + self.quads += 1; + } + + /// Solid triangle. The buffer is a quad list, so the third vertex is + /// repeated: the degenerate triangle rasterizes to nothing and keeps + /// `quads` an exact divisor of the vertex count. + pub fn tri(&mut self, a: (f32, f32), b: (f32, f32), c: (f32, f32), rgba: [u8; 4]) { + self.push_solid_quad([a, b, c, c], rgba); + } + + /// Solid antialiased-by-MSAA line segment with arbitrary orientation. + pub fn line(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, thickness: f32, rgba: [u8; 4]) { + let dx = x1 - x0; + let dy = y1 - y0; + let length = libm::sqrtf(dx * dx + dy * dy); + if length <= f32::EPSILON || thickness <= 0.0 { + return; + } + let half = thickness * 0.5; + let nx = -dy / length * half; + let ny = dx / length * half; + self.push_solid_quad( + [ + (x0 + nx, y0 + ny), + (x1 + nx, y1 + ny), + (x1 - nx, y1 - ny), + (x0 - nx, y0 - ny), + ], + rgba, + ); + } + + /// Fixed-segment circular stroke. Intended for instruments, not arbitrary + /// vector art; it emits exactly `segments` quads without heap growth. + pub fn ring( + &mut self, + cx: f32, + cy: f32, + radius: f32, + segments: u32, + thickness: f32, + rgba: [u8; 4], + ) { + if segments < 3 || radius <= 0.0 { + return; + } + let mut x0 = cx + radius; + let mut y0 = cy; + for index in 1..=segments { + let angle = index as f32 / segments as f32 * core::f32::consts::TAU; + let x1 = cx + libm::cosf(angle) * radius; + let y1 = cy + libm::sinf(angle) * radius; + self.line(x0, y0, x1, y1, thickness, rgba); + x0 = x1; + y0 = y1; + } + } + + /// Filled rectangle (screen px, top-left origin). + pub fn rect(&mut self, x: f32, y: f32, w: f32, h: f32, rgba: [u8; 4]) { + self.push_quad(x, y, w, h, (-1.0, -1.0, -1.0, -1.0), rgba); + } + + /// 1px..Npx border stroke around a rect (four edges, drawn inside the rect). + pub fn border(&mut self, x: f32, y: f32, w: f32, h: f32, t: f32, rgba: [u8; 4]) { + self.rect(x, y, w, t, rgba); // top + self.rect(x, y + h - t, w, t, rgba); // bottom + self.rect(x, y, t, h, rgba); // left + self.rect(x + w - t, y, t, h, rgba); // right + } + + /// A filled panel. Callers that need a semantic rail or focus indicator + /// draw that one accent explicitly instead of framing every surface. + pub fn panel(&mut self, x: f32, y: f32, w: f32, h: f32, fill: [u8; 4], _edge: [u8; 4]) { + self.rect(x, y, w, h, fill); + } + + /// Render the nine independently stretched regions of a rectangle style. + /// The center is painted first, followed by corners and edges. + pub fn nine_slice(&mut self, x: f32, y: f32, w: f32, h: f32, style: &RectangleStyle) { + let west = style.west_width.max(0.0).min(w * 0.5); + let east = style.east_width.max(0.0).min(w * 0.5); + let north = style.north_height.max(0.0).min(h * 0.5); + let south = style.south_height.max(0.0).min(h * 0.5); + let center_w = (w - west - east).max(0.0); + let center_h = (h - north - south).max(0.0); + + if center_w > 0.0 && center_h > 0.0 { + self.rect(x + west, y + north, center_w, center_h, style.center); + } + if east > 0.0 && north > 0.0 { + self.rect(x + w - east, y, east, north, style.north_east); + } + if west > 0.0 && north > 0.0 { + self.rect(x, y, west, north, style.north_west); + } + if east > 0.0 && south > 0.0 { + self.rect(x + w - east, y + h - south, east, south, style.south_east); + } + if west > 0.0 && south > 0.0 { + self.rect(x, y + h - south, west, south, style.south_west); + } + if west > 0.0 && center_h > 0.0 { + self.rect(x, y + north, west, center_h, style.west); + } + if east > 0.0 && center_h > 0.0 { + self.rect(x + w - east, y + north, east, center_h, style.east); + } + if north > 0.0 && center_w > 0.0 { + self.rect(x + west, y, center_w, north, style.north); + } + if south > 0.0 && center_w > 0.0 { + self.rect(x + west, y + h - south, center_w, south, style.south); + } + } + + /// Draw `text` at `(x, y)` (top-left) with a glyph pixel size of `px` + /// (each 5×7 dot is `px`×`px`). Returns the advanced x cursor. + pub fn text(&mut self, text: &str, x: f32, y: f32, px: f32, rgba: [u8; 4]) -> f32 { + if self.font.is_some() { + return self.raster_text(text, x, y, px, rgba); + } + let mut cursor = x; + for ch in text.chars() { + if let Some(rows) = glyph(ch) { + for (r, row) in rows.iter().enumerate() { + let bits = *row; + for col in 0..GLYPH_W { + if bits & (1 << (GLYPH_W - 1 - col)) != 0 { + self.rect(cursor + col as f32 * px, y + r as f32 * px, px, px, rgba); + } + } + } + } + cursor += text_advance(ch) * px; + } + cursor + } + + fn raster_text(&mut self, text: &str, x: f32, y: f32, px: f32, rgba: [u8; 4]) -> f32 { + let (ascent, line_height) = { + let font = self.font.as_ref().expect("raster font checked"); + (font.ascent, font.line_height) + }; + let target_height = 8.75 * px; + let scale = target_height / line_height.max(1.0); + let mut cursor = x; + for ch in text.chars() { + if let Some(glyph) = self.font.as_ref().and_then(|font| font.glyph(ch)) { + if glyph.width > 0.0 && glyph.height > 0.0 { + let gx = cursor + glyph.xmin * scale; + let gy = y + (ascent - (glyph.ymin + glyph.height)) * scale; + self.push_quad( + gx, + gy, + glyph.width * scale, + glyph.height * scale, + glyph.uv, + rgba, + ); + } + cursor += glyph.advance * scale; + } else { + cursor += text_advance(ch) * px; + } + } + cursor + } + + /// Pixel width a string will occupy at glyph size `px`. + pub fn text_width(text: &str, px: f32) -> f32 { + text.chars().map(text_advance).sum::<f32>() * px + } + + /// Width produced by this builder's active font path. + /// + /// Raster text uses proportional glyph advances; callers positioning text + /// must use this instead of the fixed 5×7 fallback estimate. + pub fn measure_text(&self, text: &str, px: f32) -> f32 { + let Some(font) = self.font.as_ref() else { + return Self::text_width(text, px); + }; + let scale = 8.75 * px / font.line_height.max(1.0); + text.chars() + .map(|ch| { + font.glyph(ch) + .map_or_else(|| text_advance(ch) * px, |glyph| glyph.advance * scale) + }) + .sum() + } + + /// An icon from the baked atlas, scaled into `w`×`h` at `(x, y)`, tinted. + #[allow(clippy::too_many_arguments)] + pub fn icon(&mut self, col: u32, row: u32, x: f32, y: f32, w: f32, h: f32, rgba: [u8; 4]) { + let uv = self.atlas.uv(col, row); + self.push_quad(x, y, w, h, uv, rgba); + } + + /// Full-color atlas region, scaled into `w`×`h` at `(x, y)`. + /// + /// The encoded U offset selects the fragment shader's RGBA sampling path; + /// this keeps authored art in the same pass as coverage-tinted icons. + #[allow(clippy::too_many_arguments)] + pub fn image_uv( + &mut self, + x: f32, + y: f32, + w: f32, + h: f32, + uv: (f32, f32, f32, f32), + rgba: [u8; 4], + ) { + let encoded = (uv.0 + 2.0, uv.1, uv.2 + 2.0, uv.3); + self.push_quad(x, y, w, h, encoded, rgba); + } + + /// Coverage-only atlas region, tinted by `rgba`. + #[allow(clippy::too_many_arguments)] + pub fn mask_uv( + &mut self, + x: f32, + y: f32, + w: f32, + h: f32, + uv: (f32, f32, f32, f32), + rgba: [u8; 4], + ) { + self.push_quad(x, y, w, h, uv, rgba); + } + + /// Whether `(mx, my)` (screen px) lies inside a rect — hit-testing helper. + pub fn hit(x: f32, y: f32, w: f32, h: f32, mx: f32, my: f32) -> bool { + mx >= x && mx < x + w && my >= y && my < y + h + } + + /// Pointer hover/press/click state for a rect this frame. + pub fn interact(&self, x: f32, y: f32, w: f32, h: f32) -> Response { + if !self.input_enabled { + return Response::default(); + } + let over = Self::hit(x, y, w, h, self.mx, self.my); + Response { + hovered: over, + pressed: over && self.mpressed, + released: over && self.mreleased, + clicked: over && self.mreleased, + held: over && self.mdown, + } + } + + /// A labeled button. Draws a hover/press-tinted body + centered text and + /// returns whether it was clicked (released inside) this frame. + pub fn button( + &mut self, + x: f32, + y: f32, + w: f32, + h: f32, + label: &str, + style: ButtonStyle, + ) -> bool { + let r = self.interact(x, y, w, h); + let fill = if r.held { + style.active + } else if r.hovered { + style.hover + } else { + style.fill + }; + self.rect(x, y, w, h, fill); + // Size the 5×7 label so it fits the button: glyph height = 7·px must fit + // ~half the height, and the whole label width = n·6·px must fit ~85% of + // the width — take the smaller so long labels ("UNEQUIP") never overflow. + let n = label.chars().count().max(1) as f32; + let px_h = (h * 0.5) / GLYPH_H as f32; + let px_w = (w * 0.85) / (n * (GLYPH_W as f32 + 1.0)); + let px = px_h.min(px_w).max(1.0); + let tw = self.measure_text(label, px); + let tx = x + (w - tw) * 0.5; + let ty = y + (h - GLYPH_H as f32 * px) * 0.5; + self.text(label, tx, ty, px, style.text); + r.clicked + } + + /// An icon button (atlas glyph centered in a hover-tinted slot). Returns + /// whether it was clicked this frame. + pub fn icon_button( + &mut self, + col: u32, + row: u32, + x: f32, + y: f32, + size: f32, + style: ButtonStyle, + ) -> bool { + let r = self.interact(x, y, size, size); + let fill = if r.held { + style.active + } else if r.hovered { + style.hover + } else { + style.fill + }; + self.rect(x, y, size, size, fill); + let pad = size * 0.18; + self.icon( + col, + row, + x + pad, + y + pad, + size - 2.0 * pad, + size - 2.0 * pad, + style.text, + ); + r.clicked + } + + /// A compact checkbox with a text label. Returns true when the value + /// changed this frame. + pub fn checkbox( + &mut self, + x: f32, + y: f32, + size: f32, + label: &str, + value: &mut bool, + style: ButtonStyle, + ) -> bool { + let label_w = self.measure_text(label, 1.5); + let response = self.interact(x, y, size + 8.0 + label_w, size); + let changed = response.clicked; + if changed { + *value = !*value; + } + self.rect(x, y, size, size, style.fill); + self.border( + x, + y, + size, + size, + 1.0, + if response.hovered { + style.active + } else { + style.edge + }, + ); + if *value { + let pad = (size * 0.24).max(2.0); + self.rect( + x + pad, + y + pad, + size - pad * 2.0, + size - pad * 2.0, + style.active, + ); + } + let ty = y + (size - GLYPH_H as f32 * 1.5) * 0.5; + self.text(label, x + size + 8.0, ty, 1.5, style.text); + changed + } + + /// Horizontal floating-point slider. Clicking or dragging on the track + /// updates `value`; the caller owns labels and numeric formatting. + #[allow(clippy::too_many_arguments)] + pub fn slider( + &mut self, + x: f32, + y: f32, + w: f32, + h: f32, + value: &mut f32, + min: f32, + max: f32, + style: ButtonStyle, + ) -> bool { + let response = self.interact(x, y, w, h); + let mut changed = false; + if (response.pressed || response.held) && max > min { + let next = (min + ((self.mx - x) / w).clamp(0.0, 1.0) * (max - min)).clamp(min, max); + changed = (next - *value).abs() > f32::EPSILON; + *value = next; + } + let t = if max > min { + ((*value - min) / (max - min)).clamp(0.0, 1.0) + } else { + 0.0 + }; + let track_y = y + h * 0.5 - 2.0; + self.rect(x, track_y, w, 4.0, style.fill); + self.rect(x, track_y, w * t, 4.0, style.active); + let thumb = 10.0; + let thumb_x = x + w * t - thumb * 0.5; + self.rect( + thumb_x, + y + (h - thumb) * 0.5, + thumb, + thumb, + if response.held { + style.text + } else { + style.active + }, + ); + changed + } +} + +/// Result of pointer interaction with a rect for one frame. +#[derive(Clone, Copy, Debug, Default)] +pub struct Response { + pub hovered: bool, + pub pressed: bool, + pub released: bool, + pub clicked: bool, + pub held: bool, +} + +/// Colors for `button`/`icon_button` across idle/hover/active states. +#[derive(Clone, Copy, Debug)] +pub struct ButtonStyle { + pub fill: [u8; 4], + pub hover: [u8; 4], + pub active: [u8; 4], + pub edge: [u8; 4], + pub text: [u8; 4], +} + +impl Default for ButtonStyle { + fn default() -> Self { + Self { + fill: [30, 40, 54, 210], + hover: [48, 62, 82, 230], + active: [70, 92, 120, 240], + edge: [80, 100, 122, 255], + text: [210, 222, 236, 255], + } + } +} + +/// A single-line text edit buffer. The host feeds characters (from +/// `poll_text_input`) and control keys; the widget owns the string + caret and +/// only draws when handed to `UiBuilder::text_field`. +#[derive(Clone, Debug, Default)] +pub struct TextField { + pub text: alloc::string::String, + pub focused: bool, + pub caret: usize, + pub max_len: usize, +} + +impl TextField { + pub fn new(max_len: usize) -> Self { + Self { + text: alloc::string::String::new(), + focused: false, + caret: 0, + max_len, + } + } + + /// Insert a printable character at the caret (bounded by `max_len`). + pub fn insert(&mut self, c: char) { + if c.is_control() || (self.max_len != 0 && self.text.chars().count() >= self.max_len) { + return; + } + let byte = self.byte_at(self.caret); + self.text.insert(byte, c); + self.caret += 1; + } + + /// Delete the character before the caret. + pub fn backspace(&mut self) { + if self.caret == 0 { + return; + } + let end = self.byte_at(self.caret); + let start = self.byte_at(self.caret - 1); + self.text.replace_range(start..end, ""); + self.caret -= 1; + } + + pub fn clear(&mut self) { + self.text.clear(); + self.caret = 0; + } + + pub fn move_left(&mut self) { + self.caret = self.caret.saturating_sub(1); + } + pub fn move_right(&mut self) { + let n = self.text.chars().count(); + if self.caret < n { + self.caret += 1; + } + } + + /// Byte offset of the `n`-th char (for insert/delete on UTF-8 text). + fn byte_at(&self, n: usize) -> usize { + self.text + .char_indices() + .nth(n) + .map(|(b, _)| b) + .unwrap_or(self.text.len()) + } +} + +impl UiBuilder { + /// Single-line editor. `style` supplies the theme: `fill` is the well, + /// `edge` the resting border, `active` the focused border and caret, and + /// `text` the ink. The engine has no palette of its own, and a hardcoded + /// field was the one widget no theme could reach. + #[allow(clippy::too_many_arguments)] + pub fn text_field( + &mut self, + field: &mut TextField, + x: f32, + y: f32, + w: f32, + h: f32, + px: f32, + show_caret: bool, + style: ButtonStyle, + ) -> Response { + let r = self.interact(x, y, w, h); + if r.clicked { + field.focused = true; + } else if self.mpressed && !r.hovered { + field.focused = false; + } + let edge = if field.focused { + style.active + } else { + style.edge + }; + self.rect(x, y, w, h, style.fill); + self.border(x, y, w, h, 1.0, edge); + let ty = y + (h - GLYPH_H as f32 * px) * 0.5; + let end = self.text(&field.text, x + 6.0, ty, px, style.text); + if field.focused && show_caret { + self.rect(end + 1.0, ty, px, GLYPH_H as f32 * px, style.active); + } + r + } +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + + const ATLAS: AtlasMeta = AtlasMeta { + cell: 32, + cols: 8, + width: 256, + height: 160, + }; + + #[test] + fn rect_emits_one_quad_solid() { + let mut ui = UiBuilder::new(ATLAS); + ui.begin(1000, 500); + ui.rect(100.0, 50.0, 200.0, 40.0, [10, 20, 30, 255]); + assert_eq!(ui.quads, 1); + assert_eq!(ui.buf.len(), 6 * 8); + // uv.x sentinel < 0 for solid quads. + assert!(ui.buf[2] < 0.0); + } + + #[test] + fn nine_slice_emits_center_corners_then_edges() { + let mut ui = UiBuilder::new(ATLAS); + ui.begin(100, 50); + let style = RectangleStyle { + north: [1, 1, 1, 255], + south: [2, 2, 2, 255], + east: [3, 3, 3, 255], + west: [4, 4, 4, 255], + center: [10, 20, 30, 40], + north_east: [6, 6, 6, 255], + north_west: [7, 7, 7, 255], + south_east: [8, 8, 8, 255], + south_west: [9, 9, 9, 255], + west_width: 5.0, + east_width: 5.0, + north_height: 4.0, + south_height: 4.0, + }; + ui.nine_slice(0.0, 0.0, 100.0, 50.0, &style); + assert_eq!(ui.quads, 9); + assert!((ui.buf[4] - 10.0 / 255.0).abs() < 1e-6); + assert!((ui.buf[7] - 40.0 / 255.0).abs() < 1e-6); + } + + #[test] + fn ring_emits_one_oriented_quad_per_segment() { + let mut ui = UiBuilder::new(ATLAS); + ui.begin(100, 100); + ui.ring(50.0, 50.0, 40.0, 32, 1.0, [255; 4]); + assert_eq!(ui.quads, 32); + assert_eq!(ui.buf.len(), 32 * 6 * 8); + } + + #[test] + fn ndc_maps_corners() { + let mut ui = UiBuilder::new(ATLAS); + ui.begin(1000, 500); + ui.rect(0.0, 0.0, 1000.0, 500.0, [0; 4]); + // First vertex is bottom-left of the full screen → NDC (-1, -1). + assert!((ui.buf[0] + 1.0).abs() < 1e-5); + assert!((ui.buf[1] + 1.0).abs() < 1e-5); + } + + #[test] + fn icon_uses_atlas_uv_nonnegative() { + let mut ui = UiBuilder::new(ATLAS); + ui.begin(800, 600); + ui.icon(1, 0, 0.0, 0.0, 32.0, 32.0, [255; 4]); + // Icon quad uv.x is >= 0 (first cell col 1 → 32/256 = 0.125). + assert!(ui.buf[2] >= 0.0); + assert!((ui.buf[2] - 0.125).abs() < 1e-5); + } + + #[test] + fn authored_image_encodes_the_rgba_sampling_path() { + let mut ui = UiBuilder::new(ATLAS); + ui.begin(800, 600); + ui.image_uv(10.0, 20.0, 200.0, 120.0, (0.25, 0.5, 0.75, 1.0), [255; 4]); + assert_eq!(ui.quads, 1); + assert!((ui.buf[2] - 2.25).abs() < 1e-5); + assert!((ui.buf[3] - 1.0).abs() < 1e-5); + } + + #[test] + fn authored_mask_keeps_the_coverage_sampling_path() { + let mut ui = UiBuilder::new(ATLAS); + ui.begin(800, 600); + ui.mask_uv(10.0, 20.0, 20.0, 20.0, (0.25, 0.5, 0.75, 1.0), [255; 4]); + assert!((ui.buf[2] - 0.25).abs() < 1e-5); + } + + #[test] + fn text_advances_and_emits_pixels() { + let mut ui = UiBuilder::new(ATLAS); + ui.begin(1920, 1080); + let end = ui.text("HI", 0.0, 0.0, 2.0, [255; 4]); + assert!(ui.quads > 0); + assert!((end - UiBuilder::text_width("HI", 2.0)).abs() < 1e-4); + } + + #[test] + fn hit_test() { + assert!(UiBuilder::hit(10.0, 10.0, 20.0, 20.0, 15.0, 15.0)); + assert!(!UiBuilder::hit(10.0, 10.0, 20.0, 20.0, 31.0, 15.0)); + } + + #[test] + fn button_click_edge() { + let mut ui = UiBuilder::new(ATLAS); + // press inside then release inside → clicked exactly on release frame. + ui.set_input(50.0, 50.0, true); // press + ui.begin(800, 600); + assert!(!ui.button(40.0, 40.0, 40.0, 30.0, "OK", ButtonStyle::default())); + ui.set_input(50.0, 50.0, false); // release inside + ui.begin(800, 600); + assert!(ui.button(40.0, 40.0, 40.0, 30.0, "OK", ButtonStyle::default())); + // release outside → no click. + ui.set_input(500.0, 500.0, true); + ui.begin(800, 600); + ui.button(40.0, 40.0, 40.0, 30.0, "OK", ButtonStyle::default()); + ui.set_input(500.0, 500.0, false); + ui.begin(800, 600); + assert!(!ui.button(40.0, 40.0, 40.0, 30.0, "OK", ButtonStyle::default())); + } + + #[test] + fn textfield_edit() { + let mut f = TextField::new(8); + f.insert('H'); + f.insert('i'); + assert_eq!(f.text, "Hi"); + assert_eq!(f.caret, 2); + f.backspace(); + assert_eq!(f.text, "H"); + // control chars ignored. + f.insert('\n'); + assert_eq!(f.text, "H"); + // max_len bound. + for _ in 0..20 { + f.insert('x'); + } + assert_eq!(f.text.chars().count(), 8); + } + + #[test] + fn slider_tracks_pointer_and_clamps() { + let mut ui = UiBuilder::new(ATLAS); + let mut value = 0.0; + ui.set_input(75.0, 15.0, true); + ui.begin(200, 100); + assert!(ui.slider( + 0.0, + 0.0, + 100.0, + 30.0, + &mut value, + -1.0, + 1.0, + ButtonStyle::default() + )); + assert!((value - 0.5).abs() < 1.0e-6); + ui.set_input(150.0, 15.0, true); + ui.begin(200, 100); + assert!(!ui.slider( + 0.0, + 0.0, + 100.0, + 30.0, + &mut value, + -1.0, + 1.0, + ButtonStyle::default() + )); + assert!((value - 0.5).abs() < 1.0e-6); + } + + #[test] + fn checkbox_changes_on_release_and_modal_capture_suppresses_it() { + let mut ui = UiBuilder::new(ATLAS); + let mut value = false; + ui.set_input(8.0, 8.0, true); + ui.begin(200, 100); + assert!(!ui.checkbox(0.0, 0.0, 20.0, "AA", &mut value, ButtonStyle::default())); + ui.set_input(8.0, 8.0, false); + ui.begin(200, 100); + assert!(ui.checkbox(0.0, 0.0, 20.0, "AA", &mut value, ButtonStyle::default())); + assert!(value); + + ui.set_input_enabled(false); + ui.set_input(8.0, 8.0, true); + ui.begin(200, 100); + ui.checkbox(0.0, 0.0, 20.0, "AA", &mut value, ButtonStyle::default()); + ui.set_input(8.0, 8.0, false); + ui.begin(200, 100); + assert!(!ui.checkbox(0.0, 0.0, 20.0, "AA", &mut value, ButtonStyle::default())); + assert!(value); + } +} diff --git a/client-rust/source/engine-render/src/weather.rs b/client-rust/source/engine-render/src/weather.rs new file mode 100644 index 00000000..9c982919 --- /dev/null +++ b/client-rust/source/engine-render/src/weather.rs @@ -0,0 +1,311 @@ +//! Presentation weather system (rain / dust / storm) that drives the particle pool. + +use crate::fx::ParticlePool; +use libm::{cosf, sinf}; + +/// Deterministic xorshift RNG for weather particle emission. +#[derive(Clone, Copy, Debug)] +pub struct Rng(u32); + +impl Rng { + pub fn new(seed: u32) -> Self { + Rng(seed.max(1)) + } + + #[inline] + pub fn next_u32(&mut self) -> u32 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + self.0 = x; + x + } + + /// Uniform in [0.0, 1.0). + #[inline] + pub fn unit(&mut self) -> f32 { + (self.next_u32() >> 8) as f32 / (1u32 << 24) as f32 + } + + /// Uniform in [-1.0, 1.0). + #[inline] + pub fn jit(&mut self) -> f32 { + self.unit() * 2.0 - 1.0 + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum WeatherKind { + Clear, + Rain, + DustStorm, +} + +pub struct Weather { + kind: WeatherKind, + intensity: f32, + current: f32, + wind: [f32; 2], + time: f32, + rng: Rng, +} + +impl Weather { + pub fn new(seed: u32) -> Self { + let mut w = Self { + kind: WeatherKind::Clear, + intensity: 0.0, + current: 0.0, + wind: [0.0, 0.0], + time: 0.0, + rng: Rng::new(seed), + }; + w.update_wind(); + w + } + + pub fn set(&mut self, kind: WeatherKind, intensity: f32) { + self.kind = kind; + self.intensity = intensity.clamp(0.0, 1.0); + } + + pub fn update(&mut self, dt: f32) { + if dt <= 0.0 { + return; + } + + // Easing current toward intensity at fixed rate 1.35 + let diff = self.intensity - self.current; + if diff.abs() > 1e-5 { + let sign = if diff > 0.0 { 1.0 } else { -1.0 }; + let step = sign * 1.35 * dt; + if diff.abs() <= step.abs() { + self.current = self.intensity; + } else { + self.current += step; + } + } else { + self.current = self.intensity; + } + + // Advance time + self.time += dt; + self.update_wind(); + } + + fn update_wind(&mut self) { + let t = self.time; + let pi = core::f32::consts::PI; + let wander = sinf((t * pi * 2.0) / 210.0 + 0.8 * sinf(t * 0.011)); + let dir_deg = 115.0 + wander * 40.0; + let dir_rad = dir_deg * pi / 180.0; + let dir_x = cosf(dir_rad); + let dir_z = sinf(dir_rad); + let gust_phase = (t * pi * 2.0) / 7.5; + let gust01 = + (0.5 + 0.55 * sinf(gust_phase) + 0.25 * sinf(gust_phase * 2.7 + 1.3)).clamp(0.0, 1.0); + let strength01 = (0.3 + gust01 * 0.45).clamp(0.0, 1.0); + self.wind = [dir_x * strength01, dir_z * strength01]; + } + + pub fn emit_into(&mut self, pool: &mut ParticlePool, listener: [f32; 3], area: f32) { + if self.current <= 0.0 { + return; + } + + match self.kind { + WeatherKind::Clear => {} + WeatherKind::Rain => { + const RAIN_BASE_COUNT: f32 = 20.0; + let count = (self.current * RAIN_BASE_COUNT) as usize; + for _ in 0..count { + // Spawn rain streaks (fast downward, into normal layer) + let p = [ + listener[0] + self.rng.jit() * area, + listener[1] + 10.0 + self.rng.unit() * 5.0, + listener[2] + self.rng.jit() * area, + ]; + let v = [ + self.wind[0] * 2.0, + -12.0 - self.rng.unit() * 4.0, + self.wind[1] * 2.0, + ]; + let life = 0.5 + self.rng.unit() * 0.3; + let sz = 0.02 + self.rng.unit() * 0.01; + let s0 = sz; + let s1 = sz * 0.5; + let a_peak = 0.6; + let grav = 0.0; + let c0 = [0.7, 0.75, 0.8]; + let c1 = [0.6, 0.65, 0.7]; + + pool.normal.push(p, v, life, s0, s1, a_peak, grav, c0, c1); + } + } + WeatherKind::DustStorm => { + const DUST_BASE_COUNT: f32 = 30.0; + let count = (self.current * DUST_BASE_COUNT) as usize; + for i in 0..count { + // Spawn dust motes (slow wind-drift, additive/normal) + let p = [ + listener[0] + self.rng.jit() * area, + listener[1] + self.rng.unit() * 6.0, + listener[2] + self.rng.jit() * area, + ]; + let v = [ + self.wind[0] * 1.5 + self.rng.jit() * 0.2, + self.rng.jit() * 0.1, + self.wind[1] * 1.5 + self.rng.jit() * 0.2, + ]; + let life = 2.0 + self.rng.unit() * 1.5; + let sz = 0.05 + self.rng.unit() * 0.05; + let s0 = sz; + let s1 = sz * 0.8; + let a_peak = 0.4; + let grav = 0.0; + let c0 = [0.8, 0.7, 0.5]; + let c1 = [0.7, 0.6, 0.4]; + + // Alternate/split between normal and additive layers + if i % 2 == 0 { + pool.normal.push(p, v, life, s0, s1, a_peak, grav, c0, c1); + } else { + pool.additive.push(p, v, life, s0, s1, a_peak, grav, c0, c1); + } + } + } + } + } + + pub fn current_intensity(&self) -> f32 { + self.current + } + + pub fn wind(&self) -> [f32; 2] { + self.wind + } +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + + #[test] + fn test_update_eases_current() { + let mut weather = Weather::new(42); + assert_eq!(weather.current_intensity(), 0.0); + + weather.set(WeatherKind::Rain, 0.8); + assert_eq!(weather.current_intensity(), 0.0); + + // Update several times, check monotonicity + let mut last_current = 0.0; + for _ in 0..5 { + weather.update(0.1); + let cur = weather.current_intensity(); + assert!(cur > last_current, "Must be monotonic increasing"); + assert!(cur <= 0.8, "Must not exceed target"); + last_current = cur; + } + + // Run long enough to fully reach target + weather.update(1.0); + assert_eq!(weather.current_intensity(), 0.8); + + // Ease back down to 0.3 + weather.set(WeatherKind::Rain, 0.3); + weather.update(0.1); + assert!(weather.current_intensity() < 0.8); + assert!(weather.current_intensity() >= 0.3); + + weather.update(1.0); + assert_eq!(weather.current_intensity(), 0.3); + } + + #[test] + fn test_emit_into_grows_and_clear_none() { + let mut weather = Weather::new(12345); + let mut pool = ParticlePool::new(999); + + // Initial Clear at 0 intensity + weather.emit_into(&mut pool, [0.0, 0.0, 0.0], 10.0); + assert_eq!(pool.normal.alive(), 0); + assert_eq!(pool.additive.alive(), 0); + + // Set Rain and ease to intensity 1.0 + weather.set(WeatherKind::Rain, 1.0); + weather.update(1.0); + assert_eq!(weather.current_intensity(), 1.0); + + weather.emit_into(&mut pool, [0.0, 0.0, 0.0], 10.0); + assert!(pool.normal.alive() > 0, "Rain must push particles"); + + // Clear weather shouldn't push any + let mut pool2 = ParticlePool::new(999); + let mut weather_clear = Weather::new(12345); + weather_clear.set(WeatherKind::Clear, 1.0); + weather_clear.update(1.0); + weather_clear.emit_into(&mut pool2, [0.0, 0.0, 0.0], 10.0); + assert_eq!(pool2.normal.alive(), 0); + assert_eq!(pool2.additive.alive(), 0); + } + + #[test] + fn test_emission_scales_with_intensity() { + let mut weather1 = Weather::new(777); + let mut pool1 = ParticlePool::new(111); + weather1.set(WeatherKind::Rain, 0.3); + weather1.update(1.0); + weather1.emit_into(&mut pool1, [0.0, 0.0, 0.0], 10.0); + let count_0_3 = pool1.normal.alive(); + + let mut weather2 = Weather::new(777); + let mut pool2 = ParticlePool::new(111); + weather2.set(WeatherKind::Rain, 1.0); + weather2.update(1.0); + weather2.emit_into(&mut pool2, [0.0, 0.0, 0.0], 10.0); + let count_1_0 = pool2.normal.alive(); + + assert!( + count_1_0 > count_0_3, + "Emission must scale with intensity: 1.0 count ({}) > 0.3 count ({})", + count_1_0, + count_0_3 + ); + } + + #[test] + fn test_wind_bounds_and_determinism() { + let mut weather1 = Weather::new(101); + let mut weather2 = Weather::new(101); + + for _ in 0..100 { + weather1.update(1.5); + weather2.update(1.5); + + let w1 = weather1.wind(); + let w2 = weather2.wind(); + + // Determinism + assert_eq!(w1, w2); + + // Magnitude bounds + let mag = libm::sqrtf(w1[0] * w1[0] + w1[1] * w1[1]); + assert!(mag >= 0.299, "Wind strength must be >= 0.3, got {}", mag); + assert!(mag <= 0.751, "Wind strength must be <= 0.75, got {}", mag); + + // Direction bounds (deg in [43, 187]) + let dir_rad = libm::atan2f(w1[1], w1[0]); + let mut dir_deg = dir_rad * 180.0 / core::f32::consts::PI; + if dir_deg < 0.0 { + dir_deg += 360.0; + } + assert!( + (42.9..=187.1).contains(&dir_deg), + "Wind direction degrees out of bounds: {}", + dir_deg + ); + } + } +} diff --git a/client-rust/source/engine-render/src/window.rs b/client-rust/source/engine-render/src/window.rs new file mode 100644 index 00000000..ece28a44 --- /dev/null +++ b/client-rust/source/engine-render/src/window.rs @@ -0,0 +1,1496 @@ +//! Desktop-style window manager (immediate-mode port of `client-3d`'s +//! `windowManager.ts`). Owns per-window bounds, open state, and z-order; drives +//! title-bar move, corner resize, focus-to-front, and close via the `UiBuilder` +//! pointer input. Input is resolved front-to-back in `update`; the host then +//! walks `z_order()` (back-to-front) drawing each window's chrome + content. +//! +//! Rendering stays renderer-agnostic: a window carries an optional icon atlas +//! cell `(col,row)` supplied by the host, never an icon id the engine can't +//! resolve. + +use crate::ui::{RectangleStyle, UiBuilder}; +use alloc::string::String; +use alloc::vec::Vec; + +/// Caption strip height. `ui_options.inc` captions are 19 px, all caps, +/// `bold_13`, 6 px left margin. +pub const CAPTION_H: f32 = 19.0; +/// Caption glyph cap height. +pub const CAPTION_TEXT_PX: f32 = 13.0; +/// Caption left margin. +pub const CAPTION_MARGIN: f32 = 6.0; +/// Outer top rail: the caption plus the frame's own inset above the content. +pub const TOP_RAIL: f32 = 29.0; +/// Outer bottom rail. +pub const BOTTOM_RAIL: f32 = 23.0; +/// Left/right content margin. +pub const SIDE_MARGIN: f32 = 6.0; +/// Caption close-control footprint — 16x16 in the original. +pub const CONTROL: f32 = 16.0; +/// Corner bracket arm length. The original's frames are not a plain rectangle: +/// each corner carries a stepped bracket that reads as a machined plate edge, +/// and without it a window is just a box with a caption. +pub const BRACKET_ARM: f32 = 14.0; +/// Gap between the outer perimeter and the bracket's inner step. +pub const BRACKET_INSET: f32 = 4.0; +/// Length of the 45-degree cut that joins the bracket's two inner arms. +pub const BRACKET_CHAMFER: f32 = 3.0; +/// Retained name for the caption strip height used by hosts that compute their +/// own insets. Prefer [`WindowManager::content_rect`]. +pub const TITLE_H: f32 = TOP_RAIL; +/// Pointer inset used to discover every resize edge and corner. +/// `UIWidget.cpp` RESIZE_MARGIN is 8: a press within 8 px of an edge resizes +/// and a press further inside moves. +pub const RESIZE_H: f32 = 8.0; +/// Resize inset inside the caption strip. `RESIZE_MARGIN` is measured against +/// a widget's own border; in the original the caption is a child page that +/// takes the press and moves the frame (`SwgCuiLockableMediator`'s +/// `AcceptsMoveFromChildren`), so the border is all that still resizes there. +/// Applying the full 8 px inside a 19 px caption leaves a 3 px move sliver and +/// makes title-bar dragging silently resize instead. +const CAPTION_RESIZE_MARGIN: f32 = 3.0; +/// Workspace icon slot size (`CuiWorkspaceIcon` slots are 32x32 and +/// resolution independent). +pub const ICON_SLOT: f32 = 32.0; +const EDGE_ATTRACTION_THRESHOLD: f32 = 4.0; +const DOUBLE_CLICK_MS: u64 = 350; + +#[derive(Clone)] +struct Win { + id: String, + title: String, + icon: Option<(u32, u32)>, + x: f32, + y: f32, + w: f32, + h: f32, + min_w: f32, + min_h: f32, + open: bool, + z: u32, + /// Minimized into its workspace icon slot. + iconified: bool, + /// Workspace icon slot origin. `None` ⇒ the frame cannot minimize, which + /// mirrors `CuiWorkspace::iconify` requiring `mediator.getIcon()`. + icon_slot: Option<(f32, f32)>, + /// Whether pointer gestures reach this frame. HUD panes register as + /// chromeless, non-interactive surfaces and only become draggable while + /// the host has layout editing on. + interactive: bool, + /// Whether the frame paints caption, perimeter, and close control. A + /// chromeless frame occupies its whole rect as content. + chrome: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ResizeEdges { + left: bool, + right: bool, + top: bool, + bottom: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DragMode { + Move, + Resize(ResizeEdges), +} + +struct Drag { + idx: usize, + mode: DragMode, + mx0: f32, + my0: f32, + bx: f32, + by: f32, + bw: f32, + bh: f32, +} + +/// Colors for frame chrome. Values are measured from the original frame +/// includes and window crops: base tint `#00D6FB`, inset `#01687E`, bright +/// outline `#1CFFFF`, caption ink `#00354F` on the bright rail. +#[derive(Clone, Copy, Debug)] +pub struct WindowStyle { + pub frame: RectangleStyle, + /// Unfocused caption rail (inset tone). + pub title_bar: [u8; 4], + /// Focused caption rail (base tint). + pub title_bar_focused: [u8; 4], + pub edge: [u8; 4], + /// Ink for caption text and caption controls: dark on the bright rail. + pub caption_text: [u8; 4], + /// Ink for chrome text drawn on the pane rather than the rail. + pub text: [u8; 4], + pub close: [u8; 4], + pub resize: [u8; 4], +} + +impl Default for WindowStyle { + fn default() -> Self { + // The original frame is a translucent teal pane under one bright cyan + // outline, with the caption rail carrying dark ink. Focus lives in the + // rail's brightness, never in stacked opaque cards. + Self { + frame: RectangleStyle { + north: [0x1C, 0xFF, 0xFF, 210], + south: [0x01, 0x68, 0x7E, 220], + east: [0x01, 0x68, 0x7E, 220], + west: [0x01, 0x68, 0x7E, 220], + center: [0x00, 0x38, 0x48, 226], + north_east: [0x1C, 0xFF, 0xFF, 210], + north_west: [0x1C, 0xFF, 0xFF, 210], + south_east: [0x01, 0x68, 0x7E, 220], + south_west: [0x01, 0x68, 0x7E, 220], + west_width: 1.0, + east_width: 1.0, + north_height: 1.0, + south_height: 1.0, + }, + title_bar: [0x01, 0x68, 0x7E, 232], + title_bar_focused: [0x00, 0xD6, 0xFB, 236], + edge: [0x1C, 0xFF, 0xFF, 204], + caption_text: [0x00, 0x35, 0x4F, 255], + text: [0x97, 0xFF, 0xFF, 255], + close: [0x00, 0x35, 0x4F, 255], + resize: [0x1C, 0xFF, 0xFF, 220], + } + } +} + +impl WindowStyle { + /// Scale the pane fills by a transparency setting. + /// + /// Fills only: the caption rails, the bright perimeter, and the caption + /// ink stay opaque. The original's window transparency fades the pane and + /// leaves the frame legible, and a translucent caption over terrain is + /// unreadable at any setting. + pub fn fade_fills(&mut self, opacity: f32) { + let scale = if opacity.is_finite() { + opacity.clamp(0.0, 1.0) + } else { + 1.0 + }; + for tone in [ + &mut self.frame.center, + &mut self.frame.north, + &mut self.frame.south, + &mut self.frame.east, + &mut self.frame.west, + &mut self.frame.north_east, + &mut self.frame.north_west, + &mut self.frame.south_east, + &mut self.frame.south_west, + ] { + tone[3] = libm::roundf(tone[3] as f32 * scale).clamp(0.0, 255.0) as u8; + } + } +} + +pub struct WindowManager { + wins: Vec<Win>, + z: u32, + drag: Option<Drag>, + sw: f32, + sh: f32, + captured: bool, + geometry_dirty: bool, + last_icon_click: Option<(usize, u64)>, +} + +impl Default for WindowManager { + fn default() -> Self { + Self::new() + } +} + +impl WindowManager { + pub fn new() -> Self { + Self { + wins: Vec::new(), + z: 0, + drag: None, + sw: 1.0, + sh: 1.0, + captured: false, + geometry_dirty: false, + last_icon_click: None, + } + } + + /// Register a (closed) window. `icon` is an atlas cell the host resolved. + pub fn register( + &mut self, + id: &str, + title: &str, + icon: Option<(u32, u32)>, + bounds: [f32; 4], + min_w: f32, + min_h: f32, + ) { + if self.find(id).is_some() { + return; + } + self.z += 1; + self.wins.push(Win { + id: String::from(id), + title: String::from(title), + icon, + x: bounds[0], + y: bounds[1], + w: bounds[2], + h: bounds[3], + min_w, + min_h, + open: false, + z: self.z, + iconified: false, + icon_slot: None, + interactive: true, + chrome: true, + }); + } + + fn find(&self, id: &str) -> Option<usize> { + self.wins.iter().position(|w| w.id == id) + } + + pub fn is_open(&self, id: &str) -> bool { + self.find(id).map(|i| self.wins[i].open).unwrap_or(false) + } + + /// Current outer bounds for a registered window, whether open or closed. + pub fn rect(&self, id: &str) -> Option<[f32; 4]> { + self.find(id).map(|i| { + let win = &self.wins[i]; + [win.x, win.y, win.w, win.h] + }) + } + + /// Replace a title without disturbing focus or geometry. + pub fn set_title(&mut self, id: &str, title: &str) { + if let Some(index) = self.find(id) { + if self.wins[index].title != title { + self.wins[index].title.clear(); + self.wins[index].title.push_str(title); + } + } + } + + /// Restore persisted outer bounds. Invalid rows are ignored; the next + /// viewport update performs the resolution-dependent clamp. + pub fn set_rect(&mut self, id: &str, bounds: [f32; 4]) -> bool { + if !bounds.iter().all(|value| value.is_finite()) || bounds[2] <= 0.0 || bounds[3] <= 0.0 { + return false; + } + let Some(index) = self.find(id) else { + return false; + }; + let win = &mut self.wins[index]; + win.x = bounds[0]; + win.y = bounds[1]; + win.w = bounds[2].max(win.min_w); + win.h = bounds[3].max(win.min_h); + true + } + + /// Visit every registered window's stable id and preferred (restored) + /// geometry without allocating in the window manager. + pub fn for_each_geometry<F: FnMut(&str, [f32; 4])>(&self, mut visit: F) { + for win in &self.wins { + let bounds = [win.x, win.y, win.w, win.h]; + visit(&win.id, bounds); + } + } + + /// Restore renderer-neutral frame state into an identically registered + /// manager after GPU/context reconstruction. Fresh registration retains + /// current icons, titles, and resize floors; geometry, visibility, focus + /// order, and completed-gesture dirtiness transfer without allocation. + pub fn restore_workspace_state_from(&mut self, previous: &Self) { + self.z = previous.z; + self.sw = previous.sw; + self.sh = previous.sh; + self.geometry_dirty = previous.geometry_dirty; + self.drag = None; + self.captured = false; + self.last_icon_click = None; + for win in &mut self.wins { + let Some(old) = previous.wins.iter().find(|old| old.id == win.id) else { + continue; + }; + win.x = old.x; + win.y = old.y; + win.w = old.w; + win.h = old.h; + win.open = old.open; + win.z = old.z; + win.iconified = old.iconified && win.icon_slot.is_some(); + win.interactive = old.interactive; + win.chrome = old.chrome; + } + } + + /// A completed resize/move marks geometry dirty; an in-progress pointer + /// frame never does. A changed viewport clamp is persisted separately. + pub fn take_geometry_dirty(&mut self) -> bool { + core::mem::take(&mut self.geometry_dirty) + } + + pub fn open(&mut self, id: &str) { + if let Some(i) = self.find(id) { + self.wins[i].open = true; + self.bring_to_front(i); + } + } + + pub fn close(&mut self, id: &str) { + if let Some(i) = self.find(id) { + self.wins[i].open = false; + } + } + + pub fn toggle(&mut self, id: &str) { + if let Some(i) = self.find(id) { + if self.wins[i].open { + self.wins[i].open = false; + } else { + self.wins[i].open = true; + self.bring_to_front(i); + } + } + } + + /// Whether pointer gestures reach a frame. HUD panes stay non-interactive + /// during play so gameplay input passes through, then become movable and + /// resizable while the host is editing the HUD layout. + pub fn set_interactive(&mut self, id: &str, interactive: bool) { + if let Some(index) = self.find(id) { + self.wins[index].interactive = interactive; + if !interactive && self.drag.as_ref().is_some_and(|drag| drag.idx == index) { + self.drag = None; + } + } + } + + pub fn is_interactive(&self, id: &str) -> bool { + self.find(id) + .is_some_and(|index| self.wins[index].interactive) + } + + /// Whether a frame paints window chrome. A chromeless frame draws no + /// caption, perimeter, or close control, and its content rect is its whole + /// rect — the shape HUD panes need. + pub fn set_chrome(&mut self, id: &str, chrome: bool) { + if let Some(index) = self.find(id) { + self.wins[index].chrome = chrome; + } + } + + pub fn has_chrome(&self, id: &str) -> bool { + self.find(id).is_some_and(|index| self.wins[index].chrome) + } + + pub fn any_open(&self) -> bool { + self.wins.iter().any(|w| w.open) + } + + fn bring_to_front(&mut self, idx: usize) { + self.z += 1; + self.wins[idx].z = self.z; + } + + /// Draw-order rank of an open window: 0 is furthest back. Composited 3D + /// surfaces key their layer off this so a viewer paints over its own + /// panel but under every window stacked above it. + pub fn z_rank(&self, id: &str) -> Option<usize> { + let target = self.wins.iter().find(|win| win.open && win.id == id)?; + Some( + self.wins + .iter() + .filter(|win| win.open && win.z < target.z) + .count(), + ) + } + + /// Open windows, back-to-front (ascending z) — the host draw order. + pub fn z_order(&self) -> Vec<usize> { + let mut idx: Vec<usize> = (0..self.wins.len()) + .filter(|&i| self.wins[i].open) + .collect(); + idx.sort_by_key(|&i| self.wins[i].z); + idx + } + /// Fill a caller-owned draw-order buffer without allocating. + pub fn fill_z_order(&self, out: &mut Vec<usize>) { + out.clear(); + out.extend((0..self.wins.len()).filter(|&index| self.wins[index].open)); + out.sort_unstable_by_key(|&index| self.wins[index].z); + } + + pub fn window_id(&self, idx: usize) -> &str { + &self.wins[idx].id + } + + fn clamp(&mut self, idx: usize) -> bool { + let win = &mut self.wins[idx]; + let before = [win.x, win.y, win.w, win.h]; + let vw = self.sw; + let vh = self.sh; + win.w = win.w.clamp(win.min_w.min(vw), vw); + win.h = win.h.clamp(win.min_h.min(vh), vh); + win.x = win.x.clamp(0.0, (vw - win.w).max(0.0)); + win.y = win.y.clamp(0.0, (vh - win.h).max(0.0)); + before != [win.x, win.y, win.w, win.h] + } + + fn resize_edges(win: &Win, mx: f32, my: f32) -> Option<ResizeEdges> { + // Inside a chromed frame's caption the strip is the move handle, so + // only the frame border still resizes; the bottom edge is never part + // of the caption and keeps the full margin. + let margin = if win.chrome && my <= win.y + CAPTION_H + 2.0 { + CAPTION_RESIZE_MARGIN + } else { + RESIZE_H + }; + let edges = ResizeEdges { + left: mx <= win.x + margin, + right: mx >= win.x + win.w - margin, + top: my <= win.y + margin, + bottom: my >= win.y + win.h - RESIZE_H, + }; + (edges.left || edges.right || edges.top || edges.bottom).then_some(edges) + } + + fn resize_drag(&mut self, idx: usize, edges: ResizeEdges, drag: [f32; 6], delta: [f32; 2]) { + let [bx, by, bw, bh, min_w, min_h] = drag; + let [dx, dy] = delta; + let right = bx + bw; + let bottom = by + bh; + let mut x = bx; + let mut y = by; + let mut w = bw; + let mut h = bh; + + if edges.left { + x = (bx + dx).clamp(0.0, right - min_w.min(self.sw)); + w = right - x; + } else if edges.right { + w = (bw + dx).clamp(min_w.min(self.sw), self.sw - bx); + } + if edges.top { + y = (by + dy).clamp(0.0, bottom - min_h.min(self.sh)); + h = bottom - y; + } else if edges.bottom { + h = (bh + dy).clamp(min_h.min(self.sh), self.sh - by); + } + + let win = &mut self.wins[idx]; + win.x = x; + win.y = y; + win.w = w; + win.h = h; + } + + fn snap_move(&mut self, idx: usize) { + let (mut x, mut y, w, h) = { + let win = &self.wins[idx]; + (win.x, win.y, win.w, win.h) + }; + let mut left = 0.0; + let mut right = self.sw; + let mut top = 0.0; + let mut bottom = self.sh; + for (other_idx, other) in self.wins.iter().enumerate() { + if other_idx == idx || !other.open { + continue; + } + let horizontal_overlap = other.x < x + w && other.x + other.w > x; + if horizontal_overlap { + let other_bottom = other.y + other.h; + if other_bottom > top && (other_bottom - y).abs() < (top - y).abs() { + top = other_bottom; + } + if other.y < bottom && (other.y - (y + h)).abs() < (bottom - (y + h)).abs() { + bottom = other.y; + } + } + let vertical_overlap = other.y < y + h && other.y + other.h > y; + if vertical_overlap { + let other_right = other.x + other.w; + if other_right > left && (other_right - x).abs() < (left - x).abs() { + left = other_right; + } + if other.x < right && (other.x - (x + w)).abs() < (right - (x + w)).abs() { + right = other.x; + } + } + } + if (x + w - right).abs() < EDGE_ATTRACTION_THRESHOLD { + x = right - w; + } + if (x - left).abs() < EDGE_ATTRACTION_THRESHOLD { + x = left; + } + if (y + h - bottom).abs() < EDGE_ATTRACTION_THRESHOLD { + y = bottom - h; + } + if (y - top).abs() < EDGE_ATTRACTION_THRESHOLD { + y = top; + } + self.wins[idx].x = x; + self.wins[idx].y = y; + } + + /// Resolve focus, move, all-edge resize, close, and workspace attraction. + /// Pointer presses are tested against the frontmost window. + pub fn update_at(&mut self, ui: &UiBuilder, screen_w: u32, screen_h: u32, now_ms: u64) { + let next_sw = screen_w.max(1) as f32; + let next_sh = screen_h.max(1) as f32; + let viewport_changed = self.sw != next_sw || self.sh != next_sh; + self.sw = next_sw; + self.sh = next_sh; + if viewport_changed { + let mut changed = false; + for index in 0..self.wins.len() { + changed |= self.clamp(index); + } + self.geometry_dirty |= changed; + } + self.captured = false; + let (mx, my) = ui.mouse(); + let state = ui.interact(0.0, 0.0, self.sw, self.sh); + let down = state.held; + + if self.drag.is_some() { + if !down { + self.drag = None; + self.geometry_dirty = true; + } else { + let (idx, mode, mx0, my0, bx, by, bw, bh) = { + let drag = self.drag.as_ref().expect("drag checked"); + ( + drag.idx, drag.mode, drag.mx0, drag.my0, drag.bx, drag.by, drag.bw, drag.bh, + ) + }; + let delta = [mx - mx0, my - my0]; + match mode { + DragMode::Move => { + self.wins[idx].x = bx + delta[0]; + self.wins[idx].y = by + delta[1]; + self.clamp(idx); + self.snap_move(idx); + } + DragMode::Resize(edges) => { + let min_w = self.wins[idx].min_w; + let min_h = self.wins[idx].min_h; + self.resize_drag(idx, edges, [bx, by, bw, bh, min_w, min_h], delta); + } + } + self.captured = true; + return; + } + } + + if !state.pressed { + return; + } + // A workspace icon restores only on a double click + // (`CuiWorkspaceIcon::ProcessMessage` → `restoreFromIcon`). + if let Some(index) = self.icon_slot_at(mx, my) { + self.captured = true; + let double = self.last_icon_click.is_some_and(|(last_index, last_ms)| { + last_index == index && now_ms.saturating_sub(last_ms) <= DOUBLE_CLICK_MS + }); + if double { + self.wins[index].iconified = false; + self.bring_to_front(index); + self.last_icon_click = None; + } else { + self.last_icon_click = Some((index, now_ms)); + } + return; + } + let target = self + .wins + .iter() + .enumerate() + .filter(|(_, win)| { + win.open + && win.interactive + && !win.iconified + && UiBuilder::hit(win.x, win.y, win.w, win.h, mx, my) + }) + .max_by_key(|(_, win)| win.z) + .map(|(idx, _)| idx); + let Some(idx) = target else { + return; + }; + let (x, y, w, h) = { + let win = &self.wins[idx]; + (win.x, win.y, win.w, win.h) + }; + self.bring_to_front(idx); + self.captured = true; + // The 16x16 close control is flush to the rail's right edge, in the + // same place `draw_chrome` paints it. A chromeless frame has no rail + // and no close control, so its whole body is the move surface — the + // unlocked `AcceptsMoveFromChildren` behaviour of the original's + // `SwgCuiLockableMediator`. + let chromeless = !self.wins[idx].chrome; + let close_x = if chromeless { + x + w + } else { + x + w - 2.0 - CONTROL + }; + let control_y = y + 1.0 + (CAPTION_H - CONTROL) * 0.5; + let resize_edges = Self::resize_edges(&self.wins[idx], mx, my); + let on_outer_border = + mx <= x + 2.0 || mx >= x + w - 2.0 || my <= y + 2.0 || my >= y + h - 2.0; + if on_outer_border { + if let Some(edges) = resize_edges { + self.drag = Some(Drag { + idx, + mode: DragMode::Resize(edges), + mx0: mx, + my0: my, + bx: x, + by: y, + bw: w, + bh: h, + }); + return; + } + } + if !chromeless && UiBuilder::hit(close_x, control_y, CONTROL, CONTROL, mx, my) { + self.wins[idx].open = false; + self.drag = None; + return; + } + if let Some(edges) = resize_edges { + self.drag = Some(Drag { + idx, + mode: DragMode::Resize(edges), + mx0: mx, + my0: my, + bx: x, + by: y, + bw: w, + bh: h, + }); + return; + } + let move_h = if chromeless { h } else { CAPTION_H + 2.0 }; + if UiBuilder::hit(x, y, close_x - x, move_h, mx, my) { + self.drag = Some(Drag { + idx, + mode: DragMode::Move, + mx0: mx, + my0: my, + bx: x, + by: y, + bw: w, + bh: h, + }); + } + } + + /// Compatibility entry for deterministic tests that do not model time. + pub fn update(&mut self, ui: &UiBuilder, screen_w: u32, screen_h: u32) { + self.update_at(ui, screen_w, screen_h, 0); + } + + /// Whether this frame's pointer press/drag landed on a window (so the host + /// should not also treat it as a background/HUD click). + pub fn pointer_captured(&self) -> bool { + self.captured + } + + /// Whether any visible open frame sits under this point right now. + /// + /// Interaction state controls workspace drag/resize behavior, not pointer + /// ownership. Locked HUD panes still contain live controls and must shield + /// the world from the same press. + pub fn covers(&self, x: f32, y: f32) -> bool { + self.wins.iter().any(|win| { + win.open && !win.iconified && UiBuilder::hit(win.x, win.y, win.w, win.h, x, y) + }) + } + + /// Pointer shape the workspace wants at `(x, y)`. + /// + /// Resolves against the frontmost interactive frame only, the same way the + /// press does, so a resize edge hidden under another window never claims + /// the pointer. An in-flight gesture wins outright: once a drag starts the + /// pointer keeps its shape even when it runs off the frame. + pub fn cursor_hint(&self, x: f32, y: f32) -> Option<crate::cursor::CursorKind> { + if let Some(drag) = self.drag.as_ref() { + return Some(match drag.mode { + DragMode::Move => crate::cursor::CursorKind::Move, + DragMode::Resize(edges) => crate::cursor::CursorKind::for_edges( + edges.left, + edges.right, + edges.top, + edges.bottom, + ) + .unwrap_or(crate::cursor::CursorKind::Move), + }); + } + let (index, _) = self + .wins + .iter() + .enumerate() + .filter(|(_, win)| { + win.open + && win.interactive + && !win.iconified + && UiBuilder::hit(win.x, win.y, win.w, win.h, x, y) + }) + .max_by_key(|(_, win)| win.z)?; + let win = &self.wins[index]; + if let Some(edges) = Self::resize_edges(win, x, y) { + return crate::cursor::CursorKind::for_edges( + edges.left, + edges.right, + edges.top, + edges.bottom, + ); + } + // The caption strip is the move surface on a chromed frame; a + // chromeless HUD pane is draggable anywhere once unlocked. + let move_band = if win.chrome { CAPTION_H + 2.0 } else { win.h }; + UiBuilder::hit(win.x, win.y, win.w, move_band, x, y) + .then_some(crate::cursor::CursorKind::Move) + } + + /// Id of the frontmost open frame (the focused one), if any. + pub fn focused_id(&self) -> Option<&str> { + self.focused_index() + .map(|index| self.wins[index].id.as_str()) + } + + fn focused_index(&self) -> Option<usize> { + self.wins + .iter() + .enumerate() + .filter(|(_, win)| win.open && !win.iconified) + .max_by_key(|(_, win)| win.z) + .map(|(index, _)| index) + } + + /// Index of the open, iconified frame whose workspace icon slot contains + /// the pointer. + fn icon_slot_at(&self, mx: f32, my: f32) -> Option<usize> { + self.wins.iter().position(|win| { + win.open + && win.iconified + && win + .icon_slot + .is_some_and(|(x, y)| UiBuilder::hit(x, y, ICON_SLOT, ICON_SLOT, mx, my)) + }) + } + + /// Press the focused frame's close control, the way `UIPage::ProcessMessage` + /// presses `FindCancelButton` on an unmodified Escape. Returns the id that + /// closed so the host can report it; `None` when no frame is focused. + pub fn close_focused(&mut self) -> Option<String> { + let index = self.focused_index()?; + self.wins[index].open = false; + self.drag = None; + self.captured = false; + Some(self.wins[index].id.clone()) + } + + /// Iconify a frame into its workspace icon slot (`CuiWorkspace::iconify`). + /// Only frames that declare an icon slot can minimize, which is why the + /// original matrix minimizes the ground radar rather than a plain frame. + pub fn iconify(&mut self, id: &str) -> bool { + let Some(index) = self.index_of(id) else { + return false; + }; + if self.wins[index].icon_slot.is_none() || self.wins[index].iconified { + return false; + } + self.wins[index].iconified = true; + self.drag = None; + true + } + + /// Restore an iconified frame. `CuiWorkspaceIcon::ProcessMessage` only + /// restores on `LeftMouseDoubleClick`, so the host must gate this on a real + /// double click. + pub fn restore_from_icon(&mut self, id: &str) -> bool { + let Some(index) = self.index_of(id) else { + return false; + }; + if !self.wins[index].iconified { + return false; + } + self.wins[index].iconified = false; + self.bring_to_front(index); + true + } + + pub fn is_iconified(&self, id: &str) -> bool { + self.index_of(id) + .is_some_and(|index| self.wins[index].iconified) + } + + /// Screen rect of a frame's workspace icon slot, when it has one. + pub fn icon_rect(&self, id: &str) -> Option<[f32; 4]> { + let index = self.index_of(id)?; + let (x, y) = self.wins[index].icon_slot?; + Some([x, y, ICON_SLOT, ICON_SLOT]) + } + + /// Give a frame a workspace icon slot, making it minimizable. + pub fn set_icon_slot(&mut self, id: &str, slot: Option<(f32, f32)>) { + if let Some(index) = self.index_of(id) { + self.wins[index].icon_slot = slot; + if slot.is_none() { + self.wins[index].iconified = false; + } + } + } + + fn index_of(&self, id: &str) -> Option<usize> { + self.wins.iter().position(|win| win.id == id) + } + + /// Content rect for a frame id: the caller draws inside this instead of + /// re-deriving the frame insets (`ui_options.inc`: 29 px top rail, 23 px + /// bottom rail, 6/7 px side margins). + pub fn content_rect(&self, id: &str) -> Option<[f32; 4]> { + let index = self.index_of(id)?; + Some(Self::content_rect_of(&self.wins[index])) + } + + fn content_rect_of(win: &Win) -> [f32; 4] { + if !win.chrome { + return [win.x, win.y, win.w.max(0.0), win.h.max(0.0)]; + } + [ + win.x + SIDE_MARGIN, + win.y + TOP_RAIL, + (win.w - SIDE_MARGIN * 2.0 - 1.0).max(0.0), + (win.h - TOP_RAIL - BOTTOM_RAIL).max(0.0), + ] + } + + /// Draw one frame's chrome and return its content rect. + /// + /// Grammar comes from the original `ui_options.inc` frame: a 29 px top rail + /// carrying a 19 px all-caps caption in dark ink over the bright rail, a + /// 23 px bottom rail, one bright hairline perimeter, and a persistent + /// 16x16 close control flush to the caption's right edge. Nothing else is + /// outlined — the perimeter is the only box. + pub fn draw_chrome(&self, ui: &mut UiBuilder, idx: usize, style: WindowStyle) -> [f32; 4] { + let win = &self.wins[idx]; + let (pointer_x, pointer_y) = ui.mouse(); + + // Minimized: the frame collapses to its workspace icon slot and the + // content rect goes empty, so surface content draws nothing. This + // resolves before chrome, so a chromeless pane with an icon slot still + // shows its icon. + if win.iconified { + if let Some((slot_x, slot_y)) = win.icon_slot { + ui.rect(slot_x, slot_y, ICON_SLOT, ICON_SLOT, style.title_bar); + ui.rect(slot_x, slot_y, ICON_SLOT, 1.0, style.edge); + if let Some((column, row)) = win.icon { + ui.icon( + column, + row, + slot_x + 5.0, + slot_y + 5.0, + ICON_SLOT - 10.0, + ICON_SLOT - 10.0, + style.text, + ); + } + return [slot_x, slot_y, 0.0, 0.0]; + } + return [win.x, win.y, 0.0, 0.0]; + } + + // Chromeless frames (HUD panes) paint no chrome: the surface owns its + // whole rect and supplies its own look. + if !win.chrome { + return Self::content_rect_of(win); + } + + let focused = self.focused_index() == Some(idx); + let resize_hover = Self::resize_edges(win, pointer_x, pointer_y).is_some(); + let right = win.x + win.w; + let bottom = win.y + win.h; + + // Drop shadow, translucent pane, one bright perimeter. + ui.rect(win.x + 3.0, win.y + 4.0, win.w, win.h, [0, 0, 0, 96]); + ui.nine_slice(win.x, win.y, win.w, win.h, &style.frame); + ui.border(win.x, win.y, win.w, win.h, 1.0, style.edge); + // Caption rail: bright when focused, inset when not. The caption itself + // is dark ink on the rail, which is what the original does. + let rail = if focused { + style.title_bar_focused + } else { + style.title_bar + }; + ui.rect(win.x + 1.0, win.y + 1.0, win.w - 2.0, CAPTION_H, rail); + ui.rect( + win.x + 1.0, + win.y + TOP_RAIL - 1.0, + win.w - 2.0, + 1.0, + style.edge, + ); + // Bottom rail: same inset tone, one hairline above it. + ui.rect( + win.x + 1.0, + bottom - BOTTOM_RAIL, + win.w - 2.0, + 1.0, + style.edge, + ); + + let mut text_x = win.x + CAPTION_MARGIN; + if let Some((column, row)) = win.icon { + let glyph = CAPTION_H - 6.0; + ui.icon( + column, + row, + win.x + CAPTION_MARGIN, + win.y + 1.0 + (CAPTION_H - glyph) * 0.5, + glyph, + glyph, + style.caption_text, + ); + text_x = win.x + CAPTION_MARGIN + glyph + 4.0; + } + let caption_px = CAPTION_TEXT_PX / 7.0; + let controls_w = CONTROL + 2.0; + let caption_w = (right - text_x - controls_w - 4.0).max(0.0); + let (caption, clipped) = clip_to(ui, &win.title, caption_px, caption_w); + let caption_y = win.y + 1.0 + (CAPTION_H - CAPTION_TEXT_PX) * 0.5; + let end = ui.text(caption, text_x, caption_y, caption_px, style.caption_text); + if clipped { + ui.text("...", end, caption_y, caption_px, style.caption_text); + } + + let close_x = right - 2.0 - CONTROL; + let top = win.y + 1.0 + (CAPTION_H - CONTROL) * 0.5; + // Close: an X inside its persistent 16x16 cancel control. + ui.line( + close_x + 4.0, + top + 4.0, + close_x + CONTROL - 4.0, + top + CONTROL - 4.0, + 1.4, + style.close, + ); + ui.line( + close_x + CONTROL - 4.0, + top + 4.0, + close_x + 4.0, + top + CONTROL - 4.0, + 1.4, + style.close, + ); + + // Stepped brackets over the perimeter. Only drawn when the frame is + // large enough that two facing arms cannot meet, otherwise a + // floor-sized pane fills with bracket instead of content. + // + // Every corner carries exactly one mark. Top-right belongs to the close + // control and bottom-right to the resize grip, so only the left pair is + // bracketed - a bracket laid under either control reads as damage. The + // top bracket lands on the caption rail, so its ink follows the rail: + // dark caption ink over the focused accent, the edge tone over the + // unfocused rail. One fixed choice vanishes in the other state. + if win.w > BRACKET_ARM * 2.5 && win.h > BRACKET_ARM * 2.5 { + let on_rail = if focused { + style.caption_text + } else { + style.edge + }; + corner_bracket(ui, win.x, win.y, 1.0, 1.0, on_rail); + corner_bracket(ui, win.x, bottom, 1.0, -1.0, style.edge); + } + + // Three diagonal ticks, always present: a grip you can only find by + // hovering is a grip players do not know exists. Hover brightens it to + // the resize tone; at rest it sits at the perimeter's own weight. + let grip = if resize_hover { + style.resize + } else { + style.edge + }; + for reach in [12.0, 9.0, 6.0] { + ui.line( + right - reach, + bottom - 3.0, + right - 3.0, + bottom - reach, + 1.2, + grip, + ); + } + + Self::content_rect_of(win) + } +} + +/// One stepped corner bracket, drawn from the corner at `(x, y)` inward along +/// `(dx, dy)` - each of the four is the same shape with its signs flipped. +/// +/// Two arms trace the perimeter, two more sit `BRACKET_INSET` inside it, and a +/// 45-degree cut joins the inner pair. The cut is what stops the inner arms +/// meeting at a second square corner, which would read as a double border +/// rather than a machined plate. +fn corner_bracket(ui: &mut UiBuilder, x: f32, y: f32, dx: f32, dy: f32, rgba: [u8; 4]) { + let arm = BRACKET_ARM; + let inset = BRACKET_INSET; + let step = inset + BRACKET_CHAMFER; + let t = 1.0; + + ui.line(x, y, x + dx * arm, y, t, rgba); + ui.line(x, y, x, y + dy * arm, t, rgba); + + ui.line( + x + dx * step, + y + dy * inset, + x + dx * arm, + y + dy * inset, + t, + rgba, + ); + ui.line( + x + dx * inset, + y + dy * step, + x + dx * inset, + y + dy * arm, + t, + rgba, + ); + ui.line( + x + dx * step, + y + dy * inset, + x + dx * inset, + y + dy * step, + t, + rgba, + ); +} + +/// Longest prefix of `text` that fits `max_w`, and whether it was cut. +fn clip_to<'a>(ui: &UiBuilder, text: &'a str, px: f32, max_w: f32) -> (&'a str, bool) { + if max_w <= 0.0 { + return ("", !text.is_empty()); + } + if ui.measure_text(text, px) <= max_w { + return (text, false); + } + let budget = (max_w - ui.measure_text("...", px)).max(0.0); + let mut end = 0; + let mut width = 0.0; + for (offset, ch) in text.char_indices() { + let advance = ui.measure_text(&text[offset..offset + ch.len_utf8()], px); + if width + advance > budget { + break; + } + width += advance; + end = offset + ch.len_utf8(); + } + (&text[..end], true) +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + use crate::ui::{AtlasMeta, UiBuilder}; + + const ATLAS: AtlasMeta = AtlasMeta { + cell: 32, + cols: 8, + width: 256, + height: 160, + }; + + fn wm() -> WindowManager { + let mut m = WindowManager::new(); + m.register( + "inv", + "INVENTORY", + None, + [100.0, 100.0, 300.0, 200.0], + 160.0, + 120.0, + ); + m.register( + "char", + "CHARACTER", + None, + [200.0, 150.0, 300.0, 200.0], + 160.0, + 120.0, + ); + m + } + + #[test] + fn open_close_toggle() { + let mut m = wm(); + assert!(!m.is_open("inv")); + m.open("inv"); + assert!(m.is_open("inv")); + m.toggle("inv"); + assert!(!m.is_open("inv")); + assert!(!m.any_open()); + } + + #[test] + fn focus_brings_to_front_in_z_order() { + let mut m = wm(); + m.open("inv"); + m.open("char"); + // char opened last → front (last in z_order). + let order = m.z_order(); + assert_eq!(m.window_id(*order.last().unwrap()), "char"); + // Click on inv (at 100,100) brings it to front. + let mut ui = UiBuilder::new(ATLAS); + ui.set_input(120.0, 110.0, true); + ui.begin(1280, 720); + m.update(&ui, 1280, 720); + let order = m.z_order(); + assert_eq!(m.window_id(*order.last().unwrap()), "inv"); + } + + #[test] + fn title_drag_moves_window() { + let mut m = wm(); + m.open("inv"); + let mut ui = UiBuilder::new(ATLAS); + // Press on inv title strip (100,100)+(10,10). + ui.set_input(110.0, 110.0, true); + ui.begin(1280, 720); + m.update(&ui, 1280, 720); + // Drag by (+40,+30). + ui.set_input(150.0, 140.0, true); + ui.begin(1280, 720); + m.update(&ui, 1280, 720); + let rect = m.draw_chrome(&mut ui, m.find("inv").unwrap(), WindowStyle::default()); + // content x = new window x (140) + pad(6). + assert!( + (rect[0] - (140.0 + 6.0)).abs() < 1e-3, + "moved x, got {}", + rect[0] + ); + } + + /// HUD panes register chromeless and non-interactive: locked panes shield + /// the world but do not move until explicitly unlocked. + #[test] + fn chromeless_pane_takes_no_pointer_until_unlocked() { + let mut m = wm(); + m.set_chrome("inv", false); + m.set_interactive("inv", false); + m.open("inv"); + let mut ui = UiBuilder::new(ATLAS); + + // Press well inside the body: locked, so nothing is captured or moved. + ui.set_input(200.0, 200.0, true); + ui.begin(1280, 720); + m.update(&ui, 1280, 720); + assert!( + !m.pointer_captured(), + "a locked HUD pane must not start a workspace gesture" + ); + assert!( + m.covers(200.0, 200.0), + "a visible HUD pane must shield the world" + ); + ui.set_input(240.0, 230.0, true); + ui.begin(1280, 720); + m.update(&ui, 1280, 720); + assert_eq!(m.rect("inv"), Some([100.0, 100.0, 300.0, 200.0])); + + // Unlocked, the same body press drags — no caption strip needed. + ui.set_input(0.0, 0.0, false); + ui.begin(1280, 720); + m.update(&ui, 1280, 720); + m.set_interactive("inv", true); + ui.set_input(200.0, 200.0, true); + ui.begin(1280, 720); + m.update(&ui, 1280, 720); + assert!(m.pointer_captured()); + ui.set_input(240.0, 230.0, true); + ui.begin(1280, 720); + m.update(&ui, 1280, 720); + assert_eq!(m.rect("inv"), Some([140.0, 130.0, 300.0, 200.0])); + } + + /// A chromeless pane owns its whole rect: no rails, no close control. + #[test] + fn chromeless_pane_content_is_the_whole_frame() { + let mut m = wm(); + m.open("inv"); + let mut ui = UiBuilder::new(ATLAS); + ui.begin(1280, 720); + let idx = m.find("inv").unwrap(); + + let framed = m.draw_chrome(&mut ui, idx, WindowStyle::default()); + assert_eq!(framed[1], 100.0 + TOP_RAIL); + let framed_quads = ui.quads; + + m.set_chrome("inv", false); + let bare = m.draw_chrome(&mut ui, idx, WindowStyle::default()); + assert_eq!(bare, [100.0, 100.0, 300.0, 200.0]); + assert_eq!( + ui.quads, framed_quads, + "a chromeless frame must not paint chrome" + ); + assert_eq!(m.content_rect("inv"), Some(bare)); + } + + #[test] + fn resize_respects_minimum() { + let mut m = wm(); + m.open("inv"); + let mut ui = UiBuilder::new(ATLAS); + // Press on the resize gadget (bottom-right of 100,100,300,200 → ~400,300). + ui.set_input(400.0 - 4.0, 300.0 - 4.0, true); + ui.begin(1280, 720); + m.update(&ui, 1280, 720); + // Drag far up-left to shrink below min. + ui.set_input(120.0, 120.0, true); + ui.begin(1280, 720); + m.update(&ui, 1280, 720); + let i = m.find("inv").unwrap(); + assert!(m.wins[i].w >= m.wins[i].min_w, "clamped to min width"); + assert!(m.wins[i].h >= m.wins[i].min_h, "clamped to min height"); + } + + #[test] + fn every_window_edge_resizes_from_its_anchored_side() { + let mut manager = wm(); + manager.open("inv"); + let mut ui = UiBuilder::new(ATLAS); + + // Left edge: the right side remains fixed at x=400. + ui.set_input(102.0, 180.0, true); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + ui.set_input(62.0, 180.0, true); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + let left = manager.rect("inv").unwrap(); + assert_eq!(left, [60.0, 100.0, 340.0, 200.0]); + + // Finish that gesture, restore, then use the top edge. The bottom + // remains fixed at y=300. + ui.set_input(62.0, 180.0, false); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + assert!(manager.set_rect("inv", [100.0, 100.0, 300.0, 200.0])); + ui.set_input(250.0, 102.0, true); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + ui.set_input(250.0, 72.0, true); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + assert_eq!(manager.rect("inv").unwrap(), [100.0, 70.0, 300.0, 230.0]); + + // Right and bottom edges grow away from the fixed origin. + ui.set_input(250.0, 72.0, false); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + assert!(manager.set_rect("inv", [100.0, 100.0, 300.0, 200.0])); + ui.set_input(398.0, 180.0, true); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + ui.set_input(448.0, 180.0, true); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + assert_eq!(manager.rect("inv").unwrap(), [100.0, 100.0, 350.0, 200.0]); + + ui.set_input(448.0, 180.0, false); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + assert!(manager.set_rect("inv", [100.0, 100.0, 300.0, 200.0])); + ui.set_input(250.0, 298.0, true); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + ui.set_input(250.0, 338.0, true); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + assert_eq!(manager.rect("inv").unwrap(), [100.0, 100.0, 300.0, 240.0]); + } + + /// The caption is the move handle. With the plain 8 px `RESIZE_MARGIN` + /// applied to a 19 px caption, a title-bar press lands in the top resize + /// band and the frame silently resizes instead of moving. + #[test] + fn caption_drag_moves_instead_of_resizing() { + let mut manager = wm(); + manager.open("inv"); + let mut ui = UiBuilder::new(ATLAS); + // y = 104 is 4 px into the caption: past the frame border, well inside + // the old 8 px top margin. + ui.set_input(250.0, 104.0, true); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + ui.set_input(190.0, 144.0, true); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + assert_eq!( + manager.rect("inv").unwrap(), + [40.0, 140.0, 300.0, 200.0], + "caption drag must translate the frame and preserve its size" + ); + ui.set_input(190.0, 144.0, false); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + } + + /// The frame border still resizes, including through the caption row, so + /// the top edge stays grabbable. + #[test] + fn caption_border_still_resizes() { + let mut manager = wm(); + manager.open("inv"); + let mut ui = UiBuilder::new(ATLAS); + ui.set_input(250.0, 101.0, true); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + ui.set_input(250.0, 71.0, true); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + assert_eq!(manager.rect("inv").unwrap(), [100.0, 70.0, 300.0, 230.0]); + ui.set_input(250.0, 71.0, false); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + } + + /// A pointer over the caption asks for the move cursor; over the border it + /// asks for the matching resize cursor. + #[test] + fn cursor_hint_separates_caption_from_border() { + use crate::cursor::CursorKind; + let mut manager = wm(); + manager.open("inv"); + assert_eq!(manager.cursor_hint(250.0, 104.0), Some(CursorKind::Move)); + assert_eq!( + manager.cursor_hint(250.0, 101.0), + Some(CursorKind::ResizeVertical) + ); + assert_eq!( + manager.cursor_hint(398.0, 298.0), + Some(CursorKind::ResizeNwSe) + ); + assert_eq!(manager.cursor_hint(250.0, 200.0), None); + assert_eq!(manager.cursor_hint(900.0, 600.0), None); + } + + #[test] + fn completed_window_gesture_marks_serializable_geometry_dirty() { + let mut manager = wm(); + manager.open("inv"); + assert!(!manager.take_geometry_dirty()); + let mut ui = UiBuilder::new(ATLAS); + ui.set_input(110.0, 110.0, true); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + ui.set_input(150.0, 140.0, true); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + assert!( + !manager.take_geometry_dirty(), + "in-flight drag is not persisted" + ); + ui.set_input(150.0, 140.0, false); + ui.begin(1280, 720); + manager.update(&ui, 1280, 720); + assert!(manager.take_geometry_dirty()); + + let mut rows = Vec::new(); + manager.for_each_geometry(|id, bounds| rows.push((id.to_string(), bounds))); + assert_eq!(rows[0], ("inv".to_string(), [140.0, 130.0, 300.0, 200.0])); + } + + #[test] + fn context_rebuild_preserves_completed_layout_open_order_and_dirty_save() { + let mut previous = wm(); + previous.open("char"); + previous.open("inv"); + let mut ui = UiBuilder::new(ATLAS); + ui.set_input(110.0, 110.0, true); + ui.begin(1280, 720); + previous.update(&ui, 1280, 720); + ui.set_input(150.0, 140.0, true); + ui.begin(1280, 720); + previous.update(&ui, 1280, 720); + ui.set_input(150.0, 140.0, false); + ui.begin(1280, 720); + previous.update(&ui, 1280, 720); + + let mut rebuilt = wm(); + rebuilt.restore_workspace_state_from(&previous); + + assert_eq!(rebuilt.rect("inv"), Some([140.0, 130.0, 300.0, 200.0])); + assert!(rebuilt.is_open("char")); + assert!(rebuilt.is_open("inv")); + let order = rebuilt.z_order(); + assert_eq!(rebuilt.window_id(order[0]), "char"); + assert_eq!(rebuilt.window_id(order[1]), "inv"); + assert!(!rebuilt.pointer_captured()); + assert!(rebuilt.take_geometry_dirty()); + } + + #[test] + fn close_box_click_closes() { + let mut m = wm(); + m.open("inv"); + let mut ui = UiBuilder::new(ATLAS); + // Close box: x + w - TITLE_H .. → 100+300-26=374 .. 400, y 100..126. + ui.set_input(387.0, 110.0, true); + ui.begin(1280, 720); + m.update(&ui, 1280, 720); + assert!(!m.is_open("inv"), "close box closed the window"); + } + + #[test] + fn moving_window_snaps_to_workspace_edge() { + let mut m = wm(); + m.open("inv"); + let mut ui = UiBuilder::new(ATLAS); + ui.set_input(110.0, 110.0, true); + ui.begin(1280, 720); + m.update(&ui, 1280, 720); + ui.set_input(12.0, 110.0, true); + ui.begin(1280, 720); + m.update(&ui, 1280, 720); + let win = &m.wins[m.find("inv").unwrap()]; + assert_eq!(win.x, 0.0, "two-pixel edge gap should attract"); + } + + #[test] + fn title_double_click_keeps_standard_frame_dragging() { + let mut m = wm(); + m.open("inv"); + let mut ui = UiBuilder::new(ATLAS); + ui.set_input(110.0, 110.0, true); + ui.begin(1280, 720); + m.update_at(&ui, 1280, 720, 100); + ui.set_input(110.0, 110.0, false); + ui.begin(1280, 720); + m.update_at(&ui, 1280, 720, 140); + + // A second title press stays a held move rather than maximizing. + ui.set_input(110.0, 110.0, true); + ui.begin(1280, 720); + m.update_at(&ui, 1280, 720, 200); + ui.set_input(150.0, 140.0, true); + ui.begin(1280, 720); + m.update_at(&ui, 1280, 720, 220); + + assert_eq!(m.rect("inv"), Some([140.0, 130.0, 300.0, 200.0])); + } +} diff --git a/client-rust/source/platform/Cargo.toml b/client-rust/source/platform/Cargo.toml new file mode 100644 index 00000000..17fd6eab --- /dev/null +++ b/client-rust/source/platform/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "successor-platform" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Compile-time platform backend (desktop GLFW/GL, web WebGL2) implementing the engine Gpu trait and I/O surface." +build = "build.rs" + +[dependencies] +successor-engine-core.workspace = true +successor-engine-render.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tungstenite.workspace = true +url.workspace = true +parking_lot = "0.12" +native-tls = "0.2" +[features] +default = [] +std = ["successor-engine-core/std", "successor-engine-render/std"] +alloc-count = ["successor-engine-core/alloc-count", "successor-engine-render/alloc-count"] diff --git a/client-rust/source/platform/build.rs b/client-rust/source/platform/build.rs new file mode 100644 index 00000000..5e987052 --- /dev/null +++ b/client-rust/source/platform/build.rs @@ -0,0 +1,57 @@ +// Native link flags for the desktop backend. Web (wasm32) links nothing here: +// its GL/WebSocket/fetch surface is satisfied by the JS shim at runtime. +// +// macOS: Homebrew GLFW + Apple frameworks (mirrors +// ~/code/sandbox/voxel_engine/source/engine/build.rs). Linux: pkg-config glfw3 +// plus the system GL loader. + +fn main() { + let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); + if target_arch == "wasm32" { + return; + } + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + match target_os.as_str() { + "macos" => { + // Homebrew default prefixes (Apple Silicon then Intel). + println!("cargo:rustc-link-search=native=/opt/homebrew/lib"); + println!("cargo:rustc-link-search=native=/usr/local/lib"); + println!("cargo:rustc-link-lib=glfw"); + println!("cargo:rustc-link-lib=framework=OpenGL"); + println!("cargo:rustc-link-lib=framework=Cocoa"); + println!("cargo:rustc-link-lib=framework=IOKit"); + println!("cargo:rustc-link-lib=framework=CoreFoundation"); + println!("cargo:rustc-link-lib=framework=CoreVideo"); + } + "linux" => { + // Prefer pkg-config; fall back to bare -lglfw -lGL. + if pkg_config_glfw().is_err() { + println!("cargo:rustc-link-lib=glfw"); + } + println!("cargo:rustc-link-lib=GL"); + } + other => { + println!("cargo:warning=successor-platform: unhandled target_os `{other}`; no GL link flags emitted"); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn pkg_config_glfw() -> Result<(), ()> { + let out = std::process::Command::new("pkg-config") + .args(["--libs", "glfw3"]) + .output() + .map_err(|_| ())?; + if !out.status.success() { + return Err(()); + } + let flags = String::from_utf8_lossy(&out.stdout); + for tok in flags.split_whitespace() { + if let Some(dir) = tok.strip_prefix("-L") { + println!("cargo:rustc-link-search=native={dir}"); + } else if let Some(lib) = tok.strip_prefix("-l") { + println!("cargo:rustc-link-lib={lib}"); + } + } + Ok(()) +} diff --git a/client-rust/source/platform/src/gl_gpu.rs b/client-rust/source/platform/src/gl_gpu.rs new file mode 100644 index 00000000..24f62aa2 --- /dev/null +++ b/client-rust/source/platform/src/gl_gpu.rs @@ -0,0 +1,948 @@ +//! OpenGL implementation of the Gpu trait. + +use std::collections::HashMap; +use successor_engine_render::gpu::{ + BufferId, BufferUsage, ClearSpec, Cull, Filter, ForwardLight, Gpu, GpuCaps, GpuError, + MinFilter, MrtDesc, PassTarget, PipelineState, ProgramId, RectPx, RenderTargetDesc, + RenderTargetId, Texture3dDesc, TextureArrayDesc, TextureDesc, TextureFormat, TextureId, + Uniform, UniformValue, VertexFormat, VertexLayout, Wrap, +}; + +#[cfg(not(target_arch = "wasm32"))] +use crate::native::gl; + +#[cfg(target_arch = "wasm32")] +use crate::web::gl; + +struct RenderTarget { + fbo: u32, + colors: Vec<TextureId>, + depth_tex: Option<TextureId>, + width: u32, + height: u32, +} + +pub struct GlGpu { + #[allow(dead_code)] + vao: u32, + uniform_cache: HashMap<u32, HashMap<&'static str, i32>>, + render_targets: Vec<RenderTarget>, + active_program: u32, + caps: successor_engine_render::gpu::GpuCaps, + error: Option<GpuError>, +} + +impl Default for GlGpu { + fn default() -> Self { + Self::new() + } +} + +impl GlGpu { + pub fn new() -> Self { + let vao = gl::gen_vertex_array(); + gl::bind_vertex_array(vao); + + Self { + vao, + uniform_cache: HashMap::new(), + render_targets: Vec::new(), + active_program: 0, + caps: successor_engine_render::gpu::GpuCaps { + half_float_target: gl::cap_half_float_target(), + max_color_attachments: gl::get_integer(gl::MAX_COLOR_ATTACHMENTS).max(0) as u32, + max_draw_buffers: gl::get_integer(gl::MAX_DRAW_BUFFERS).max(0) as u32, + }, + error: None, + } + } +} + +impl Gpu for GlGpu { + fn create_buffer(&mut self, data: &[u8], usage: BufferUsage) -> BufferId { + let handle = gl::gen_buffer(); + let gl_usage = match usage { + BufferUsage::Static => gl::STATIC_DRAW, + BufferUsage::Dynamic => gl::DYNAMIC_DRAW, + }; + gl::bind_buffer(gl::ARRAY_BUFFER, handle); + gl::buffer_data(gl::ARRAY_BUFFER, data, gl_usage); + gl::bind_buffer(gl::ARRAY_BUFFER, 0); + BufferId(handle) + } + + fn create_index_buffer(&mut self, data: &[u8], usage: BufferUsage) -> BufferId { + let handle = gl::gen_buffer(); + let gl_usage = match usage { + BufferUsage::Static => gl::STATIC_DRAW, + BufferUsage::Dynamic => gl::DYNAMIC_DRAW, + }; + // Bind as ELEMENT_ARRAY_BUFFER on creation so WebGL2 fixes its type as an + // index buffer (a buffer first bound to ARRAY_BUFFER can't be rebound). + gl::bind_buffer(gl::ELEMENT_ARRAY_BUFFER, handle); + gl::buffer_data(gl::ELEMENT_ARRAY_BUFFER, data, gl_usage); + gl::bind_buffer(gl::ELEMENT_ARRAY_BUFFER, 0); + BufferId(handle) + } + + fn update_buffer(&mut self, id: BufferId, data: &[u8]) { + gl::bind_buffer(gl::ARRAY_BUFFER, id.0); + gl::buffer_data(gl::ARRAY_BUFFER, data, gl::DYNAMIC_DRAW); + gl::bind_buffer(gl::ARRAY_BUFFER, 0); + } + + fn create_program(&mut self, vert_src: &str, frag_src: &str) -> ProgramId { + let header = if cfg!(target_arch = "wasm32") { + "#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\nprecision highp sampler3D;\nprecision highp sampler2DArray;\n" + } else { + "#version 330 core\n" + }; + + let vert_full = format!("{}{}", header, vert_src); + let frag_full = format!("{}{}", header, frag_src); + + let vs = gl::create_shader(gl::VERTEX_SHADER); + gl::shader_source(vs, vert_full.as_bytes()); + gl::compile_shader(vs); + + let vs_ok = gl::get_shaderiv(vs, gl::COMPILE_STATUS); + if vs_ok == 0 { + let mut info = [0u8; 1024]; + let len = gl::get_shader_info_log(vs, &mut info); + let log_msg = std::str::from_utf8(&info[..len]).unwrap_or("unknown error"); + successor_engine_core::rt::log::log_str("Vertex shader compile error:\n"); + successor_engine_core::rt::log::log_str(log_msg); + successor_engine_core::rt::log::log_str("\n"); + self.error.get_or_insert(GpuError::ShaderCompile); + } + + let fs = gl::create_shader(gl::FRAGMENT_SHADER); + gl::shader_source(fs, frag_full.as_bytes()); + gl::compile_shader(fs); + + let fs_ok = gl::get_shaderiv(fs, gl::COMPILE_STATUS); + if fs_ok == 0 { + let mut info = [0u8; 1024]; + let len = gl::get_shader_info_log(fs, &mut info); + let log_msg = std::str::from_utf8(&info[..len]).unwrap_or("unknown error"); + successor_engine_core::rt::log::log_str("Fragment shader compile error:\n"); + successor_engine_core::rt::log::log_str(log_msg); + successor_engine_core::rt::log::log_str("\n"); + self.error.get_or_insert(GpuError::ShaderCompile); + } + + let program = gl::create_program(); + gl::attach_shader(program, vs); + gl::attach_shader(program, fs); + gl::link_program(program); + + let link_ok = gl::get_programiv(program, gl::LINK_STATUS); + if link_ok == 0 { + let mut info = [0u8; 1024]; + let len = gl::get_program_info_log(program, &mut info); + let log_msg = std::str::from_utf8(&info[..len]).unwrap_or("unknown error"); + successor_engine_core::rt::log::log_str("Program link error:\n"); + successor_engine_core::rt::log::log_str(log_msg); + successor_engine_core::rt::log::log_str("\n"); + self.error.get_or_insert(GpuError::ProgramLink); + } + + gl::delete_shader(vs); + gl::delete_shader(fs); + + ProgramId(program) + } + + fn create_texture(&mut self, desc: &TextureDesc, data: Option<&[u8]>) -> TextureId { + if desc.width == 0 || desc.height == 0 { + self.error.get_or_insert(GpuError::InvalidResource); + } + let handle = gl::gen_texture(); + gl::bind_texture(gl::TEXTURE_2D, handle); + gl::pixel_storei(gl::UNPACK_ALIGNMENT, 1); + + let mag_filter = match desc.mag_filter { + Filter::Nearest => gl::NEAREST, + Filter::Linear => gl::LINEAR, + }; + let min_filter = match desc.min_filter { + MinFilter::Nearest => gl::NEAREST, + MinFilter::Linear => gl::LINEAR, + MinFilter::NearestMipmapNearest => gl::NEAREST_MIPMAP_NEAREST, + MinFilter::LinearMipmapLinear => gl::LINEAR_MIPMAP_LINEAR, + }; + let wrap = |mode| match mode { + Wrap::ClampToEdge => gl::CLAMP_TO_EDGE, + Wrap::Repeat => gl::REPEAT, + }; + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_MIN_FILTER, min_filter); + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_MAG_FILTER, mag_filter); + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_S, wrap(desc.wrap_s)); + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_T, wrap(desc.wrap_t)); + + let (internal_format, format, ty) = match desc.format { + TextureFormat::Rgba8 => (gl::RGBA8 as i32, gl::RGBA, gl::UNSIGNED_BYTE), + TextureFormat::Srgba8 => (gl::SRGB8_ALPHA8 as i32, gl::RGBA, gl::UNSIGNED_BYTE), + TextureFormat::R8 => (gl::R8 as i32, gl::RED, gl::UNSIGNED_BYTE), + TextureFormat::Rg8 => (gl::RG8 as i32, gl::RG, gl::UNSIGNED_BYTE), + TextureFormat::Rgba16F => (gl::RGBA16F as i32, gl::RGBA, gl::HALF_FLOAT), + TextureFormat::Depth => ( + gl::DEPTH_COMPONENT24 as i32, + gl::DEPTH_COMPONENT, + gl::UNSIGNED_INT, + ), + }; + + gl::tex_image_2d( + gl::TEXTURE_2D, + 0, + internal_format, + desc.width as i32, + desc.height as i32, + 0, + format, + ty, + data, + ); + if desc.mipmaps { + gl::generate_mipmap(gl::TEXTURE_2D); + } + + gl::bind_texture(gl::TEXTURE_2D, 0); + TextureId(handle) + } + + fn update_texture(&mut self, id: TextureId, desc: &TextureDesc, data: &[u8]) { + gl::bind_texture(gl::TEXTURE_2D, id.0); + gl::pixel_storei(gl::UNPACK_ALIGNMENT, 1); + let (internal_format, format) = match desc.format { + TextureFormat::Rgba8 => (gl::RGBA8 as i32, gl::RGBA), + TextureFormat::Srgba8 => (gl::SRGB8_ALPHA8 as i32, gl::RGBA), + _ => { + self.error.get_or_insert(GpuError::InvalidResource); + gl::bind_texture(gl::TEXTURE_2D, 0); + return; + } + }; + gl::tex_image_2d( + gl::TEXTURE_2D, + 0, + internal_format, + desc.width as i32, + desc.height as i32, + 0, + format, + gl::UNSIGNED_BYTE, + Some(data), + ); + if desc.mipmaps { + gl::generate_mipmap(gl::TEXTURE_2D); + } + gl::bind_texture(gl::TEXTURE_2D, 0); + } + + fn create_texture_array(&mut self, desc: &TextureArrayDesc, data: Option<&[u8]>) -> TextureId { + if desc.width == 0 || desc.height == 0 || desc.layers == 0 { + self.error.get_or_insert(GpuError::InvalidResource); + } + let handle = gl::gen_texture(); + gl::bind_texture(gl::TEXTURE_2D_ARRAY, handle); + gl::pixel_storei(gl::UNPACK_ALIGNMENT, 1); + gl::tex_parameteri( + gl::TEXTURE_2D_ARRAY, + gl::TEXTURE_MIN_FILTER, + if desc.mipmaps { + gl::LINEAR_MIPMAP_LINEAR + } else { + gl::LINEAR + }, + ); + gl::tex_parameteri(gl::TEXTURE_2D_ARRAY, gl::TEXTURE_MAG_FILTER, gl::LINEAR); + gl::tex_parameteri(gl::TEXTURE_2D_ARRAY, gl::TEXTURE_WRAP_S, gl::REPEAT); + gl::tex_parameteri(gl::TEXTURE_2D_ARRAY, gl::TEXTURE_WRAP_T, gl::REPEAT); + let (internal_format, format) = match desc.format { + TextureFormat::Rgba8 => (gl::RGBA8 as i32, gl::RGBA), + TextureFormat::Srgba8 => (gl::SRGB8_ALPHA8 as i32, gl::RGBA), + _ => { + self.error.get_or_insert(GpuError::InvalidResource); + (gl::RGBA8 as i32, gl::RGBA) + } + }; + gl::tex_image_3d( + gl::TEXTURE_2D_ARRAY, + 0, + internal_format, + desc.width as i32, + desc.height as i32, + desc.layers as i32, + 0, + format, + gl::UNSIGNED_BYTE, + data, + ); + if desc.mipmaps { + gl::generate_mipmap(gl::TEXTURE_2D_ARRAY); + } + gl::bind_texture(gl::TEXTURE_2D_ARRAY, 0); + TextureId(handle) + } + + fn bind_texture_array(&mut self, slot: u32, tex: TextureId) { + gl::active_texture(gl::TEXTURE0 + slot); + gl::bind_texture(gl::TEXTURE_2D_ARRAY, tex.0); + } + + fn create_render_target(&mut self, desc: &RenderTargetDesc) -> RenderTargetId { + let fbo = gl::gen_framebuffer(); + gl::bind_framebuffer(gl::FRAMEBUFFER, fbo); + + let filter = match desc.filter { + Filter::Nearest => gl::NEAREST, + Filter::Linear => gl::LINEAR, + }; + + let color_tex = if desc.color { + let handle = gl::gen_texture(); + gl::bind_texture(gl::TEXTURE_2D, handle); + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_MIN_FILTER, filter); + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_MAG_FILTER, filter); + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_S, gl::CLAMP_TO_EDGE); + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_T, gl::CLAMP_TO_EDGE); + gl::tex_image_2d( + gl::TEXTURE_2D, + 0, + gl::RGBA8 as i32, + desc.width as i32, + desc.height as i32, + 0, + gl::RGBA, + gl::UNSIGNED_BYTE, + None, + ); + gl::framebuffer_texture_2d( + gl::FRAMEBUFFER, + gl::COLOR_ATTACHMENT0, + gl::TEXTURE_2D, + handle, + 0, + ); + Some(TextureId(handle)) + } else { + None + }; + + let depth_tex = if desc.depth { + let handle = gl::gen_texture(); + gl::bind_texture(gl::TEXTURE_2D, handle); + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_MIN_FILTER, filter); + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_MAG_FILTER, filter); + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_S, gl::CLAMP_TO_EDGE); + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_T, gl::CLAMP_TO_EDGE); + gl::tex_image_2d( + gl::TEXTURE_2D, + 0, + gl::DEPTH_COMPONENT24 as i32, + desc.width as i32, + desc.height as i32, + 0, + gl::DEPTH_COMPONENT, + gl::UNSIGNED_INT, + None, + ); + gl::framebuffer_texture_2d( + gl::FRAMEBUFFER, + gl::DEPTH_ATTACHMENT, + gl::TEXTURE_2D, + handle, + 0, + ); + Some(TextureId(handle)) + } else { + None + }; + + if !desc.color { + gl::disable_draw_buffer(); + } + + let status = gl::check_framebuffer_status(gl::FRAMEBUFFER); + if status != gl::FRAMEBUFFER_COMPLETE { + successor_engine_core::rt::log::log1u("Framebuffer incomplete status: ", status as u64); + successor_engine_core::rt::log::log_str("\n"); + self.error.get_or_insert(GpuError::IncompleteFramebuffer); + } + + gl::bind_framebuffer(gl::FRAMEBUFFER, 0); + + let rt_idx = self.render_targets.len() as u32 + 1; + self.render_targets.push(RenderTarget { + fbo, + colors: color_tex.into_iter().collect(), + depth_tex, + width: desc.width, + height: desc.height, + }); + + RenderTargetId(rt_idx) + } + + fn render_target_color(&self, rt: RenderTargetId) -> Option<TextureId> { + let idx = rt.0 as usize - 1; + if idx < self.render_targets.len() { + self.render_targets[idx].colors.first().copied() + } else { + None + } + } + + fn render_target_depth(&self, rt: RenderTargetId) -> Option<TextureId> { + let idx = rt.0 as usize - 1; + if idx < self.render_targets.len() { + self.render_targets[idx].depth_tex + } else { + None + } + } + + fn render_target_size(&self, rt: RenderTargetId) -> Option<(u32, u32)> { + let target = self.render_targets.get(rt.0 as usize - 1)?; + (target.fbo != 0).then_some((target.width, target.height)) + } + + fn begin_pass(&mut self, target: PassTarget, viewport: RectPx, clear: ClearSpec) { + match target { + PassTarget::Screen => { + gl::bind_framebuffer(gl::FRAMEBUFFER, 0); + } + PassTarget::RenderTarget(rt_id) => { + let idx = rt_id.0 as usize - 1; + if idx < self.render_targets.len() { + gl::bind_framebuffer(gl::FRAMEBUFFER, self.render_targets[idx].fbo); + } + } + } + gl::viewport(viewport.x, viewport.y, viewport.w, viewport.h); + + let mut mask = 0; + if let Some(color) = clear.color { + // glClear respects the active color write mask. A depth-only pass + // leaves it disabled, so restore it before clearing the next + // framebuffer; the next pipeline will apply its own mask. + gl::color_mask(true, true, true, true); + gl::clear_color(color[0], color[1], color[2], color[3]); + mask |= gl::COLOR_BUFFER_BIT; + } + if clear.depth.is_some() { + gl::depth_mask(true); + mask |= gl::DEPTH_BUFFER_BIT; + } + if mask != 0 { + gl::clear(mask); + } + } + + fn set_pipeline(&mut self, program: ProgramId, state: &PipelineState) { + gl::use_program(program.0); + self.active_program = program.0; + + if state.depth_test { + gl::enable(gl::DEPTH_TEST); + } else { + gl::disable(gl::DEPTH_TEST); + } + + gl::depth_mask(state.depth_write); + + match state.cull { + Cull::None => { + gl::disable(gl::CULL_FACE); + } + Cull::Back => { + gl::enable(gl::CULL_FACE); + gl::cull_face(gl::BACK); + } + Cull::Front => { + gl::enable(gl::CULL_FACE); + gl::cull_face(gl::FRONT); + } + } + + gl::color_mask( + state.color_write, + state.color_write, + state.color_write, + state.color_write, + ); + + if state.blend { + gl::enable(gl::BLEND); + if state.additive { + gl::blend_func(gl::SRC_ALPHA, gl::ONE); + } else { + gl::blend_func(gl::SRC_ALPHA, gl::ONE_MINUS_SRC_ALPHA); + } + } else { + gl::disable(gl::BLEND); + } + } + + fn set_uniforms(&mut self, uniforms: &[Uniform]) { + let program = self.active_program; + if program == 0 { + return; + } + + // Safe steady-state lock-free lookup + let has_key = if let Some(cache) = self.uniform_cache.get(&program) { + let mut all_found = true; + for u in uniforms { + if !cache.contains_key(u.name) { + all_found = false; + break; + } + } + all_found + } else { + false + }; + + if !has_key { + // Allocate once during first frame or load + let cache = self.uniform_cache.entry(program).or_default(); + for u in uniforms { + if !cache.contains_key(u.name) { + let loc = gl::get_uniform_location(program, u.name); + cache.insert(u.name, loc); + } + } + } + + let cache = self.uniform_cache.get(&program).unwrap(); + for u in uniforms { + if let Some(&loc) = cache.get(u.name) { + if loc == -1 { + continue; + } + match u.value { + UniformValue::Float(v) => gl::uniform1f(loc, v), + UniformValue::Vec3(v) => gl::uniform3f(loc, v[0], v[1], v[2]), + UniformValue::Vec2(v) => gl::uniform2f(loc, v[0], v[1]), + UniformValue::Vec4(v) => gl::uniform4f(loc, v[0], v[1], v[2], v[3]), + UniformValue::Mat4(v) => gl::uniform_matrix4fv(loc, false, &v), + UniformValue::Int(v) => gl::uniform1i(loc, v), + UniformValue::Sampler(v) => gl::uniform1i(loc, v), + } + } + } + } + + fn bind_texture(&mut self, slot: u32, tex: TextureId) { + gl::active_texture(gl::TEXTURE0 + slot); + gl::bind_texture(gl::TEXTURE_2D, tex.0); + } + + fn draw( + &mut self, + vertices: BufferId, + indices: Option<BufferId>, + layout: &VertexLayout, + count: u32, + ) { + gl::bind_buffer(gl::ARRAY_BUFFER, vertices.0); + + for attr in layout.attrs { + gl::enable_vertex_attrib_array(attr.location); + let (scalar, normalized) = match attr.format { + VertexFormat::F32 => (gl::FLOAT, false), + VertexFormat::U8 => (gl::UNSIGNED_BYTE, false), + VertexFormat::U8Norm => (gl::UNSIGNED_BYTE, true), + VertexFormat::U16Norm => (gl::UNSIGNED_SHORT, true), + }; + gl::vertex_attrib_pointer( + attr.location, + attr.components as i32, + scalar, + normalized, + layout.stride as i32, + attr.offset, + ); + } + + if let Some(ebo) = indices { + gl::bind_buffer(gl::ELEMENT_ARRAY_BUFFER, ebo.0); + gl::draw_elements(gl::TRIANGLES, count as i32, gl::UNSIGNED_INT, 0); + gl::bind_buffer(gl::ELEMENT_ARRAY_BUFFER, 0); + } else { + gl::draw_arrays(gl::TRIANGLES, 0, count as i32); + } + + for attr in layout.attrs { + gl::disable_vertex_attrib_array(attr.location); + } + + gl::bind_buffer(gl::ARRAY_BUFFER, 0); + } + + fn set_joints(&mut self, mats: &[[f32; 16]]) { + let program = self.active_program; + if program == 0 || mats.is_empty() { + return; + } + let name = "u_joints"; + let loc = match self + .uniform_cache + .get(&program) + .and_then(|c| c.get(name).copied()) + { + Some(l) => l, + None => { + let l = gl::get_uniform_location(program, name); + self.uniform_cache + .entry(program) + .or_default() + .insert(name, l); + l + } + }; + if loc == -1 { + return; + } + let flat = + unsafe { std::slice::from_raw_parts(mats.as_ptr() as *const f32, mats.len() * 16) }; + gl::uniform_matrix4fv_array(loc, flat); + } + + fn set_forward_lights(&mut self, lights: &[ForwardLight]) { + let program = self.active_program; + if program == 0 { + return; + } + let count = lights.len().min(32); + if count == 0 { + return; + } + let mut positions = [0.0; 96]; + let mut radii = [0.0; 32]; + let mut colors = [0.0; 96]; + let mut intensities = [0.0; 32]; + for (index, light) in lights[..count].iter().enumerate() { + positions[index * 3..index * 3 + 3].copy_from_slice(&light.position); + radii[index] = light.radius; + colors[index * 3..index * 3 + 3].copy_from_slice(&light.color); + intensities[index] = light.intensity; + } + for (name, kind) in [ + ("u_pointPositions[0]", 0u8), + ("u_pointRadii[0]", 1), + ("u_pointColors[0]", 2), + ("u_pointIntensities[0]", 3), + ] { + let location = self + .uniform_cache + .get(&program) + .and_then(|cache| cache.get(name).copied()) + .unwrap_or_else(|| { + let location = gl::get_uniform_location(program, name); + self.uniform_cache + .entry(program) + .or_default() + .insert(name, location); + location + }); + if location == -1 { + continue; + } + match kind { + 0 => gl::uniform3fv(location, &positions[..count * 3]), + 1 => gl::uniform1fv(location, &radii[..count]), + 2 => gl::uniform3fv(location, &colors[..count * 3]), + _ => gl::uniform1fv(location, &intensities[..count]), + } + } + } + + #[allow(clippy::too_many_arguments)] + fn draw_instanced( + &mut self, + vertices: BufferId, + indices: Option<BufferId>, + layout: &VertexLayout, + index_count: u32, + instance_buf: BufferId, + instance_layout: &VertexLayout, + instances: u32, + ) { + gl::bind_buffer(gl::ARRAY_BUFFER, vertices.0); + for attr in layout.attrs { + gl::enable_vertex_attrib_array(attr.location); + let (scalar, normalized) = match attr.format { + VertexFormat::F32 => (gl::FLOAT, false), + VertexFormat::U8 => (gl::UNSIGNED_BYTE, false), + VertexFormat::U8Norm => (gl::UNSIGNED_BYTE, true), + VertexFormat::U16Norm => (gl::UNSIGNED_SHORT, true), + }; + gl::vertex_attrib_pointer( + attr.location, + attr.components as i32, + scalar, + normalized, + layout.stride as i32, + attr.offset, + ); + } + // Per-instance attributes (divisor 1), described by `instance_layout`. + gl::bind_buffer(gl::ARRAY_BUFFER, instance_buf.0); + for attr in instance_layout.attrs { + gl::enable_vertex_attrib_array(attr.location); + gl::vertex_attrib_pointer( + attr.location, + attr.components as i32, + gl::FLOAT, + false, + instance_layout.stride as i32, + attr.offset, + ); + gl::vertex_attrib_divisor(attr.location, 1); + } + if let Some(ebo) = indices { + gl::bind_buffer(gl::ELEMENT_ARRAY_BUFFER, ebo.0); + gl::draw_elements_instanced( + gl::TRIANGLES, + index_count as i32, + gl::UNSIGNED_INT, + 0, + instances as i32, + ); + gl::bind_buffer(gl::ELEMENT_ARRAY_BUFFER, 0); + } else { + gl::draw_arrays_instanced(gl::TRIANGLES, 0, index_count as i32, instances as i32); + } + for attr in instance_layout.attrs { + gl::vertex_attrib_divisor(attr.location, 0); + gl::disable_vertex_attrib_array(attr.location); + } + for attr in layout.attrs { + gl::disable_vertex_attrib_array(attr.location); + } + gl::bind_buffer(gl::ARRAY_BUFFER, 0); + } + + fn caps(&self) -> GpuCaps { + self.caps + } + + fn take_error(&mut self) -> Option<GpuError> { + self.error.take() + } + + fn create_texture_3d(&mut self, desc: &Texture3dDesc, data: Option<&[u8]>) -> TextureId { + let handle = gl::gen_texture(); + gl::bind_texture(gl::TEXTURE_3D, handle); + gl::pixel_storei(gl::UNPACK_ALIGNMENT, 1); + let min_filter = if desc.mips { + gl::LINEAR_MIPMAP_LINEAR + } else { + gl::LINEAR + }; + gl::tex_parameteri(gl::TEXTURE_3D, gl::TEXTURE_MIN_FILTER, min_filter); + gl::tex_parameteri(gl::TEXTURE_3D, gl::TEXTURE_MAG_FILTER, gl::LINEAR); + let wrap_xz = if desc.wrap_xz { + gl::REPEAT + } else { + gl::CLAMP_TO_EDGE + }; + gl::tex_parameteri(gl::TEXTURE_3D, gl::TEXTURE_WRAP_S, wrap_xz); + gl::tex_parameteri(gl::TEXTURE_3D, gl::TEXTURE_WRAP_T, gl::CLAMP_TO_EDGE); + gl::tex_parameteri(gl::TEXTURE_3D, gl::TEXTURE_WRAP_R, wrap_xz); + let (internal_format, format, ty) = match desc.format { + TextureFormat::Rgba8 => (gl::RGBA8 as i32, gl::RGBA, gl::UNSIGNED_BYTE), + TextureFormat::Srgba8 => (gl::SRGB8_ALPHA8 as i32, gl::RGBA, gl::UNSIGNED_BYTE), + TextureFormat::R8 => (gl::R8 as i32, gl::RED, gl::UNSIGNED_BYTE), + TextureFormat::Rg8 => (gl::RG8 as i32, gl::RG, gl::UNSIGNED_BYTE), + TextureFormat::Rgba16F => (gl::RGBA16F as i32, gl::RGBA, gl::HALF_FLOAT), + TextureFormat::Depth => ( + gl::DEPTH_COMPONENT24 as i32, + gl::DEPTH_COMPONENT, + gl::UNSIGNED_INT, + ), + }; + let s = desc.size as i32; + gl::tex_image_3d( + gl::TEXTURE_3D, + 0, + internal_format, + s, + s, + s, + 0, + format, + ty, + data, + ); + if desc.mips { + gl::generate_mipmap(gl::TEXTURE_3D); + } + gl::bind_texture(gl::TEXTURE_3D, 0); + TextureId(handle) + } + + fn update_texture_3d_region( + &mut self, + id: TextureId, + offset: [u32; 3], + extent: [u32; 3], + data: &[u8], + ) { + gl::bind_texture(gl::TEXTURE_3D, id.0); + gl::pixel_storei(gl::UNPACK_ALIGNMENT, 1); + gl::tex_sub_image_3d( + gl::TEXTURE_3D, + 0, + offset[0] as i32, + offset[1] as i32, + offset[2] as i32, + extent[0] as i32, + extent[1] as i32, + extent[2] as i32, + gl::RGBA, + gl::UNSIGNED_BYTE, + data, + ); + gl::bind_texture(gl::TEXTURE_3D, 0); + } + + fn generate_mipmaps_3d(&mut self, id: TextureId) { + gl::bind_texture(gl::TEXTURE_3D, id.0); + gl::generate_mipmap(gl::TEXTURE_3D); + gl::bind_texture(gl::TEXTURE_3D, 0); + } + + fn bind_texture_3d(&mut self, slot: u32, tex: TextureId) { + gl::active_texture(gl::TEXTURE0 + slot); + gl::bind_texture(gl::TEXTURE_3D, tex.0); + } + + fn create_render_target_mrt(&mut self, desc: &MrtDesc) -> RenderTargetId { + let fbo = gl::gen_framebuffer(); + gl::bind_framebuffer(gl::FRAMEBUFFER, fbo); + let mut colors: Vec<TextureId> = Vec::with_capacity(desc.colors.len()); + let mut attachments: Vec<u32> = Vec::with_capacity(desc.colors.len()); + for (i, fmt) in desc.colors.iter().enumerate() { + let handle = gl::gen_texture(); + gl::bind_texture(gl::TEXTURE_2D, handle); + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_MIN_FILTER, gl::LINEAR); + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_MAG_FILTER, gl::LINEAR); + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_S, gl::CLAMP_TO_EDGE); + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_T, gl::CLAMP_TO_EDGE); + let (internal_format, format, ty) = match fmt { + TextureFormat::Rgba8 => (gl::RGBA8 as i32, gl::RGBA, gl::UNSIGNED_BYTE), + TextureFormat::Srgba8 => (gl::SRGB8_ALPHA8 as i32, gl::RGBA, gl::UNSIGNED_BYTE), + TextureFormat::R8 => (gl::R8 as i32, gl::RED, gl::UNSIGNED_BYTE), + TextureFormat::Rg8 => (gl::RG8 as i32, gl::RG, gl::UNSIGNED_BYTE), + TextureFormat::Rgba16F => (gl::RGBA16F as i32, gl::RGBA, gl::HALF_FLOAT), + TextureFormat::Depth => ( + gl::DEPTH_COMPONENT24 as i32, + gl::DEPTH_COMPONENT, + gl::UNSIGNED_INT, + ), + }; + gl::tex_image_2d( + gl::TEXTURE_2D, + 0, + internal_format, + desc.width as i32, + desc.height as i32, + 0, + format, + ty, + None, + ); + let attach = gl::COLOR_ATTACHMENT0 + i as u32; + gl::framebuffer_texture_2d(gl::FRAMEBUFFER, attach, gl::TEXTURE_2D, handle, 0); + colors.push(TextureId(handle)); + attachments.push(attach); + } + let depth_tex = if desc.depth { + let handle = gl::gen_texture(); + gl::bind_texture(gl::TEXTURE_2D, handle); + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_MIN_FILTER, gl::NEAREST); + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_MAG_FILTER, gl::NEAREST); + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_S, gl::CLAMP_TO_EDGE); + gl::tex_parameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_T, gl::CLAMP_TO_EDGE); + gl::tex_image_2d( + gl::TEXTURE_2D, + 0, + gl::DEPTH_COMPONENT24 as i32, + desc.width as i32, + desc.height as i32, + 0, + gl::DEPTH_COMPONENT, + gl::UNSIGNED_INT, + None, + ); + gl::framebuffer_texture_2d( + gl::FRAMEBUFFER, + gl::DEPTH_ATTACHMENT, + gl::TEXTURE_2D, + handle, + 0, + ); + Some(TextureId(handle)) + } else { + None + }; + if attachments.is_empty() { + gl::disable_draw_buffer(); + } else { + gl::draw_buffers(&attachments); + } + let status = gl::check_framebuffer_status(gl::FRAMEBUFFER); + if status != gl::FRAMEBUFFER_COMPLETE { + successor_engine_core::rt::log::log1u( + "MRT framebuffer incomplete status: ", + status as u64, + ); + successor_engine_core::rt::log::log_str("\n"); + self.error.get_or_insert(GpuError::IncompleteFramebuffer); + } + gl::bind_framebuffer(gl::FRAMEBUFFER, 0); + let rt_idx = self.render_targets.len() as u32 + 1; + self.render_targets.push(RenderTarget { + fbo, + colors, + depth_tex, + width: desc.width, + height: desc.height, + }); + RenderTargetId(rt_idx) + } + + fn render_target_color_n(&self, rt: RenderTargetId, index: usize) -> Option<TextureId> { + let idx = rt.0 as usize - 1; + self.render_targets + .get(idx) + .and_then(|t| t.colors.get(index).copied()) + } + + fn delete_render_target(&mut self, rt: RenderTargetId) { + let idx = rt.0 as usize - 1; + if let Some(t) = self.render_targets.get_mut(idx) { + gl::delete_framebuffer(t.fbo); + for c in t.colors.drain(..) { + gl::delete_texture(c.0); + } + if let Some(d) = t.depth_tex.take() { + gl::delete_texture(d.0); + } + t.fbo = 0; + } + } + + fn finish(&mut self) { + gl::finish(); + } + + fn end_pass(&mut self) { + gl::bind_framebuffer(gl::FRAMEBUFFER, 0); + } +} diff --git a/client-rust/source/platform/src/lib.rs b/client-rust/source/platform/src/lib.rs new file mode 100644 index 00000000..283fc7c2 --- /dev/null +++ b/client-rust/source/platform/src/lib.rs @@ -0,0 +1,255 @@ +//! Successor Rust client — platform backend. + +#[cfg(not(target_arch = "wasm32"))] +pub mod native; + +#[cfg(target_arch = "wasm32")] +pub mod web; + +pub mod gl_gpu; + +// Common GPU re-exports +pub use gl_gpu::GlGpu; + +pub fn create_gpu() -> GlGpu { + GlGpu::new() +} + +/// Stable platform-facing contracts shared by native and WebGL2 shells. +/// Gameplay and rendering code must not access filesystem/DOM/GL directly. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AppMode { + Entry, + CharacterSelect, + Loading, + Connected, + Fatal, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SettingsScope { + Local, + Account, + Character, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AssetError { + MissingRequired(&'static str), + InvalidId, + Unreadable, +} + +/// Opaque handle for an in-flight asynchronous asset request. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct AssetHandle(pub u64); + +/// Poll result for an asynchronous asset request. +#[derive(Debug)] +pub enum AssetPoll { + Pending, + Ready(Vec<u8>), + Failed, +} + +/// Services required by the renderer-neutral application state machine. +pub trait Platform { + fn monotonic_ms(&self) -> u64; + fn logical_size(&self) -> (u32, u32); + fn read_asset(&self, stable_id: &str) -> Result<Vec<u8>, AssetError>; + /// Begin an asynchronous fetch of `stable_id`. `None` means the backend + /// has no async channel; callers then fall back to `read_asset`. + fn begin_asset(&mut self, _stable_id: &str) -> Option<AssetHandle> { + None + } + /// Poll a handle previously returned by `begin_asset`. + fn poll_asset(&mut self, _handle: AssetHandle) -> AssetPoll { + AssetPoll::Failed + } + fn load_settings(&self, scope: SettingsScope) -> Option<Vec<u8>>; + fn save_settings(&mut self, scope: SettingsScope, bytes: &[u8]) -> Result<(), String>; + fn report_fatal(&mut self, message: &str); +} + +#[cfg(not(target_arch = "wasm32"))] +pub struct NativePlatform { + pub asset_root: std::path::PathBuf, + pub settings_root: std::path::PathBuf, + /// Completed immediate reads awaiting `poll_asset`. + pub asset_requests: std::collections::HashMap<u64, Result<Vec<u8>, AssetError>>, + pub next_asset_handle: u64, +} + +#[cfg(not(target_arch = "wasm32"))] +impl Platform for NativePlatform { + fn monotonic_ms(&self) -> u64 { + now_ms().max(0.0) as u64 + } + fn logical_size(&self) -> (u32, u32) { + let (width, height) = framebuffer_size(); + (width.max(0) as u32, height.max(0) as u32) + } + fn read_asset(&self, stable_id: &str) -> Result<Vec<u8>, AssetError> { + if stable_id.is_empty() || stable_id.contains("..") || stable_id.starts_with('/') { + return Err(AssetError::InvalidId); + } + let relative = if stable_id.starts_with("assets/") { + std::path::PathBuf::from("client-3d/public").join(stable_id) + } else if let Some(path) = stable_id.strip_prefix("successor-slice/") { + std::path::PathBuf::from("client/public/successor-slice").join(path) + } else if let Some(path) = stable_id.strip_prefix("successor-audio/") { + std::path::PathBuf::from("client/public/successor-audio").join(path) + } else if let Some(path) = stable_id.strip_prefix("render/") { + std::path::PathBuf::from("client-3d/src/render").join(path) + } else { + return Err(AssetError::InvalidId); + }; + let path = self.asset_root.join(relative); + fs_read(path.to_str().ok_or(AssetError::InvalidId)?).map_err(|_| AssetError::Unreadable) + } + /// Native "async" is immediate: local disk reads are fast, so begin + /// resolves inline and the first poll delivers the bytes. + fn begin_asset(&mut self, stable_id: &str) -> Option<AssetHandle> { + self.next_asset_handle = self.next_asset_handle.max(1); + let handle = AssetHandle(self.next_asset_handle); + self.next_asset_handle += 1; + let result = self.read_asset(stable_id); + self.asset_requests.insert(handle.0, result); + Some(handle) + } + fn poll_asset(&mut self, handle: AssetHandle) -> AssetPoll { + match self.asset_requests.remove(&handle.0) { + Some(Ok(bytes)) => AssetPoll::Ready(bytes), + Some(Err(_)) | None => AssetPoll::Failed, + } + } + fn load_settings(&self, scope: SettingsScope) -> Option<Vec<u8>> { + let name = match scope { + SettingsScope::Local => "local", + SettingsScope::Account => "account", + SettingsScope::Character => "character", + }; + fs_read(self.settings_root.join(format!("{name}.json")).to_str()?).ok() + } + fn save_settings(&mut self, scope: SettingsScope, bytes: &[u8]) -> Result<(), String> { + let name = match scope { + SettingsScope::Local => "local", + SettingsScope::Account => "account", + SettingsScope::Character => "character", + }; + fs_write_atomic( + self.settings_root + .join(format!("{name}.json")) + .to_str() + .ok_or("invalid settings path")?, + bytes, + ) + } + fn report_fatal(&mut self, message: &str) { + eprintln!("fatal launch: {message}"); + } +} + +#[cfg(target_arch = "wasm32")] +pub struct WebPlatform; + +#[cfg(target_arch = "wasm32")] +impl Platform for WebPlatform { + fn monotonic_ms(&self) -> u64 { + now_ms().max(0.0) as u64 + } + fn logical_size(&self) -> (u32, u32) { + let (width, height) = framebuffer_size(); + (width.max(0) as u32, height.max(0) as u32) + } + fn read_asset(&self, stable_id: &str) -> Result<Vec<u8>, AssetError> { + http_get(stable_id).map_err(|_| AssetError::Unreadable) + } + fn begin_asset(&mut self, stable_id: &str) -> Option<AssetHandle> { + web::net::asset_begin(stable_id).map(|id| AssetHandle(id as u64)) + } + fn poll_asset(&mut self, handle: AssetHandle) -> AssetPoll { + web::net::asset_poll(handle.0 as u32) + } + fn load_settings(&self, _scope: SettingsScope) -> Option<Vec<u8>> { + None + } + fn save_settings(&mut self, _scope: SettingsScope, _bytes: &[u8]) -> Result<(), String> { + Ok(()) + } + fn report_fatal(&mut self, _message: &str) {} +} +#[cfg(not(target_arch = "wasm32"))] +pub use native::control::{ + configure as configure_control, publish_control_status, shutdown as shutdown_control, + take_ui_intents, ControlConfig, ControlMovementStatus, ControlStatus, ControlStatusV2, + ControlUiIntent, ControlWindowFrame, DEFAULT_CONTROL_PORT, +}; + +// target-specific re-exports of free-function surface +#[cfg(not(target_arch = "wasm32"))] +pub use native::window::{ + begin_frame, deinit, end_frame, framebuffer_size, gl_error, init, is_key_down, + mouse_button_down, mouse_position, now_ms, poll_scroll_delta, poll_text_input, + read_pixels_rgba, set_cursor_visible, should_quit, window_focused, +}; + +#[cfg(target_arch = "wasm32")] +pub use web::{ + begin_frame, deinit, end_frame, framebuffer_size, init, is_key_down, mouse_button_down, + mouse_position, now_ms, poll_scroll_delta, poll_text_input, read_pixels_rgba, + set_cursor_visible, should_quit, +}; + +// Network transport re-exports +#[cfg(not(target_arch = "wasm32"))] +pub use native::audio::{AudioOutput, FillFn}; +#[cfg(not(target_arch = "wasm32"))] +pub use native::fs::{fs_exists, fs_read, fs_write_atomic}; +#[cfg(not(target_arch = "wasm32"))] +pub use native::http::{http_get, http_post_json}; +#[cfg(not(target_arch = "wasm32"))] +pub use native::net::{ws_connect, ws_poll, ws_send, WsEvent, WsHandle}; + +#[cfg(target_arch = "wasm32")] +pub use web::net::{http_get, http_post_json}; +#[cfg(target_arch = "wasm32")] +pub use web::net::{ws_connect, ws_poll, ws_send, WsEvent, WsHandle}; + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + + #[test] + fn native_async_channel_resolves_immediately() { + let root = + std::env::temp_dir().join(format!("successor-platform-test-{}", std::process::id())); + let asset_dir = root.join("client-3d/public/assets/test"); + std::fs::create_dir_all(&asset_dir).expect("create asset dir"); + std::fs::write(asset_dir.join("probe.bin"), b"probe-bytes").expect("write asset"); + let mut platform = NativePlatform { + asset_root: root.clone(), + settings_root: root.join("settings"), + asset_requests: std::collections::HashMap::new(), + next_asset_handle: 0, + }; + let handle = platform + .begin_asset("assets/test/probe.bin") + .expect("native channel always begins"); + match platform.poll_asset(handle) { + AssetPoll::Ready(bytes) => assert_eq!(bytes, b"probe-bytes"), + _ => panic!("native first poll must deliver bytes"), + } + // Consumed handles fail closed; distinct ids get distinct handles. + assert!(matches!(platform.poll_asset(handle), AssetPoll::Failed)); + let second = platform + .begin_asset("assets/test/probe.bin") + .expect("re-begin"); + assert_ne!(handle, second); + let missing = platform + .begin_asset("assets/test/absent.bin") + .expect("begin succeeds"); + assert!(matches!(platform.poll_asset(missing), AssetPoll::Failed)); + std::fs::remove_dir_all(&root).ok(); + } +} diff --git a/client-rust/source/platform/src/native/audio.rs b/client-rust/source/platform/src/native/audio.rs new file mode 100644 index 00000000..47b8b399 --- /dev/null +++ b/client-rust/source/platform/src/native/audio.rs @@ -0,0 +1,206 @@ +//! Live audio output sink. +//! +//! On macOS this drives a CoreAudio `AudioQueue` output (interleaved f32 +//! stereo); the queue's render thread pulls PCM blocks from a caller-supplied +//! fill closure. Other native targets get a no-op sink so the shared audio +//! runtime still builds. The deterministic verification path is the WAV render +//! in `app::audio::wav` (same mixer blocks); audible device output is confirmed +//! interactively. + +#![allow(non_snake_case, non_upper_case_globals)] + +use std::os::raw::c_void; + +/// A boxed fill callback: writes `out.len()` interleaved-stereo f32 samples. +pub type FillFn = Box<dyn FnMut(&mut [f32]) + Send>; + +#[cfg(target_os = "macos")] +mod coreaudio { + use super::*; + + pub type AudioQueueRef = *mut c_void; + pub type AudioQueueBufferRef = *mut AudioQueueBuffer; + + #[repr(C)] + pub struct AudioStreamBasicDescription { + pub mSampleRate: f64, + pub mFormatID: u32, + pub mFormatFlags: u32, + pub mBytesPerPacket: u32, + pub mFramesPerPacket: u32, + pub mBytesPerFrame: u32, + pub mChannelsPerFrame: u32, + pub mBitsPerChannel: u32, + pub mReserved: u32, + } + + #[repr(C)] + pub struct AudioQueueBuffer { + pub mAudioDataBytesCapacity: u32, + pub mAudioData: *mut c_void, + pub mAudioDataByteSize: u32, + pub mUserData: *mut c_void, + pub mPacketDescriptionCapacity: u32, + pub mPacketDescriptions: *mut c_void, + pub mPacketDescriptionCount: u32, + } + + // 'lpcm' + pub const K_AUDIO_FORMAT_LINEAR_PCM: u32 = 0x6c70_636d; + pub const K_LINEAR_PCM_FLAG_IS_FLOAT: u32 = 1 << 0; + pub const K_LINEAR_PCM_FLAG_IS_PACKED: u32 = 1 << 3; + + #[link(name = "AudioToolbox", kind = "framework")] + extern "C" { + pub fn AudioQueueNewOutput( + inFormat: *const AudioStreamBasicDescription, + inCallbackProc: extern "C" fn(*mut c_void, AudioQueueRef, AudioQueueBufferRef), + inUserData: *mut c_void, + inCallbackRunLoop: *mut c_void, + inCallbackRunLoopMode: *const c_void, + inFlags: u32, + outAQ: *mut AudioQueueRef, + ) -> i32; + pub fn AudioQueueAllocateBuffer( + inAQ: AudioQueueRef, + inBufferByteSize: u32, + outBuffer: *mut AudioQueueBufferRef, + ) -> i32; + pub fn AudioQueueEnqueueBuffer( + inAQ: AudioQueueRef, + inBuffer: AudioQueueBufferRef, + inNumPacketDescs: u32, + inPacketDescs: *const c_void, + ) -> i32; + pub fn AudioQueueStart(inAQ: AudioQueueRef, inStartTime: *const c_void) -> i32; + pub fn AudioQueueStop(inAQ: AudioQueueRef, inImmediate: u8) -> i32; + pub fn AudioQueueDispose(inAQ: AudioQueueRef, inImmediate: u8) -> i32; + } +} + +/// State handed to the CoreAudio render callback (kept alive by `AudioOutput`). +struct FillState { + fill: FillFn, + scratch: Vec<f32>, +} + +#[cfg(target_os = "macos")] +extern "C" fn render_cb( + user: *mut c_void, + queue: coreaudio::AudioQueueRef, + buf: coreaudio::AudioQueueBufferRef, +) { + use coreaudio::*; + unsafe { + let state = &mut *(user as *mut FillState); + let cap = (*buf).mAudioDataBytesCapacity as usize; + let floats = cap / 4; + if state.scratch.len() < floats { + state.scratch.resize(floats, 0.0); + } + let slice = &mut state.scratch[..floats]; + (state.fill)(slice); + core::ptr::copy_nonoverlapping( + slice.as_ptr() as *const u8, + (*buf).mAudioData as *mut u8, + floats * 4, + ); + (*buf).mAudioDataByteSize = (floats * 4) as u32; + AudioQueueEnqueueBuffer(queue, buf, 0, core::ptr::null()); + } +} + +/// A live output device sink. Dropping it stops + disposes the queue. +pub struct AudioOutput { + #[cfg(target_os = "macos")] + queue: coreaudio::AudioQueueRef, + // Boxed so its address is stable for the C callback userdata. + _state: Box<FillState>, + started: bool, +} + +impl AudioOutput { + /// Open a stereo f32 output at `sample_rate` and start pulling `fill`. + /// Returns `None` if the OS has no output device / the queue fails. + pub fn start(sample_rate: u32, fill: FillFn) -> Option<Self> { + let mut state = Box::new(FillState { + fill, + scratch: Vec::new(), + }); + #[cfg(target_os = "macos")] + { + use coreaudio::*; + let fmt = AudioStreamBasicDescription { + mSampleRate: sample_rate as f64, + mFormatID: K_AUDIO_FORMAT_LINEAR_PCM, + mFormatFlags: K_LINEAR_PCM_FLAG_IS_FLOAT | K_LINEAR_PCM_FLAG_IS_PACKED, + mBytesPerPacket: 8, + mFramesPerPacket: 1, + mBytesPerFrame: 8, // 2 ch * f32 + mChannelsPerFrame: 2, + mBitsPerChannel: 32, + mReserved: 0, + }; + let mut queue: AudioQueueRef = core::ptr::null_mut(); + let user = &mut *state as *mut FillState as *mut c_void; + let rc = unsafe { + AudioQueueNewOutput( + &fmt, + render_cb, + user, + core::ptr::null_mut(), + core::ptr::null(), + 0, + &mut queue, + ) + }; + if rc != 0 || queue.is_null() { + return None; + } + // Three ~1024-frame buffers, primed and enqueued. + let bytes = 1024u32 * 8; + for _ in 0..3 { + let mut buf: AudioQueueBufferRef = core::ptr::null_mut(); + if unsafe { AudioQueueAllocateBuffer(queue, bytes, &mut buf) } != 0 { + unsafe { AudioQueueDispose(queue, 1) }; + return None; + } + render_cb(user, queue, buf); + } + if unsafe { AudioQueueStart(queue, core::ptr::null()) } != 0 { + unsafe { AudioQueueDispose(queue, 1) }; + return None; + } + Some(Self { + queue, + _state: state, + started: true, + }) + } + #[cfg(not(target_os = "macos"))] + { + let _ = sample_rate; + // No device backend for this target; a headless no-op sink. + Some(Self { + _state: state, + started: false, + }) + } + } + + pub fn is_active(&self) -> bool { + self.started + } +} + +impl Drop for AudioOutput { + fn drop(&mut self) { + #[cfg(target_os = "macos")] + unsafe { + if !self.queue.is_null() { + coreaudio::AudioQueueStop(self.queue, 1); + coreaudio::AudioQueueDispose(self.queue, 1); + } + } + } +} diff --git a/client-rust/source/platform/src/native/control.rs b/client-rust/source/platform/src/native/control.rs new file mode 100644 index 00000000..d6cf6d06 --- /dev/null +++ b/client-rust/source/platform/src/native/control.rs @@ -0,0 +1,1349 @@ +//! Developer-only loopback control, input recording, and deterministic replay. +//! +//! The server is disabled unless explicitly configured. While a client is +//! connected (or a remote key/button remains held), remote input replaces local +//! GLFW input. Requests and responses are newline-delimited so a tiny CLI can +//! drive the client from argv or stdin without an SDK. + +use parking_lot::Mutex; +use std::collections::VecDeque; +use std::fs::File; +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, SocketAddrV4, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::LazyLock; +use successor_engine_core::input::Key; + +pub const DEFAULT_CONTROL_PORT: u16 = 47_778; +pub const RECORDING_HEADER: &str = "successor.input.v1"; +const MAX_REQUEST_BUFFER: usize = 16 * 1024; +const MOUSE_BUTTON_COUNT: usize = 3; + +static CONFIGURED: AtomicBool = AtomicBool::new(false); +static CONTROL: LazyLock<Mutex<ControlState>> = LazyLock::new(|| Mutex::new(ControlState::new())); + +#[derive(Clone, Debug, Default)] +pub struct ControlConfig { + pub port: Option<u16>, + pub record_path: Option<PathBuf>, + pub replay_path: Option<PathBuf>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ControlStatus { + pub listen_port: Option<u16>, + pub replaying: bool, + pub recording: bool, +} + +/// Secret-free status payload contract. Values are owned so the status can be +/// published from the connected frame without borrowing runtime state. +#[derive(Clone, Debug, Default)] +pub struct ControlStatusV2 { + pub frame: u64, + pub framebuffer: Option<(u32, u32)>, + pub app_mode: Option<String>, + pub game_connection: String, + pub chat_connection: String, + pub shard: Option<String>, + pub tick: Option<u64>, + pub area: Option<String>, + pub source_hashes: Vec<String>, + pub player_actor_id: Option<String>, + pub player_position: Option<(f32, f32)>, + pub life: Option<String>, + pub selection: Option<String>, + pub windows: Vec<String>, + pub focused_window: Option<String>, + pub pending_command_kinds: Vec<String>, + pub last_receipt: Option<String>, + pub renderer_degradation_ids: Vec<String>, + /// Every registered workspace frame with its live geometry. UI journeys + /// assert move, resize, and layout persistence against this instead of + /// reading pixels. + pub window_frames: Vec<ControlWindowFrame>, + pub movement: Option<ControlMovementStatus>, +} + +/// One workspace frame as the control protocol reports it. +#[derive(Clone, Debug, Default)] +pub struct ControlWindowFrame { + pub id: String, + pub rect: [f32; 4], + pub open: bool, + pub iconified: bool, + pub interactive: bool, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct ControlMovementStatus { + pub authoritative: (f32, f32), + pub predicted: (f32, f32), + pub rendered: (f32, f32), + pub correction_cells: f32, + pub intent: (i32, i32, bool), + pub applied_command_id: u64, + pub blocker_count: usize, + pub presented_ground_y: f32, + pub sampled_ground_y: f32, + pub frame_dt_ms: f32, + pub last_change_ms: u64, + pub last_send_ms: u64, + pub next_send_ms: u64, + pub sampled_at_ms: u64, + pub stop_retry_until_ms: u64, +} +#[derive(Clone, Debug)] +pub struct NativeInputSnapshot { + pub keys: [bool; Key::COUNT], + pub mouse_position: (f32, f32), + pub mouse_buttons: [bool; MOUSE_BUTTON_COUNT], + pub text: Vec<char>, + pub scroll: (f32, f32), +} + +impl Default for NativeInputSnapshot { + fn default() -> Self { + Self { + keys: [false; Key::COUNT], + mouse_position: (0.0, 0.0), + mouse_buttons: [false; MOUSE_BUTTON_COUNT], + text: Vec::new(), + scroll: (0.0, 0.0), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +enum Command { + Key { key: Key, action: KeyAction }, + MouseMove { relative: bool, x: f32, y: f32 }, + MouseButton { button: usize, pressed: bool }, + Text(String), + Scroll(f32, f32), + Screenshot(PathBuf), + RecordStart(PathBuf), + RecordStop, + Status, + Quit, + Ui(ControlUiIntent), +} + +/// A developer-only request to put the UI into a specific state. +/// +/// Capturing every pane, or every pane under every theme, is otherwise a +/// clicking exercise against surfaces that only open from a terminal, a +/// target, or an item. These are inspection intents, not gameplay: the host +/// drains them each frame and applies them exactly as the equivalent player +/// action would. +#[derive(Clone, Debug, PartialEq)] +pub enum ControlUiIntent { + /// `None` toggles. + Window { + id: String, + open: Option<bool>, + }, + Theme(usize), + Opacity { + hud: bool, + value: f32, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum KeyAction { + Down, + Up, + Tap, +} + +#[derive(Clone, Debug)] +struct ReplayEvent { + frame: u64, + + command: Command, +} + +struct Recorder { + file: File, + start_frame: u64, + native_keys: [bool; Key::COUNT], + native_mouse_position: (f32, f32), + native_mouse_buttons: [bool; MOUSE_BUTTON_COUNT], +} + +struct PendingWrite { + bytes: Vec<u8>, + sent: usize, +} +#[derive(Clone, Debug)] +pub struct ScreenshotRequest { + pub sequence: u64, + pub path: PathBuf, +} + +struct ControlState { + listener: Option<TcpListener>, + listen_port: Option<u16>, + client: Option<TcpStream>, + receive: Vec<u8>, + writes: VecDeque<PendingWrite>, + frame: u64, + latest_status: ControlStatusV2, + remote_keys: [bool; Key::COUNT], + remote_mouse_position: (f32, f32), + remote_mouse_buttons: [bool; MOUSE_BUTTON_COUNT], + release_frame: [u64; Key::COUNT], + native: NativeInputSnapshot, + text: VecDeque<char>, + scroll: (f32, f32), + screenshot_requests: VecDeque<ScreenshotRequest>, + recorder: Option<Recorder>, + replay: Vec<ReplayEvent>, + replay_index: usize, + replaying: bool, + quit_requested: bool, + /// Inspection intents awaiting the host's next frame. + ui_intents: Vec<ControlUiIntent>, +} + +impl ControlState { + fn new() -> Self { + Self { + listener: None, + listen_port: None, + client: None, + receive: Vec::with_capacity(4096), + writes: VecDeque::new(), + frame: 0, + latest_status: ControlStatusV2::default(), + remote_keys: [false; Key::COUNT], + remote_mouse_position: (0.0, 0.0), + remote_mouse_buttons: [false; MOUSE_BUTTON_COUNT], + release_frame: [u64::MAX; Key::COUNT], + native: NativeInputSnapshot::default(), + text: VecDeque::new(), + scroll: (0.0, 0.0), + screenshot_requests: VecDeque::new(), + recorder: None, + replay: Vec::new(), + replay_index: 0, + replaying: false, + quit_requested: false, + ui_intents: Vec::new(), + } + } + + fn override_active(&self) -> bool { + self.replaying + || self.client.is_some() + || self.remote_keys.iter().any(|pressed| *pressed) + || self.remote_mouse_buttons.iter().any(|pressed| *pressed) + } + + fn start_recording(&mut self, path: &Path) -> Result<(), String> { + if self.recorder.is_some() { + return Err("input recording is already active".into()); + } + let mut file = File::create(path) + .map_err(|error| format!("cannot create recording {}: {error}", path.display()))?; + writeln!(file, "{RECORDING_HEADER}") + .map_err(|error| format!("cannot write recording {}: {error}", path.display()))?; + self.recorder = Some(Recorder { + file, + start_frame: self.frame, + native_keys: [false; Key::COUNT], + native_mouse_position: (0.0, 0.0), + native_mouse_buttons: [false; MOUSE_BUTTON_COUNT], + }); + Ok(()) + } + + fn stop_recording(&mut self) -> Result<(), String> { + let Some(mut recorder) = self.recorder.take() else { + return Err("input recording is not active".into()); + }; + recorder + .file + .flush() + .map_err(|error| format!("cannot flush input recording: {error}")) + } + + fn record(&mut self, command: &str) { + let Some(recorder) = self.recorder.as_mut() else { + return; + }; + let relative_frame = self.frame.saturating_sub(recorder.start_frame); + let _ = writeln!(recorder.file, "frame\t{relative_frame}\t{command}"); + } + + fn record_native_changes(&mut self) { + if self.recorder.is_none() || self.override_active() { + return; + } + + let mut commands = Vec::new(); + { + let recorder = self.recorder.as_mut().expect("checked above"); + for index in 0..Key::COUNT { + if recorder.native_keys[index] != self.native.keys[index] { + recorder.native_keys[index] = self.native.keys[index]; + let key = Key::from_u16(index as u16).expect("bounded key index"); + commands.push(format!( + "key {} {}", + if self.native.keys[index] { + "down" + } else { + "up" + }, + key_name(key) + )); + } + } + if recorder.native_mouse_position != self.native.mouse_position { + recorder.native_mouse_position = self.native.mouse_position; + commands.push(format!( + "mouse move abs {} {}", + format_float(self.native.mouse_position.0), + format_float(self.native.mouse_position.1) + )); + } + for index in 0..MOUSE_BUTTON_COUNT { + if recorder.native_mouse_buttons[index] != self.native.mouse_buttons[index] { + recorder.native_mouse_buttons[index] = self.native.mouse_buttons[index]; + commands.push(format!( + "mouse {} {}", + if self.native.mouse_buttons[index] { + "down" + } else { + "up" + }, + button_name(index) + )); + } + } + } + for command in commands { + self.record(&command); + } + let text: String = self.native.text.iter().collect(); + if !text.is_empty() { + self.record(&format!("text {text}")); + } + if self.native.scroll != (0.0, 0.0) { + self.record(&format!( + "scroll {} {}", + format_float(self.native.scroll.0), + format_float(self.native.scroll.1) + )); + } + } + + fn apply_releases(&mut self) { + for index in 0..Key::COUNT { + if self.release_frame[index] <= self.frame { + self.release_frame[index] = u64::MAX; + if self.remote_keys[index] { + self.remote_keys[index] = false; + let key = Key::from_u16(index as u16).expect("bounded key index"); + self.record(&format!("key up {}", key_name(key))); + } + } + } + } + + fn apply_replay(&mut self) { + while self.replay_index < self.replay.len() + && self.replay[self.replay_index].frame <= self.frame + { + let command = self.replay[self.replay_index].command.clone(); + self.replay_index += 1; + let _ = self.apply_input(command, false); + } + } + + fn apply_input(&mut self, command: Command, record: bool) -> Result<(), String> { + match command { + Command::Key { key, action } => { + let index = key as usize; + match action { + KeyAction::Down => { + self.remote_keys[index] = true; + self.release_frame[index] = u64::MAX; + if record { + self.record(&format!("key down {}", key_name(key))); + } + } + KeyAction::Up => { + self.remote_keys[index] = false; + self.release_frame[index] = u64::MAX; + if record { + self.record(&format!("key up {}", key_name(key))); + } + } + KeyAction::Tap => { + self.remote_keys[index] = true; + self.release_frame[index] = self.frame.saturating_add(1); + if record { + self.record(&format!("key down {}", key_name(key))); + } + } + } + } + Command::MouseMove { relative, x, y } => { + if relative { + self.remote_mouse_position.0 += x; + self.remote_mouse_position.1 += y; + } else { + self.remote_mouse_position = (x, y); + } + if record { + self.record(&format!( + "mouse move abs {} {}", + format_float(self.remote_mouse_position.0), + format_float(self.remote_mouse_position.1) + )); + } + } + Command::MouseButton { button, pressed } => { + self.remote_mouse_buttons[button] = pressed; + if record { + self.record(&format!( + "mouse {} {}", + if pressed { "down" } else { "up" }, + button_name(button) + )); + } + } + Command::Text(value) => { + self.text.extend(value.chars()); + if record { + self.record(&format!("text {value}")); + } + } + Command::Scroll(x, y) => { + self.scroll.0 += x; + self.scroll.1 += y; + if record { + self.record(&format!("scroll {} {}", format_float(x), format_float(y))); + } + } + _ => return Err("command is not an input command".into()), + } + Ok(()) + } + + fn handle_request(&mut self, sequence: u64, command: Command) { + if self.replaying + && matches!( + command, + Command::Key { .. } + | Command::MouseMove { .. } + | Command::MouseButton { .. } + | Command::Text(_) + | Command::Scroll(_, _) + ) + { + self.queue_error(sequence, "live input is disabled during replay"); + return; + } + + match command { + input @ (Command::Key { .. } + | Command::MouseMove { .. } + | Command::MouseButton { .. } + | Command::Text(_) + | Command::Scroll(_, _)) => match self.apply_input(input, true) { + Ok(()) => self.queue_ok(sequence, ""), + Err(error) => self.queue_error(sequence, &error), + }, + Command::Screenshot(path) => { + self.screenshot_requests + .push_back(ScreenshotRequest { sequence, path }); + } + Command::RecordStart(path) => match self.start_recording(&path) { + Ok(()) => self.queue_ok(sequence, "\"recording\":true"), + Err(error) => self.queue_error(sequence, &error), + }, + Command::RecordStop => match self.stop_recording() { + Ok(()) => self.queue_ok(sequence, "\"recording\":false"), + Err(error) => self.queue_error(sequence, &error), + }, + Command::Status => { + let details = self.status_details(); + self.queue_ok(sequence, &details); + } + Command::Quit => { + self.quit_requested = true; + self.queue_ok(sequence, "\"quitting\":true"); + } + Command::Ui(intent) => { + self.ui_intents.push(intent); + self.queue_ok(sequence, ""); + } + } + } + + fn queue_ok(&mut self, sequence: u64, details: &str) { + let suffix = if details.is_empty() { + String::new() + } else { + format!(",{details}") + }; + self.queue_response(format!("{{\"ok\":true,\"sequence\":{sequence}{suffix}}}\n")); + } + + fn queue_error(&mut self, sequence: u64, error: &str) { + self.queue_response(format!( + "{{\"ok\":false,\"sequence\":{sequence},\"error\":\"{}\"}}\n", + json_escape(error) + )); + } + fn status_details(&self) -> String { + let s = &self.latest_status; + let framebuffer = s + .framebuffer + .map(|(w, h)| format!("[{w},{h}]")) + .unwrap_or_else(|| "null".into()); + let position = s + .player_position + .map(|(x, y)| format!("[{},{}]", format_float(x), format_float(y))) + .unwrap_or_else(|| "null".into()); + let app_mode = s + .app_mode + .as_deref() + .map(json_string) + .unwrap_or_else(|| "null".into()); + let shard = s + .shard + .as_deref() + .map(json_string) + .unwrap_or_else(|| "null".into()); + let area = s + .area + .as_deref() + .map(json_string) + .unwrap_or_else(|| "null".into()); + let actor = s + .player_actor_id + .as_deref() + .map(json_string) + .unwrap_or_else(|| "null".into()); + let life = s + .life + .as_deref() + .map(json_string) + .unwrap_or_else(|| "null".into()); + let selection = s + .selection + .as_deref() + .map(json_string) + .unwrap_or_else(|| "null".into()); + let focused = s + .focused_window + .as_deref() + .map(json_string) + .unwrap_or_else(|| "null".into()); + let receipt = s + .last_receipt + .as_deref() + .map(json_string) + .unwrap_or_else(|| "null".into()); + let movement = s.movement.map_or_else( + || "null".to_owned(), + |movement| { + format!( + "{{\"authoritative\":[{},{}],\"predicted\":[{},{}],\ + \"rendered\":[{},{}],\"correction_cells\":{},\ + \"intent\":[{},{},{}],\"applied_command_id\":{},\ + \"blocker_count\":{},\"presented_ground_y\":{},\ + \"sampled_ground_y\":{},\"frame_dt_ms\":{},\ + \"last_change_ms\":{},\"last_send_ms\":{},\ + \"next_send_ms\":{},\"sampled_at_ms\":{},\"stop_retry_until_ms\":{}}}", + format_float(movement.authoritative.0), + format_float(movement.authoritative.1), + format_float(movement.predicted.0), + format_float(movement.predicted.1), + format_float(movement.rendered.0), + format_float(movement.rendered.1), + format_float(movement.correction_cells), + movement.intent.0, + movement.intent.1, + movement.intent.2, + movement.applied_command_id, + movement.blocker_count, + format_float(movement.presented_ground_y), + format_float(movement.sampled_ground_y), + format_float(movement.frame_dt_ms), + movement.last_change_ms, + movement.last_send_ms, + movement.next_send_ms, + movement.sampled_at_ms, + movement.stop_retry_until_ms, + ) + }, + ); + let mut frames = String::from("["); + for (index, frame) in s.window_frames.iter().enumerate() { + if index > 0 { + frames.push(','); + } + frames.push_str(&format!( + "{{\"id\":{},\"rect\":[{},{},{},{}],\"open\":{},\"iconified\":{},\"interactive\":{}}}", + json_string(&frame.id), + frame.rect[0], + frame.rect[1], + frame.rect[2], + frame.rect[3], + frame.open, + frame.iconified, + frame.interactive + )); + } + frames.push(']'); + format!( + "\"schema\":\"successor.control.status.v2\",\"frame\":{},\"framebuffer\":{},\ + \"app_mode\":{},\"game_connection\":{},\"chat_connection\":{},\"shard\":{},\ + \"tick\":{},\"area\":{},\"source_hashes\":{},\"player_actor_id\":{},\ + \"player_position\":{},\"movement\":{},\"life\":{},\"selection\":{},\"windows\":{},\ + \"window_frames\":{},\ + \"focused_window\":{},\"pending_command_kinds\":{},\"last_receipt\":{},\ + \"recording\":{},\"replaying\":{},\"renderer_degradation_ids\":{},\ + \"input_override\":{},\"listen_port\":{}", + s.frame, + framebuffer, + app_mode, + json_string(&s.game_connection), + json_string(&s.chat_connection), + shard, + s.tick + .map(|v| v.to_string()) + .unwrap_or_else(|| "null".into()), + area, + json_list(&s.source_hashes), + actor, + position, + movement, + life, + selection, + json_list(&s.windows), + frames, + focused, + json_list(&s.pending_command_kinds), + receipt, + self.recorder.is_some(), + self.replaying, + json_list(&s.renderer_degradation_ids), + self.override_active(), + self.listen_port + .map(|p| p.to_string()) + .unwrap_or_else(|| "null".into()) + ) + } + + fn queue_response(&mut self, response: String) { + self.writes.push_back(PendingWrite { + bytes: response.into_bytes(), + sent: 0, + }); + } + + fn accept_client(&mut self) { + if self.client.is_some() { + return; + } + let accepted = self + .listener + .as_ref() + .and_then(|listener| match listener.accept() { + Ok((stream, _)) => Some(Ok(stream)), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => None, + Err(error) => Some(Err(error)), + }); + if let Some(Ok(stream)) = accepted { + if stream.set_nonblocking(true).is_ok() { + self.client = Some(stream); + self.receive.clear(); + self.writes.clear(); + self.remote_mouse_position = self.native.mouse_position; + } + } + } + + fn read_client(&mut self) { + let mut disconnected = false; + let mut bytes = [0u8; 4096]; + if let Some(stream) = self.client.as_mut() { + loop { + match stream.read(&mut bytes) { + Ok(0) => { + disconnected = true; + break; + } + Ok(count) => { + if self.receive.len() + count > MAX_REQUEST_BUFFER { + disconnected = true; + break; + } + self.receive.extend_from_slice(&bytes[..count]); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => break, + Err(_) => { + disconnected = true; + break; + } + } + } + } + + while let Some(newline) = self.receive.iter().position(|byte| *byte == b'\n') { + let line: Vec<u8> = self.receive.drain(..=newline).collect(); + let line = match std::str::from_utf8(&line[..line.len().saturating_sub(1)]) { + Ok(line) => line.trim_end_matches('\r'), + Err(_) => { + self.queue_error(0, "request is not UTF-8"); + continue; + } + }; + match parse_request(line) { + Ok((sequence, command)) => self.handle_request(sequence, command), + Err((sequence, error)) => self.queue_error(sequence, &error), + } + } + + if disconnected { + self.client = None; + self.receive.clear(); + self.writes.clear(); + } + } + + fn flush_client(&mut self) { + let Some(stream) = self.client.as_mut() else { + return; + }; + while let Some(write) = self.writes.front_mut() { + match stream.write(&write.bytes[write.sent..]) { + Ok(0) => break, + Ok(count) => { + write.sent += count; + if write.sent == write.bytes.len() { + self.writes.pop_front(); + } + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => break, + Err(_) => { + self.client = None; + self.writes.clear(); + break; + } + } + } + } +} + +pub fn configure(config: ControlConfig) -> Result<ControlStatus, String> { + if config.record_path.is_some() && config.replay_path.is_some() { + return Err("--record-input and --replay-input cannot be used together".into()); + } + + let mut next = ControlState::new(); + if let Some(path) = config.replay_path.as_deref() { + next.replay = load_replay(path)?; + next.replaying = true; + } + if let Some(port) = config.port { + let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, port)) + .map_err(|error| format!("cannot bind control server on 127.0.0.1:{port}: {error}"))?; + listener + .set_nonblocking(true) + .map_err(|error| format!("cannot make control server nonblocking: {error}"))?; + next.listen_port = Some( + listener + .local_addr() + .map_err(|error| format!("cannot read control server address: {error}"))? + .port(), + ); + next.listener = Some(listener); + } + if let Some(path) = config.record_path.as_deref() { + next.start_recording(path)?; + } + + let status = ControlStatus { + listen_port: next.listen_port, + replaying: next.replaying, + recording: next.recorder.is_some(), + }; + *CONTROL.lock() = next; + CONFIGURED.store(true, Ordering::Release); + Ok(status) +} + +pub fn shutdown() { + if !is_configured() { + return; + } + let mut state = CONTROL.lock(); + if let Some(recorder) = state.recorder.as_mut() { + let _ = recorder.file.flush(); + } + *state = ControlState::new(); + CONFIGURED.store(false, Ordering::Release); +} + +pub fn is_configured() -> bool { + CONFIGURED.load(Ordering::Acquire) +} + +/// Drain inspection intents queued since the last frame. +pub fn take_ui_intents() -> Vec<ControlUiIntent> { + core::mem::take(&mut CONTROL.lock().ui_intents) +} +/// Publish the latest connected runtime status as one atomic owned snapshot. +pub fn publish_control_status(status: ControlStatusV2) { + CONTROL.lock().latest_status = status; +} + +pub fn begin_frame(snapshot: NativeInputSnapshot) { + if !is_configured() { + return; + } + let mut state = CONTROL.lock(); + state.native = snapshot; + state.text.clear(); + state.scroll = (0.0, 0.0); + state.apply_releases(); + if state.replaying { + state.apply_replay(); + } + state.accept_client(); + state.read_client(); + + if !state.override_active() { + let text = state.native.text.clone(); + state.text.extend(text); + state.scroll = state.native.scroll; + state.record_native_changes(); + } + state.flush_client(); + state.frame = state.frame.saturating_add(1); +} + +pub fn flush() { + if is_configured() { + CONTROL.lock().flush_client(); + } +} + +pub fn key_down(key: Key) -> Option<bool> { + if !is_configured() { + return None; + } + let state = CONTROL.lock(); + Some(if state.override_active() { + state.remote_keys[key as usize] + } else { + state.native.keys[key as usize] + }) +} + +pub fn mouse_position() -> Option<(f32, f32)> { + if !is_configured() { + return None; + } + let state = CONTROL.lock(); + Some(if state.override_active() { + state.remote_mouse_position + } else { + state.native.mouse_position + }) +} + +pub fn mouse_button_down(button: usize) -> Option<bool> { + if !is_configured() || button >= MOUSE_BUTTON_COUNT { + return None; + } + let state = CONTROL.lock(); + Some(if state.override_active() { + state.remote_mouse_buttons[button] + } else { + state.native.mouse_buttons[button] + }) +} + +pub fn poll_text_input() -> Option<char> { + if !is_configured() { + return None; + } + CONTROL.lock().text.pop_front() +} + +pub fn poll_scroll_delta() -> Option<(f32, f32)> { + if !is_configured() { + return None; + } + let mut state = CONTROL.lock(); + let value = state.scroll; + state.scroll = (0.0, 0.0); + (value != (0.0, 0.0)).then_some(value) +} + +pub fn quit_requested() -> bool { + is_configured() && CONTROL.lock().quit_requested +} + +pub fn take_screenshot_request() -> Option<ScreenshotRequest> { + if !is_configured() { + return None; + } + CONTROL.lock().screenshot_requests.pop_front() +} + +pub fn finish_screenshot(request: ScreenshotRequest, result: Result<(u32, u32), String>) { + if !is_configured() { + return; + } + let mut state = CONTROL.lock(); + match result { + Ok((width, height)) => { + let details = format!( + "\"screenshot\":{{\"path\":\"{}\",\"width\":{width},\"height\":{height}}}", + json_escape(&request.path.display().to_string()) + ); + state.queue_ok(request.sequence, &details); + } + Err(error) => state.queue_error(request.sequence, &error), + } + state.flush_client(); +} + +pub fn write_bmp(path: &Path, rgba: &[u8], width: u32, height: u32) -> Result<(), String> { + let row_bytes = (width as usize) + .checked_mul(3) + .ok_or_else(|| "screenshot width overflow".to_string())?; + let padded_row_bytes = (row_bytes + 3) & !3; + let pixel_bytes = padded_row_bytes + .checked_mul(height as usize) + .ok_or_else(|| "screenshot height overflow".to_string())?; + let expected_rgba = (width as usize) + .checked_mul(height as usize) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| "screenshot dimensions overflow".to_string())?; + if rgba.len() != expected_rgba { + return Err(format!( + "screenshot pixel size mismatch: expected {expected_rgba}, got {}", + rgba.len() + )); + } + + let file_size = 54usize + .checked_add(pixel_bytes) + .ok_or_else(|| "screenshot file size overflow".to_string())?; + let mut file = File::create(path) + .map_err(|error| format!("cannot create screenshot {}: {error}", path.display()))?; + let mut header = [0u8; 54]; + header[0..2].copy_from_slice(b"BM"); + header[2..6].copy_from_slice(&(file_size as u32).to_le_bytes()); + header[10..14].copy_from_slice(&54u32.to_le_bytes()); + header[14..18].copy_from_slice(&40u32.to_le_bytes()); + header[18..22].copy_from_slice(&width.to_le_bytes()); + header[22..26].copy_from_slice(&height.to_le_bytes()); + header[26..28].copy_from_slice(&1u16.to_le_bytes()); + header[28..30].copy_from_slice(&24u16.to_le_bytes()); + header[34..38].copy_from_slice(&(pixel_bytes as u32).to_le_bytes()); + file.write_all(&header) + .map_err(|error| format!("cannot write screenshot header: {error}"))?; + + let padding = [0u8; 3]; + for row in 0..height as usize { + let row_start = row * width as usize * 4; + for column in 0..width as usize { + let pixel = row_start + column * 4; + file.write_all(&[rgba[pixel + 2], rgba[pixel + 1], rgba[pixel]]) + .map_err(|error| format!("cannot write screenshot pixels: {error}"))?; + } + file.write_all(&padding[..padded_row_bytes - row_bytes]) + .map_err(|error| format!("cannot write screenshot padding: {error}"))?; + } + Ok(()) +} + +fn parse_request(line: &str) -> Result<(u64, Command), (u64, String)> { + let (sequence, command_text) = if let Some((prefix, rest)) = line.split_once('\t') { + match prefix.parse::<u64>() { + Ok(sequence) => (sequence, rest), + Err(_) => (0, line), + } + } else { + (0, line) + }; + parse_command(command_text) + .map(|command| (sequence, command)) + .map_err(|error| (sequence, error)) +} + +fn parse_command(line: &str) -> Result<Command, String> { + let line = line.trim(); + if line.is_empty() { + return Err("empty command".into()); + } + if let Some(text) = line.strip_prefix("text ") { + if text.is_empty() { + return Err("text command requires content".into()); + } + return Ok(Command::Text(text.to_string())); + } + if let Some(path) = line.strip_prefix("screenshot ") { + return nonempty_path(path).map(Command::Screenshot); + } + if let Some(path) = line.strip_prefix("record start ") { + return nonempty_path(path).map(Command::RecordStart); + } + + let words: Vec<&str> = line.split_whitespace().collect(); + match words.as_slice() { + ["key", action, key] => Ok(Command::Key { + key: parse_key(key)?, + action: match *action { + "down" => KeyAction::Down, + "up" => KeyAction::Up, + "tap" => KeyAction::Tap, + _ => return Err("key action must be down, up, or tap".into()), + }, + }), + ["mouse", "move", mode, x, y] => Ok(Command::MouseMove { + relative: match *mode { + "abs" | "absolute" => false, + "rel" | "relative" => true, + _ => return Err("mouse move mode must be abs or rel".into()), + }, + x: parse_finite(x, "mouse x")?, + y: parse_finite(y, "mouse y")?, + }), + ["mouse", action, button] => Ok(Command::MouseButton { + button: parse_button(button)?, + pressed: match *action { + "down" => true, + "up" => false, + _ => return Err("mouse action must be down or up".into()), + }, + }), + ["scroll", x, y] => Ok(Command::Scroll( + parse_finite(x, "scroll x")?, + parse_finite(y, "scroll y")?, + )), + ["record", "stop"] => Ok(Command::RecordStop), + ["status"] => Ok(Command::Status), + ["ui", "window", action, id] => Ok(Command::Ui(ControlUiIntent::Window { + id: (*id).to_string(), + open: match *action { + "open" => Some(true), + "close" => Some(false), + "toggle" => None, + _ => return Err("ui window action must be open, close, or toggle".into()), + }, + })), + ["ui", "theme", index] => index + .parse::<usize>() + .map(|value| Command::Ui(ControlUiIntent::Theme(value))) + .map_err(|_| "ui theme requires an index".to_string()), + ["ui", "opacity", surface, value] => Ok(Command::Ui(ControlUiIntent::Opacity { + hud: match *surface { + "hud" => true, + "window" => false, + _ => return Err("ui opacity surface must be window or hud".into()), + }, + value: parse_finite(value, "opacity")?, + })), + ["quit"] => Ok(Command::Quit), + _ => Err("unknown command".into()), + } +} + +fn nonempty_path(value: &str) -> Result<PathBuf, String> { + let value = value.trim(); + if value.is_empty() { + Err("command requires a path".into()) + } else { + Ok(PathBuf::from(value)) + } +} + +fn parse_finite(value: &str, name: &str) -> Result<f32, String> { + let parsed = value + .parse::<f32>() + .map_err(|_| format!("{name} must be a number"))?; + if parsed.is_finite() { + Ok(parsed) + } else { + Err(format!("{name} must be finite")) + } +} + +fn parse_key(value: &str) -> Result<Key, String> { + match value.to_ascii_lowercase().as_str() { + "w" => Ok(Key::W), + "a" => Ok(Key::A), + "s" => Ok(Key::S), + "d" => Ok(Key::D), + "up" => Ok(Key::Up), + "down" => Ok(Key::Down), + "left" => Ok(Key::Left), + "right" => Ok(Key::Right), + "space" => Ok(Key::Space), + "enter" | "return" => Ok(Key::Enter), + "escape" | "esc" => Ok(Key::Escape), + "backspace" => Ok(Key::Backspace), + "leftshift" | "shift" => Ok(Key::LeftShift), + "backquote" | "grave" | "`" => Ok(Key::Backquote), + "r" => Ok(Key::R), + "f" => Ok(Key::F), + "i" => Ok(Key::I), + "c" => Ok(Key::C), + "semicolon" | ";" => Ok(Key::Semicolon), + "o" => Ok(Key::O), + "tab" => Ok(Key::Tab), + "v" => Ok(Key::V), + "x" => Ok(Key::X), + "n" => Ok(Key::N), + "0" | "digit0" => Ok(Key::Digit0), + "1" | "digit1" => Ok(Key::Digit1), + "2" | "digit2" => Ok(Key::Digit2), + "3" | "digit3" => Ok(Key::Digit3), + "4" | "digit4" => Ok(Key::Digit4), + "5" | "digit5" => Ok(Key::Digit5), + "6" | "digit6" => Ok(Key::Digit6), + "7" | "digit7" => Ok(Key::Digit7), + "8" | "digit8" => Ok(Key::Digit8), + "9" | "digit9" => Ok(Key::Digit9), + "p" => Ok(Key::P), + "k" => Ok(Key::K), + "b" => Ok(Key::B), + "m" => Ok(Key::M), + "g" => Ok(Key::G), + _ => Err(format!("unknown key: {value}")), + } +} + +fn key_name(key: Key) -> &'static str { + match key { + Key::W => "w", + Key::A => "a", + Key::S => "s", + Key::D => "d", + Key::Up => "up", + Key::Down => "down", + Key::Left => "left", + Key::Right => "right", + Key::Space => "space", + Key::Enter => "enter", + Key::Escape => "escape", + Key::Backspace => "backspace", + Key::LeftShift => "leftshift", + Key::Backquote => "backquote", + Key::R => "r", + Key::F => "f", + Key::I => "i", + Key::C => "c", + Key::Semicolon => "semicolon", + Key::O => "o", + Key::Tab => "tab", + Key::V => "v", + Key::X => "x", + Key::N => "n", + Key::Digit0 => "0", + Key::Digit1 => "1", + Key::Digit2 => "2", + Key::Digit3 => "3", + Key::Digit4 => "4", + Key::Digit5 => "5", + Key::Digit6 => "6", + Key::Digit7 => "7", + Key::Digit8 => "8", + Key::Digit9 => "9", + Key::P => "p", + Key::K => "k", + Key::B => "b", + Key::M => "m", + Key::G => "g", + } +} + +fn parse_button(value: &str) -> Result<usize, String> { + match value.to_ascii_lowercase().as_str() { + "left" => Ok(0), + "right" => Ok(1), + "middle" => Ok(2), + _ => Err(format!("unknown mouse button: {value}")), + } +} + +fn button_name(button: usize) -> &'static str { + match button { + 0 => "left", + 1 => "right", + 2 => "middle", + _ => "unknown", + } +} + +fn format_float(value: f32) -> String { + let mut value = value.to_string(); + if !value.contains(['.', 'e', 'E']) { + value.push_str(".0"); + } + value +} + +fn load_replay(path: &Path) -> Result<Vec<ReplayEvent>, String> { + let source = std::fs::read_to_string(path) + .map_err(|error| format!("cannot read replay {}: {error}", path.display()))?; + let mut lines = source.lines(); + if lines.next() != Some(RECORDING_HEADER) { + return Err(format!( + "unsupported input replay schema in {}", + path.display() + )); + } + + let mut events = Vec::new(); + let mut previous_frame = 0; + for (index, line) in lines.enumerate() { + if line.trim().is_empty() || line.trim_start().starts_with('#') { + continue; + } + let mut fields = line.splitn(3, '\t'); + if fields.next() != Some("frame") { + return Err(format!("invalid replay line {}", index + 2)); + } + let frame = fields + .next() + .and_then(|value| value.parse::<u64>().ok()) + .ok_or_else(|| format!("invalid replay frame on line {}", index + 2))?; + if !events.is_empty() && frame < previous_frame { + return Err(format!( + "replay frames are out of order on line {}", + index + 2 + )); + } + let command_text = fields + .next() + .ok_or_else(|| format!("missing replay command on line {}", index + 2))?; + let command = parse_command(command_text) + .map_err(|error| format!("invalid replay command on line {}: {error}", index + 2))?; + if !matches!( + command, + Command::Key { .. } + | Command::MouseMove { .. } + | Command::MouseButton { .. } + | Command::Text(_) + | Command::Scroll(_, _) + ) { + return Err(format!("non-input replay command on line {}", index + 2)); + } + previous_frame = frame; + events.push(ReplayEvent { frame, command }); + } + Ok(events) +} + +fn json_escape(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '"' => escaped.push_str("\\\""), + '\\' => escaped.push_str("\\\\"), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + c if c.is_control() => escaped.push_str(&format!("\\u{:04x}", c as u32)), + c => escaped.push(c), + } + } + escaped +} +fn json_string(value: &str) -> String { + format!("\"{}\"", json_escape(value)) +} + +fn json_list(values: &[String]) -> String { + let mut out = String::from("["); + for (i, value) in values.iter().enumerate() { + if i != 0 { + out.push(','); + } + out.push_str(&json_string(value)); + } + out.push(']'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn parses_command_vocabulary_and_rejects_non_finite_values() { + assert_eq!( + parse_command("key tap w").unwrap(), + Command::Key { + key: Key::W, + action: KeyAction::Tap + } + ); + assert_eq!( + parse_command("mouse move rel -2 3.5").unwrap(), + Command::MouseMove { + relative: true, + x: -2.0, + y: 3.5 + } + ); + assert_eq!( + parse_command("text hello world").unwrap(), + Command::Text("hello world".into()) + ); + assert!(parse_command("scroll NaN 1").is_err()); + assert!(parse_command("key down unknown").is_err()); + } + + #[test] + fn replay_is_schema_checked_ordered_and_input_only() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!("successor-replay-{nonce}.txt")); + std::fs::write( + &path, + "successor.input.v1\nframe\t0\tkey down w\nframe\t2\tkey up w\n", + ) + .unwrap(); + let replay = load_replay(&path).unwrap(); + assert_eq!(replay.len(), 2); + assert_eq!(replay[0].frame, 0); + assert_eq!(replay[1].frame, 2); + + std::fs::write(&path, "successor.input.v1\nframe\t0\tquit\n").unwrap(); + assert!(load_replay(&path).is_err()); + let _ = std::fs::remove_file(path); + } + + #[test] + fn bmp_writer_emits_expected_header_and_pixels() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!("successor-shot-{nonce}.bmp")); + write_bmp(&path, &[255, 0, 0, 255], 1, 1).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(&bytes[..2], b"BM"); + assert_eq!(bytes.len(), 58); + assert_eq!(&bytes[54..57], &[0, 0, 255]); + let _ = std::fs::remove_file(path); + } +} diff --git a/client-rust/source/platform/src/native/fs.rs b/client-rust/source/platform/src/native/fs.rs new file mode 100644 index 00000000..6eb588fb --- /dev/null +++ b/client-rust/source/platform/src/native/fs.rs @@ -0,0 +1,56 @@ +//! Native filesystem access for local asset directories. + +use std::io::Write; +use std::path::Path; + +/// Read an entire file into memory. Errors carry the path for diagnostics. +pub fn fs_read(path: &str) -> Result<Vec<u8>, String> { + std::fs::read(Path::new(path)).map_err(|e| format!("read {path}: {e}")) +} + +/// True when a file exists and is readable. +pub fn fs_exists(path: &str) -> bool { + Path::new(path).is_file() +} + +/// Atomically replace a file by syncing a sibling temporary file, then +/// renaming it over the destination. The sibling keeps the rename on one +/// filesystem; a process suffix avoids two developer clients sharing a temp. +pub fn fs_write_atomic(path: &str, bytes: &[u8]) -> Result<(), String> { + let destination = Path::new(path); + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("create {}: {error}", parent.display()))?; + } + let temporary = destination.with_extension(format!( + "{}.tmp-{}", + destination + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or("file"), + std::process::id() + )); + let result = (|| { + let mut file = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&temporary) + .map_err(|error| format!("open {}: {error}", temporary.display()))?; + file.write_all(bytes) + .map_err(|error| format!("write {}: {error}", temporary.display()))?; + file.sync_all() + .map_err(|error| format!("sync {}: {error}", temporary.display()))?; + std::fs::rename(&temporary, destination).map_err(|error| { + format!( + "replace {} from {}: {error}", + destination.display(), + temporary.display() + ) + }) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result +} diff --git a/client-rust/source/platform/src/native/gl.rs b/client-rust/source/platform/src/native/gl.rs new file mode 100644 index 00000000..358d531f --- /dev/null +++ b/client-rust/source/platform/src/native/gl.rs @@ -0,0 +1,751 @@ +//! Native OpenGL bindings, constants, and safe FFI wrappers. +#![allow(non_snake_case, dead_code)] + +use std::os::raw::c_void; + +// Constants +pub const COLOR_BUFFER_BIT: u32 = 0x00004000; +pub const DEPTH_BUFFER_BIT: u32 = 0x00000100; +pub const DEPTH_TEST: u32 = 0x0B71; +pub const CULL_FACE: u32 = 0x0B44; +pub const BACK: u32 = 0x0405; +pub const FRONT: u32 = 0x0404; +pub const BLEND: u32 = 0x0BE2; +pub const SRC_ALPHA: u32 = 0x0302; +pub const ONE_MINUS_SRC_ALPHA: u32 = 0x0303; +pub const ONE: u32 = 1; + +pub const VERTEX_SHADER: u32 = 0x8B31; +pub const FRAGMENT_SHADER: u32 = 0x8B30; +pub const COMPILE_STATUS: u32 = 0x8B81; +pub const LINK_STATUS: u32 = 0x8B82; +pub const INFO_LOG_LENGTH: u32 = 0x8B84; + +pub const TEXTURE_2D: u32 = 0x0DE1; +pub const TEXTURE0: u32 = 0x84C0; +pub const TEXTURE_MIN_FILTER: u32 = 0x2801; +pub const TEXTURE_MAG_FILTER: u32 = 0x2800; +pub const TEXTURE_WRAP_S: u32 = 0x2802; +pub const TEXTURE_WRAP_T: u32 = 0x2803; + +pub const NEAREST: i32 = 0x2600; +pub const LINEAR: i32 = 0x2601; +pub const CLAMP_TO_EDGE: i32 = 0x812F; +pub const REPEAT: i32 = 0x2901; + +pub const RGBA: u32 = 0x1908; +pub const RGBA8: u32 = 0x8058; +pub const SRGB8_ALPHA8: u32 = 0x8C43; +pub const RED: u32 = 0x1903; +pub const RG: u32 = 0x8227; +pub const R8: u32 = 0x8229; +pub const RG8: u32 = 0x822B; +pub const DEPTH_COMPONENT: u32 = 0x1902; +pub const DEPTH_COMPONENT24: u32 = 0x81A6; + +pub const UNSIGNED_BYTE: u32 = 0x1401; +pub const UNSIGNED_SHORT: u32 = 0x1403; +pub const UNSIGNED_INT: u32 = 0x1405; +pub const FLOAT: u32 = 0x1406; + +pub const ARRAY_BUFFER: u32 = 0x8892; +pub const ELEMENT_ARRAY_BUFFER: u32 = 0x8893; +pub const STATIC_DRAW: u32 = 0x88E4; +pub const DYNAMIC_DRAW: u32 = 0x88E8; + +pub const TRIANGLES: u32 = 0x0004; + +pub const FRAMEBUFFER: u32 = 0x8D40; +pub const COLOR_ATTACHMENT0: u32 = 0x8CE0; +pub const DEPTH_ATTACHMENT: u32 = 0x8D00; +pub const FRAMEBUFFER_COMPLETE: u32 = 0x8CD5; +pub const UNPACK_ALIGNMENT: u32 = 0x0CF5; +pub const TEXTURE_3D: u32 = 0x806F; +pub const TEXTURE_2D_ARRAY: u32 = 0x8C1A; +pub const TEXTURE_WRAP_R: u32 = 0x8072; +pub const RGBA16F: u32 = 0x881A; +pub const HALF_FLOAT: u32 = 0x140B; +pub const COLOR_ATTACHMENT1: u32 = 0x8CE1; +pub const COLOR_ATTACHMENT2: u32 = 0x8CE2; +pub const COLOR_ATTACHMENT3: u32 = 0x8CE3; +pub const MAX_COLOR_ATTACHMENTS: u32 = 0x8CDF; +pub const MAX_DRAW_BUFFERS: u32 = 0x8824; +pub const LINEAR_MIPMAP_LINEAR: i32 = 0x2703; +pub const NEAREST_MIPMAP_NEAREST: i32 = 0x2700; + +extern "C" { + fn glClearColor(red: f32, green: f32, blue: f32, alpha: f32); + fn glClear(mask: u32); + fn glViewport(x: i32, y: i32, width: i32, height: i32); + fn glEnable(cap: u32); + fn glDisable(cap: u32); + fn glCullFace(mode: u32); + fn glDepthMask(flag: u8); + fn glColorMask(red: u8, green: u8, blue: u8, alpha: u8); + fn glBlendFunc(sfactor: u32, dfactor: u32); + fn glGetIntegerv(pname: u32, data: *mut i32); + + fn glCreateShader(type_: u32) -> u32; + fn glShaderSource(shader: u32, count: i32, string: *const *const u8, length: *const i32); + fn glCompileShader(shader: u32); + fn glGetShaderiv(shader: u32, pname: u32, params: *mut i32); + fn glGetShaderInfoLog(shader: u32, bufSize: i32, length: *mut i32, infoLog: *mut u8); + fn glDeleteShader(shader: u32); + + fn glCreateProgram() -> u32; + fn glAttachShader(program: u32, shader: u32); + fn glLinkProgram(program: u32); + fn glGetProgramiv(program: u32, pname: u32, params: *mut i32); + fn glGetProgramInfoLog(program: u32, bufSize: i32, length: *mut i32, infoLog: *mut u8); + fn glUseProgram(program: u32); + fn glDeleteProgram(program: u32); + + fn glGetUniformLocation(program: u32, name: *const u8) -> i32; + fn glUniform1i(location: i32, v0: i32); + fn glUniform1f(location: i32, v0: f32); + fn glUniform2f(location: i32, v0: f32, v1: f32); + fn glUniform3f(location: i32, v0: f32, v1: f32, v2: f32); + fn glUniform4f(location: i32, v0: f32, v1: f32, v2: f32, v3: f32); + fn glUniform3fv(location: i32, count: i32, value: *const f32); + fn glUniform1fv(location: i32, count: i32, value: *const f32); + fn glUniformMatrix4fv(location: i32, count: i32, transpose: u8, value: *const f32); + + fn glGenTextures(n: i32, textures: *mut u32); + fn glDeleteTextures(n: i32, textures: *const u32); + fn glBindTexture(target: u32, texture: u32); + fn glActiveTexture(texture: u32); + fn glTexParameteri(target: u32, pname: u32, param: i32); + fn glTexImage2D( + target: u32, + level: i32, + internalformat: i32, + width: i32, + height: i32, + border: i32, + format: u32, + type_: u32, + pixels: *const c_void, + ); + + fn glGenBuffers(n: i32, buffers: *mut u32); + fn glDeleteBuffers(n: i32, buffers: *const u32); + fn glBindBuffer(target: u32, buffer: u32); + fn glBufferData(target: u32, size: isize, data: *const c_void, usage: u32); + + fn glGenVertexArrays(n: i32, arrays: *mut u32); + fn glDeleteVertexArrays(n: i32, arrays: *const u32); + fn glBindVertexArray(array: u32); + fn glVertexAttribPointer( + index: u32, + size: i32, + type_: u32, + normalized: u8, + stride: i32, + pointer: *const c_void, + ); + fn glEnableVertexAttribArray(index: u32); + fn glDisableVertexAttribArray(index: u32); + + fn glDrawArrays(mode: u32, first: i32, count: i32); + fn glDrawElements(mode: u32, count: i32, type_: u32, indices: *const c_void); + + fn glGenFramebuffers(n: i32, framebuffers: *mut u32); + fn glDeleteFramebuffers(n: i32, framebuffers: *const u32); + fn glBindFramebuffer(target: u32, framebuffer: u32); + fn glFramebufferTexture2D( + target: u32, + attachment: u32, + textarget: u32, + texture: u32, + level: i32, + ); + fn glCheckFramebufferStatus(target: u32) -> u32; + fn glDrawBuffers(n: i32, bufs: *const u32); + fn glPixelStorei(pname: u32, param: i32); + fn glDrawBuffer(mode: u32); + fn glReadPixels( + x: i32, + y: i32, + width: i32, + height: i32, + format: u32, + ty: u32, + data: *mut c_void, + ); + fn glGetError() -> u32; + fn glFinish(); + fn glVertexAttribDivisor(index: u32, divisor: u32); + fn glDrawElementsInstanced( + mode: u32, + count: i32, + type_: u32, + indices: *const c_void, + primcount: i32, + ); + fn glDrawArraysInstanced(mode: u32, first: i32, count: i32, primcount: i32); + fn glTexImage3D( + target: u32, + level: i32, + internalformat: i32, + width: i32, + height: i32, + depth: i32, + border: i32, + format: u32, + type_: u32, + pixels: *const c_void, + ); + fn glTexSubImage3D( + target: u32, + level: i32, + xoffset: i32, + yoffset: i32, + zoffset: i32, + width: i32, + height: i32, + depth: i32, + format: u32, + type_: u32, + pixels: *const c_void, + ); + fn glGenerateMipmap(target: u32); +} + +// Wrappers +pub fn clear_color(r: f32, g: f32, b: f32, a: f32) { + unsafe { + glClearColor(r, g, b, a); + } +} + +pub fn read_pixels(x: i32, y: i32, w: i32, h: i32, format: u32, ty: u32, data: &mut [u8]) { + unsafe { + glReadPixels(x, y, w, h, format, ty, data.as_mut_ptr() as *mut c_void); + } +} + +pub fn get_error() -> u32 { + unsafe { glGetError() } +} + +pub fn finish() { + unsafe { + glFinish(); + } +} + +pub fn clear(mask: u32) { + unsafe { + glClear(mask); + } +} + +pub fn viewport(x: i32, y: i32, w: i32, h: i32) { + unsafe { + glViewport(x, y, w, h); + } +} + +pub fn enable(cap: u32) { + unsafe { + glEnable(cap); + } +} + +pub fn disable(cap: u32) { + unsafe { + glDisable(cap); + } +} + +pub fn cull_face(mode: u32) { + unsafe { + glCullFace(mode); + } +} + +pub fn depth_mask(flag: bool) { + unsafe { + glDepthMask(if flag { 1 } else { 0 }); + } +} + +pub fn color_mask(r: bool, g: bool, b: bool, a: bool) { + unsafe { + glColorMask( + if r { 1 } else { 0 }, + if g { 1 } else { 0 }, + if b { 1 } else { 0 }, + if a { 1 } else { 0 }, + ); + } +} + +pub fn blend_func(sfactor: u32, dfactor: u32) { + unsafe { + glBlendFunc(sfactor, dfactor); + } +} + +pub fn create_shader(type_: u32) -> u32 { + unsafe { glCreateShader(type_) } +} + +pub fn shader_source(shader: u32, src: &[u8]) { + let ptr = src.as_ptr(); + let len = src.len() as i32; + unsafe { + glShaderSource(shader, 1, &ptr, &len); + } +} + +pub fn compile_shader(shader: u32) { + unsafe { + glCompileShader(shader); + } +} + +pub fn get_shaderiv(shader: u32, pname: u32) -> i32 { + let mut param = 0; + unsafe { + glGetShaderiv(shader, pname, &mut param); + } + param +} + +pub fn get_shader_info_log(shader: u32, buf: &mut [u8]) -> usize { + let mut length = 0; + unsafe { + glGetShaderInfoLog(shader, buf.len() as i32, &mut length, buf.as_mut_ptr()); + } + length as usize +} + +pub fn delete_shader(shader: u32) { + unsafe { + glDeleteShader(shader); + } +} + +pub fn create_program() -> u32 { + unsafe { glCreateProgram() } +} + +pub fn attach_shader(program: u32, shader: u32) { + unsafe { + glAttachShader(program, shader); + } +} + +pub fn link_program(program: u32) { + unsafe { + glLinkProgram(program); + } +} + +pub fn get_programiv(program: u32, pname: u32) -> i32 { + let mut param = 0; + unsafe { + glGetProgramiv(program, pname, &mut param); + } + param +} + +pub fn get_program_info_log(program: u32, buf: &mut [u8]) -> usize { + let mut length = 0; + unsafe { + glGetProgramInfoLog(program, buf.len() as i32, &mut length, buf.as_mut_ptr()); + } + length as usize +} + +pub fn use_program(program: u32) { + unsafe { + glUseProgram(program); + } +} + +pub fn delete_program(program: u32) { + unsafe { + glDeleteProgram(program); + } +} + +pub fn get_uniform_location(program: u32, name: &str) -> i32 { + let mut name_c = name.as_bytes().to_vec(); + name_c.push(0); + unsafe { glGetUniformLocation(program, name_c.as_ptr()) } +} + +pub fn uniform1i(location: i32, value: i32) { + unsafe { + glUniform1i(location, value); + } +} + +pub fn uniform1f(location: i32, value: f32) { + unsafe { + glUniform1f(location, value); + } +} + +pub fn uniform2f(location: i32, x: f32, y: f32) { + unsafe { + glUniform2f(location, x, y); + } +} + +pub fn uniform3f(location: i32, x: f32, y: f32, z: f32) { + unsafe { + glUniform3f(location, x, y, z); + } +} + +pub fn uniform4f(location: i32, x: f32, y: f32, z: f32, w: f32) { + unsafe { + glUniform4f(location, x, y, z, w); + } +} + +pub fn uniform1fv(location: i32, values: &[f32]) { + unsafe { + glUniform1fv(location, values.len() as i32, values.as_ptr()); + } +} + +pub fn uniform3fv(location: i32, values: &[f32]) { + unsafe { + glUniform3fv(location, (values.len() / 3) as i32, values.as_ptr()); + } +} + +pub fn uniform_matrix4fv(location: i32, transpose: bool, values: &[f32; 16]) { + unsafe { + glUniformMatrix4fv(location, 1, if transpose { 1 } else { 0 }, values.as_ptr()); + } +} + +pub fn uniform_matrix4fv_array(location: i32, values: &[f32]) { + unsafe { + glUniformMatrix4fv(location, (values.len() / 16) as i32, 0, values.as_ptr()); + } +} + +pub fn vertex_attrib_divisor(index: u32, divisor: u32) { + unsafe { + glVertexAttribDivisor(index, divisor); + } +} + +pub fn draw_elements_instanced(mode: u32, count: i32, type_: u32, offset: u32, primcount: i32) { + unsafe { + glDrawElementsInstanced( + mode, + count, + type_, + offset as usize as *const c_void, + primcount, + ); + } +} + +pub fn draw_arrays_instanced(mode: u32, first: i32, count: i32, primcount: i32) { + unsafe { + glDrawArraysInstanced(mode, first, count, primcount); + } +} + +pub fn gen_texture() -> u32 { + let mut tex = 0; + unsafe { + glGenTextures(1, &mut tex); + } + tex +} + +pub fn delete_texture(texture: u32) { + unsafe { + glDeleteTextures(1, &texture); + } +} + +pub fn bind_texture(target: u32, texture: u32) { + unsafe { + glBindTexture(target, texture); + } +} + +pub fn active_texture(unit: u32) { + unsafe { + glActiveTexture(unit); + } +} + +pub fn tex_parameteri(target: u32, pname: u32, param: i32) { + unsafe { + glTexParameteri(target, pname, param); + } +} + +#[allow(clippy::too_many_arguments)] +pub fn tex_image_2d( + target: u32, + level: i32, + internal_format: i32, + width: i32, + height: i32, + border: i32, + format: u32, + type_: u32, + data: Option<&[u8]>, +) { + let ptr = match data { + Some(d) => d.as_ptr() as *const c_void, + None => std::ptr::null(), + }; + unsafe { + glTexImage2D( + target, + level, + internal_format, + width, + height, + border, + format, + type_, + ptr, + ); + } +} + +pub fn gen_buffer() -> u32 { + let mut buf = 0; + unsafe { + glGenBuffers(1, &mut buf); + } + buf +} + +pub fn delete_buffer(buffer: u32) { + unsafe { + glDeleteBuffers(1, &buffer); + } +} + +pub fn bind_buffer(target: u32, buffer: u32) { + unsafe { + glBindBuffer(target, buffer); + } +} + +pub fn buffer_data(target: u32, data: &[u8], usage: u32) { + unsafe { + glBufferData( + target, + data.len() as isize, + data.as_ptr() as *const c_void, + usage, + ); + } +} + +pub fn gen_vertex_array() -> u32 { + let mut vao = 0; + unsafe { + glGenVertexArrays(1, &mut vao); + } + vao +} + +pub fn delete_vertex_array(vao: u32) { + unsafe { + glDeleteVertexArrays(1, &vao); + } +} + +pub fn bind_vertex_array(vao: u32) { + unsafe { + glBindVertexArray(vao); + } +} + +pub fn vertex_attrib_pointer( + index: u32, + size: i32, + type_: u32, + normalized: bool, + stride: i32, + offset: u32, +) { + unsafe { + glVertexAttribPointer( + index, + size, + type_, + if normalized { 1 } else { 0 }, + stride, + offset as usize as *const c_void, + ); + } +} + +pub fn enable_vertex_attrib_array(index: u32) { + unsafe { + glEnableVertexAttribArray(index); + } +} + +pub fn disable_vertex_attrib_array(index: u32) { + unsafe { + glDisableVertexAttribArray(index); + } +} + +pub fn draw_arrays(mode: u32, first: i32, count: i32) { + unsafe { + glDrawArrays(mode, first, count); + } +} + +pub fn draw_elements(mode: u32, count: i32, type_: u32, offset: u32) { + unsafe { + glDrawElements(mode, count, type_, offset as usize as *const c_void); + } +} + +pub fn gen_framebuffer() -> u32 { + let mut fbo = 0; + unsafe { + glGenFramebuffers(1, &mut fbo); + } + fbo +} + +pub fn delete_framebuffer(fbo: u32) { + unsafe { + glDeleteFramebuffers(1, &fbo); + } +} + +pub fn bind_framebuffer(target: u32, framebuffer: u32) { + unsafe { + glBindFramebuffer(target, framebuffer); + } +} + +pub fn framebuffer_texture_2d( + target: u32, + attachment: u32, + textarget: u32, + texture: u32, + level: i32, +) { + unsafe { + glFramebufferTexture2D(target, attachment, textarget, texture, level); + } +} + +pub fn check_framebuffer_status(target: u32) -> u32 { + unsafe { glCheckFramebufferStatus(target) } +} + +pub fn draw_buffers(buffers: &[u32]) { + unsafe { + glDrawBuffers(buffers.len() as i32, buffers.as_ptr()); + } +} + +pub fn pixel_storei(pname: u32, param: i32) { + unsafe { + glPixelStorei(pname, param); + } +} + +pub fn disable_draw_buffer() { + unsafe { + glDrawBuffer(0); // GL_NONE + } +} + +#[allow(clippy::too_many_arguments)] +pub fn tex_image_3d( + target: u32, + level: i32, + internal_format: i32, + width: i32, + height: i32, + depth: i32, + border: i32, + format: u32, + type_: u32, + data: Option<&[u8]>, +) { + let ptr = match data { + Some(d) => d.as_ptr() as *const c_void, + None => core::ptr::null(), + }; + unsafe { + glTexImage3D( + target, + level, + internal_format, + width, + height, + depth, + border, + format, + type_, + ptr, + ); + } +} + +#[allow(clippy::too_many_arguments)] +pub fn tex_sub_image_3d( + target: u32, + level: i32, + xoffset: i32, + yoffset: i32, + zoffset: i32, + width: i32, + height: i32, + depth: i32, + format: u32, + type_: u32, + data: &[u8], +) { + unsafe { + glTexSubImage3D( + target, + level, + xoffset, + yoffset, + zoffset, + width, + height, + depth, + format, + type_, + data.as_ptr() as *const c_void, + ); + } +} + +pub fn generate_mipmap(target: u32) { + unsafe { + glGenerateMipmap(target); + } +} + +pub fn get_integer(pname: u32) -> i32 { + let mut value = 0; + unsafe { + glGetIntegerv(pname, &mut value); + } + value +} + +/// Native always supports RGBA16F color attachments (GL 3.3 core). +pub fn cap_half_float_target() -> bool { + true +} diff --git a/client-rust/source/platform/src/native/http.rs b/client-rust/source/platform/src/native/http.rs new file mode 100644 index 00000000..73c6baa9 --- /dev/null +++ b/client-rust/source/platform/src/native/http.rs @@ -0,0 +1,166 @@ +//! Native HTTP JSON POST implementation over TcpStream and native-tls. + +use native_tls::TlsConnector; +use std::io::{Read, Write}; +use std::net::TcpStream; +use url::Url; + +pub fn http_post_json(url_str: &str, body: &[u8]) -> Result<Vec<u8>, String> { + let parsed_url = Url::parse(url_str).map_err(|e| e.to_string())?; + let host = parsed_url + .host_str() + .ok_or_else(|| "Missing host in URL".to_string())?; + let port = parsed_url + .port_or_known_default() + .ok_or_else(|| "Could not determine port".to_string())?; + let path = parsed_url.path(); + let query = parsed_url.query(); + + let full_path = if let Some(q) = query { + format!("{}?{}", path, q) + } else { + path.to_string() + }; + + let stream = TcpStream::connect(format!("{}:{}", host, port)).map_err(|e| e.to_string())?; + + let is_https = parsed_url.scheme() == "https"; + + let mut response = Vec::new(); + let mut request = Vec::new(); + + request.extend_from_slice( + format!( + "POST {} HTTP/1.1\r\n\ + Host: {}\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\r\n", + full_path, + host, + body.len() + ) + .as_bytes(), + ); + request.extend_from_slice(body); + + if is_https { + let connector = TlsConnector::new().map_err(|e| e.to_string())?; + let mut tls_stream = connector.connect(host, stream).map_err(|e| e.to_string())?; + tls_stream.write_all(&request).map_err(|e| e.to_string())?; + tls_stream.flush().map_err(|e| e.to_string())?; + tls_stream + .read_to_end(&mut response) + .map_err(|e| e.to_string())?; + } else { + let mut raw_stream = stream; + raw_stream.write_all(&request).map_err(|e| e.to_string())?; + raw_stream.flush().map_err(|e| e.to_string())?; + raw_stream + .read_to_end(&mut response) + .map_err(|e| e.to_string())?; + } + + // Split response into headers and body + let mut header_end = None; + for i in 0..response.len().saturating_sub(3) { + if &response[i..i + 4] == b"\r\n\r\n" { + header_end = Some(i); + break; + } + } + + let header_end = + header_end.ok_or_else(|| "Invalid HTTP response (missing header separator)".to_string())?; + let headers_part = &response[..header_end]; + let body_part = &response[header_end + 4..]; + + // Parse status code from headers + let headers_str = std::str::from_utf8(headers_part) + .map_err(|_| "Invalid UTF-8 in HTTP headers".to_string())?; + let mut lines = headers_str.lines(); + let status_line = lines + .next() + .ok_or_else(|| "Empty HTTP response".to_string())?; + let parts: Vec<&str> = status_line.split_whitespace().collect(); + if parts.len() < 2 { + return Err("Invalid HTTP status line".to_string()); + } + + let status_code = parts[1] + .parse::<u32>() + .map_err(|_| "Invalid HTTP status code".to_string())?; + if status_code != 200 { + return Err(format!( + "HTTP request failed with status code: {}", + status_code + )); + } + + Ok(body_part.to_vec()) +} + +/// HTTP GET returning the raw response body (any size; used for asset blobs). +pub fn http_get(url_str: &str) -> Result<Vec<u8>, String> { + let parsed_url = Url::parse(url_str).map_err(|e| e.to_string())?; + let host = parsed_url + .host_str() + .ok_or_else(|| "Missing host in URL".to_string())?; + let port = parsed_url + .port_or_known_default() + .ok_or_else(|| "Could not determine port".to_string())?; + let path = parsed_url.path(); + let full_path = match parsed_url.query() { + Some(q) => format!("{}?{}", path, q), + None => path.to_string(), + }; + let stream = TcpStream::connect(format!("{}:{}", host, port)).map_err(|e| e.to_string())?; + let request = format!( + "GET {} HTTP/1.1\r\n\ + Host: {}\r\n\ + Accept: */*\r\n\ + Connection: close\r\n\r\n", + full_path, host + ); + let mut response = Vec::new(); + if parsed_url.scheme() == "https" { + let connector = TlsConnector::new().map_err(|e| e.to_string())?; + let mut tls = connector.connect(host, stream).map_err(|e| e.to_string())?; + tls.write_all(request.as_bytes()) + .map_err(|e| e.to_string())?; + tls.flush().map_err(|e| e.to_string())?; + tls.read_to_end(&mut response).map_err(|e| e.to_string())?; + } else { + let mut raw = stream; + raw.write_all(request.as_bytes()) + .map_err(|e| e.to_string())?; + raw.flush().map_err(|e| e.to_string())?; + raw.read_to_end(&mut response).map_err(|e| e.to_string())?; + } + let mut header_end = None; + for i in 0..response.len().saturating_sub(3) { + if &response[i..i + 4] == b"\r\n\r\n" { + header_end = Some(i); + break; + } + } + let header_end = + header_end.ok_or_else(|| "Invalid HTTP response (no header separator)".to_string())?; + let headers_str = std::str::from_utf8(&response[..header_end]) + .map_err(|_| "Invalid UTF-8 in HTTP headers".to_string())?; + let status_line = headers_str + .lines() + .next() + .ok_or_else(|| "Empty HTTP response".to_string())?; + let parts: Vec<&str> = status_line.split_whitespace().collect(); + if parts.len() < 2 { + return Err("Invalid HTTP status line".to_string()); + } + let status_code = parts[1] + .parse::<u32>() + .map_err(|_| "Invalid HTTP status code".to_string())?; + if status_code != 200 { + return Err(format!("HTTP GET failed with status code: {}", status_code)); + } + Ok(response[header_end + 4..].to_vec()) +} diff --git a/client-rust/source/platform/src/native/mod.rs b/client-rust/source/platform/src/native/mod.rs new file mode 100644 index 00000000..4465ac71 --- /dev/null +++ b/client-rust/source/platform/src/native/mod.rs @@ -0,0 +1,7 @@ +pub mod audio; +pub mod control; +pub mod fs; +pub mod gl; +pub mod http; +pub mod net; +pub mod window; diff --git a/client-rust/source/platform/src/native/net.rs b/client-rust/source/platform/src/native/net.rs new file mode 100644 index 00000000..203b0350 --- /dev/null +++ b/client-rust/source/platform/src/native/net.rs @@ -0,0 +1,109 @@ +//! Native WebSocket transport implementation using tungstenite. + +use std::net::TcpStream; +use tungstenite::{stream::MaybeTlsStream, Message, WebSocket}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WsEvent { + Open, + Frame(usize), + Closed, + Error, + None, +} + +pub struct WsHandle { + pub(crate) socket: WebSocket<MaybeTlsStream<TcpStream>>, + pub(crate) opened_reported: bool, +} + +pub fn ws_connect(url_str: &str) -> Result<WsHandle, String> { + let (mut socket, _) = tungstenite::connect(url_str).map_err(|e| e.to_string())?; + + // Set non-blocking on the underlying TcpStream + match socket.get_mut() { + MaybeTlsStream::Plain(s) => { + s.set_nonblocking(true).map_err(|e| e.to_string())?; + } + MaybeTlsStream::NativeTls(s) => { + s.get_mut() + .set_nonblocking(true) + .map_err(|e| e.to_string())?; + } + _ => {} + } + + Ok(WsHandle { + socket, + opened_reported: false, + }) +} + +pub fn ws_send(handle: &mut WsHandle, data: &[u8]) { + let _ = handle.socket.write(Message::Binary(data.to_vec())); + let _ = handle.socket.flush(); +} + +pub fn ws_poll(handle: &mut WsHandle, out_buf: &mut Vec<u8>) -> WsEvent { + if !handle.opened_reported { + handle.opened_reported = true; + return WsEvent::Open; + } + + match handle.socket.read() { + Ok(Message::Binary(bin)) => { + out_buf.clear(); + out_buf.extend_from_slice(&bin); + WsEvent::Frame(bin.len()) + } + Ok(Message::Text(txt)) => { + out_buf.clear(); + out_buf.extend_from_slice(txt.as_bytes()); + WsEvent::Frame(txt.len()) + } + // A server-sent Close carries the code and reason that say WHY the + // shard hung up (e.g. 1011 "durable character entry failed"). Dropping + // it leaves only "reconnect 1/5" and hides an authority-side refusal. + Ok(Message::Close(frame)) => { + match frame { + Some(frame) => eprintln!( + "ws closed by peer: code={} reason={}", + frame.code, frame.reason + ), + None => eprintln!("ws closed by peer: no close frame"), + } + WsEvent::Closed + } + Ok(Message::Ping(_)) => { + // tungstenite QUEUES the Pong; it only leaves the socket on the next + // write or flush. An idle client never writes, so without this flush + // the reply is never sent and Colyseus terminates the connection on + // its liveness timeout -- an abrupt reset with no close frame, which + // the client then reports only as "reconnect 1/5". A client that was + // being driven kept the socket alive purely because its outbound + // commands flushed the pong as a side effect. + if let Err(error) = handle.socket.flush() { + if !matches!(&error, tungstenite::Error::Io(io) if io.kind() == std::io::ErrorKind::WouldBlock) + { + eprintln!("ws pong flush failed: {error}"); + return WsEvent::Error; + } + } + WsEvent::None + } + Ok(Message::Pong(_)) => WsEvent::None, + Ok(Message::Frame(_)) => WsEvent::None, + Err(tungstenite::Error::Io(e)) if e.kind() == std::io::ErrorKind::WouldBlock => { + WsEvent::None + } + Err(tungstenite::Error::ConnectionClosed) => WsEvent::Closed, + // `WsEvent` is a `Copy` enum shared with the wasm backend, so the cause + // cannot ride along with it. Print it here rather than discard it: a + // silent drop reports only "reconnect 1/5" and hides why the transport + // died, which is indistinguishable from the authority refusing the join. + Err(error) => { + eprintln!("ws transport error: {error}"); + WsEvent::Error + } + } +} diff --git a/client-rust/source/platform/src/native/window.rs b/client-rust/source/platform/src/native/window.rs new file mode 100644 index 00000000..1a93bca0 --- /dev/null +++ b/client-rust/source/platform/src/native/window.rs @@ -0,0 +1,397 @@ +//! Native windowing and input implementation via GLFW. +#![allow(dead_code)] + +use crate::native::control; + +use parking_lot::Mutex; +use std::os::raw::c_void; +use successor_engine_core::input::Key; + +// GLFW Constants +const GLFW_CONTEXT_VERSION_MAJOR: i32 = 0x00022002; +const GLFW_CONTEXT_VERSION_MINOR: i32 = 0x00022003; +const GLFW_OPENGL_PROFILE: i32 = 0x00022008; +const GLFW_OPENGL_CORE_PROFILE: i32 = 0x00032001; +const GLFW_OPENGL_FORWARD_COMPAT: i32 = 0x00022006; +const GLFW_VISIBLE: i32 = 0x00020002; +const GLFW_COCOA_RETINA_FRAMEBUFFER: i32 = 0x00023001; +const GLFW_FOCUSED: i32 = 0x0002_0001; +const GLFW_TRUE: i32 = 1; +const GLFW_FALSE: i32 = 0; +const GLFW_CURSOR: i32 = 0x00033001; +const GLFW_CURSOR_NORMAL: i32 = 0x00034001; +const GLFW_CURSOR_HIDDEN: i32 = 0x00034002; + +type GLFWwindow = c_void; + +extern "C" { + fn glfwInit() -> i32; + fn glfwTerminate(); + fn glfwWindowHint(hint: i32, value: i32); + fn glfwCreateWindow( + width: i32, + height: i32, + title: *const u8, + monitor: *mut c_void, + share: *mut c_void, + ) -> *mut GLFWwindow; + fn glfwDestroyWindow(window: *mut GLFWwindow); + fn glfwWindowShouldClose(window: *mut GLFWwindow) -> i32; + fn glfwMakeContextCurrent(window: *mut GLFWwindow); + fn glfwSwapInterval(interval: i32); + fn glfwPollEvents(); + fn glfwSwapBuffers(window: *mut GLFWwindow); + fn glfwGetFramebufferSize(window: *mut GLFWwindow, width: *mut i32, height: *mut i32); + fn glfwGetWindowSize(window: *mut GLFWwindow, width: *mut i32, height: *mut i32); + fn glfwGetWindowAttrib(window: *mut GLFWwindow, attrib: i32) -> i32; + fn glfwGetTime() -> f64; + fn glfwGetKey(window: *mut GLFWwindow, key: i32) -> i32; + fn glfwGetMouseButton(window: *mut GLFWwindow, button: i32) -> i32; + fn glfwGetCursorPos(window: *mut GLFWwindow, xpos: *mut f64, ypos: *mut f64); + fn glfwSetInputMode(window: *mut GLFWwindow, mode: i32, value: i32); + fn glfwSetCharCallback( + window: *mut GLFWwindow, + callback: Option<extern "C" fn(*mut GLFWwindow, u32)>, + ) -> *mut c_void; + fn glfwSetScrollCallback( + window: *mut GLFWwindow, + callback: Option<extern "C" fn(*mut GLFWwindow, f64, f64)>, + ) -> *mut c_void; + +} + +struct NativeState { + window: *mut GLFWwindow, + start_time: f64, +} + +unsafe impl Send for NativeState {} +unsafe impl Sync for NativeState {} + +static STATE: Mutex<NativeState> = Mutex::new(NativeState { + window: std::ptr::null_mut(), + start_time: 0.0, +}); + +static TEXT_INPUT_QUEUE: Mutex<Vec<char>> = Mutex::new(Vec::new()); +static SCROLL_DELTA: Mutex<(f32, f32)> = Mutex::new((0.0, 0.0)); + +extern "C" fn char_callback(_window: *mut GLFWwindow, codepoint: u32) { + if let Some(ch) = std::char::from_u32(codepoint) { + TEXT_INPUT_QUEUE.lock().push(ch); + } +} +extern "C" fn scroll_callback(_window: *mut GLFWwindow, x: f64, y: f64) { + let mut delta = SCROLL_DELTA.lock(); + delta.0 += x as f32; + delta.1 += y as f32; +} + +fn native_log_sink(s: &str) { + use std::io::Write; + let _ = std::io::stderr().write_all(s.as_bytes()); + let _ = std::io::stderr().flush(); +} + +pub fn init(title: &str, w: i32, h: i32) -> bool { + // Install log sink + successor_engine_core::rt::log::set_sink(native_log_sink); + + unsafe { + if glfwInit() == GLFW_FALSE { + return false; + } + + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); + // Verification and presentation budgets use physical 1280x720 pixels. + glfwWindowHint(GLFW_COCOA_RETINA_FRAMEBUFFER, GLFW_FALSE); + + // Check for headless/hidden environment variable + let hidden = std::env::var("SUCCESSOR_HEADLESS").is_ok() + || std::env::var("GLFW_VISIBLE") + .map(|v| v == "false") + .unwrap_or(false); + + if hidden { + glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE); + } + + // Null-terminate title string + let title_c = + std::ffi::CString::new(title).unwrap_or_else(|_| std::ffi::CString::new("").unwrap()); + + let win = glfwCreateWindow( + w, + h, + title_c.as_ptr() as *const u8, + std::ptr::null_mut(), + std::ptr::null_mut(), + ); + + if win.is_null() { + glfwTerminate(); + return false; + } + + glfwMakeContextCurrent(win); + glfwSwapInterval(1); + + // Set char callback for text input + glfwSetCharCallback(win, Some(char_callback)); + glfwSetScrollCallback(win, Some(scroll_callback)); + + let mut state = STATE.lock(); + state.window = win; + state.start_time = glfwGetTime(); + + true + } +} + +pub fn should_quit() -> bool { + if control::quit_requested() { + return true; + } + let state = STATE.lock(); + if state.window.is_null() { + true + } else { + unsafe { glfwWindowShouldClose(state.window) != 0 } + } +} + +pub fn begin_frame() { + unsafe { + glfwPollEvents(); + } + if !control::is_configured() { + return; + } + + let mut snapshot = control::NativeInputSnapshot::default(); + for index in 0..Key::COUNT { + let key = Key::from_u16(index as u16).expect("bounded key index"); + snapshot.keys[index] = raw_key_down(key); + } + snapshot.mouse_position = raw_mouse_position(); + for button in 0..3 { + snapshot.mouse_buttons[button] = raw_mouse_button_down(button as i32); + } + snapshot.text = TEXT_INPUT_QUEUE.lock().drain(..).collect(); + { + let mut scroll = SCROLL_DELTA.lock(); + snapshot.scroll = *scroll; + *scroll = (0.0, 0.0); + } + control::begin_frame(snapshot); +} + +pub fn end_frame() { + if let Some(request) = control::take_screenshot_request() { + let (width, height) = framebuffer_size(); + let result = if width <= 0 || height <= 0 { + Err("framebuffer is unavailable".to_string()) + } else { + let rgba = read_pixels_rgba(width, height); + control::write_bmp(&request.path, &rgba, width as u32, height as u32) + .map(|()| (width as u32, height as u32)) + }; + control::finish_screenshot(request, result); + } + + let state = STATE.lock(); + if !state.window.is_null() { + unsafe { + glfwSwapBuffers(state.window); + } + } + control::flush(); +} + +pub fn deinit() { + unsafe { + let mut state = STATE.lock(); + if !state.window.is_null() { + glfwDestroyWindow(state.window); + state.window = std::ptr::null_mut(); + } + glfwTerminate(); + } + control::shutdown(); +} + +pub fn framebuffer_size() -> (i32, i32) { + let state = STATE.lock(); + if state.window.is_null() { + (0, 0) + } else { + let mut w = 0; + let mut h = 0; + unsafe { + glfwGetFramebufferSize(state.window, &mut w, &mut h); + } + (w, h) + } +} +pub fn window_focused() -> bool { + let state = STATE.lock(); + !state.window.is_null() + && unsafe { glfwGetWindowAttrib(state.window, GLFW_FOCUSED) == GLFW_TRUE } +} + +pub fn now_ms() -> f64 { + let state = STATE.lock(); + unsafe { (glfwGetTime() - state.start_time) * 1000.0 } +} + +pub fn is_key_down(key: Key) -> bool { + control::key_down(key).unwrap_or_else(|| raw_key_down(key)) +} + +fn raw_key_down(key: Key) -> bool { + let state = STATE.lock(); + if state.window.is_null() { + return false; + } + let glfw_key = match key { + Key::W => 87, + Key::A => 65, + Key::S => 83, + Key::D => 68, + Key::Up => 265, + Key::Down => 264, + Key::Left => 263, + Key::Right => 262, + Key::Space => 32, + Key::Enter => 257, + Key::Escape => 256, + Key::Backspace => 259, + Key::LeftShift => 340, + Key::Backquote => 96, + Key::R => 82, + Key::F => 70, + Key::I => 73, + Key::C => 67, + Key::Semicolon => 59, + Key::O => 79, + Key::Tab => 258, + Key::V => 86, + Key::X => 88, + Key::N => 78, + Key::Digit0 => 48, + Key::Digit1 => 49, + Key::Digit2 => 50, + Key::Digit3 => 51, + Key::Digit4 => 52, + Key::Digit5 => 53, + Key::Digit6 => 54, + Key::Digit7 => 55, + Key::Digit8 => 56, + Key::Digit9 => 57, + Key::P => 80, + Key::K => 75, + Key::B => 66, + Key::M => 77, + Key::G => 71, + }; + unsafe { glfwGetKey(state.window, glfw_key) == 1 } +} + +pub fn set_cursor_visible(visible: bool) { + let state = STATE.lock(); + if !state.window.is_null() { + unsafe { + glfwSetInputMode( + state.window, + GLFW_CURSOR, + if visible { + GLFW_CURSOR_NORMAL + } else { + GLFW_CURSOR_HIDDEN + }, + ); + } + } +} + +/// Cursor position in framebuffer pixels (top-left origin). GLFW reports window +/// coordinates; on HiDPI the framebuffer is scaled, so we rescale to match +/// `framebuffer_size()`. +pub fn mouse_position() -> (f32, f32) { + control::mouse_position().unwrap_or_else(raw_mouse_position) +} + +fn raw_mouse_position() -> (f32, f32) { + let state = STATE.lock(); + if state.window.is_null() { + return (0.0, 0.0); + } + let (mut x, mut y) = (0.0f64, 0.0f64); + let (mut ww, mut wh) = (0i32, 0i32); + let (mut fw, mut fh) = (0i32, 0i32); + unsafe { + glfwGetCursorPos(state.window, &mut x, &mut y); + glfwGetWindowSize(state.window, &mut ww, &mut wh); + glfwGetFramebufferSize(state.window, &mut fw, &mut fh); + } + let sx = if ww > 0 { fw as f64 / ww as f64 } else { 1.0 }; + let sy = if wh > 0 { fh as f64 / wh as f64 } else { 1.0 }; + ((x * sx) as f32, (y * sy) as f32) +} + +/// Whether the given mouse button (0 = left, 1 = right, 2 = middle) is pressed. +pub fn mouse_button_down(button: i32) -> bool { + control::mouse_button_down(button as usize).unwrap_or_else(|| raw_mouse_button_down(button)) +} + +fn raw_mouse_button_down(button: i32) -> bool { + let state = STATE.lock(); + if state.window.is_null() { + return false; + } + unsafe { glfwGetMouseButton(state.window, button) == 1 } +} + +pub fn poll_text_input() -> Option<char> { + if control::is_configured() { + return control::poll_text_input(); + } + let mut queue = TEXT_INPUT_QUEUE.lock(); + if !queue.is_empty() { + return Some(queue.remove(0)); + } + None +} + +pub fn poll_scroll_delta() -> Option<(f32, f32)> { + if control::is_configured() { + return control::poll_scroll_delta(); + } + let mut delta = SCROLL_DELTA.lock(); + let value = *delta; + *delta = (0.0, 0.0); + (value != (0.0, 0.0)).then_some(value) +} + +/// Read the current framebuffer as RGBA8, bottom-up (GL row order). +pub fn read_pixels_rgba(w: i32, h: i32) -> Vec<u8> { + let mut buf = vec![0u8; (w.max(0) * h.max(0) * 4) as usize]; + crate::native::gl::read_pixels( + 0, + 0, + w, + h, + crate::native::gl::RGBA, + crate::native::gl::UNSIGNED_BYTE, + &mut buf, + ); + buf +} + +/// Last GL error code (0 = none). +pub fn gl_error() -> u32 { + crate::native::gl::get_error() +} diff --git a/client-rust/source/platform/src/web/gl.rs b/client-rust/source/platform/src/web/gl.rs new file mode 100644 index 00000000..390320d8 --- /dev/null +++ b/client-rust/source/platform/src/web/gl.rs @@ -0,0 +1,705 @@ +//! Web WebGL2 bindings, constants, and safe FFI wrappers. +#![allow(non_snake_case, dead_code)] + +// Constants (mirror native GL constants) +pub const COLOR_BUFFER_BIT: u32 = 0x00004000; +pub const DEPTH_BUFFER_BIT: u32 = 0x00000100; +pub const DEPTH_TEST: u32 = 0x0B71; +pub const CULL_FACE: u32 = 0x0B44; +pub const BACK: u32 = 0x0405; +pub const FRONT: u32 = 0x0404; +pub const BLEND: u32 = 0x0BE2; +pub const SRC_ALPHA: u32 = 0x0302; +pub const ONE_MINUS_SRC_ALPHA: u32 = 0x0303; +pub const ONE: u32 = 1; + +pub const VERTEX_SHADER: u32 = 0x8B31; +pub const FRAGMENT_SHADER: u32 = 0x8B30; +pub const COMPILE_STATUS: u32 = 0x8B81; +pub const LINK_STATUS: u32 = 0x8B82; +pub const INFO_LOG_LENGTH: u32 = 0x8B84; + +pub const TEXTURE_2D: u32 = 0x0DE1; +pub const TEXTURE0: u32 = 0x84C0; +pub const TEXTURE_MIN_FILTER: u32 = 0x2801; +pub const TEXTURE_MAG_FILTER: u32 = 0x2800; +pub const TEXTURE_WRAP_S: u32 = 0x2802; +pub const TEXTURE_WRAP_T: u32 = 0x2803; + +pub const NEAREST: i32 = 0x2600; +pub const LINEAR: i32 = 0x2601; +pub const CLAMP_TO_EDGE: i32 = 0x812F; +pub const REPEAT: i32 = 0x2901; + +pub const RGBA: u32 = 0x1908; +pub const RGBA8: u32 = 0x8058; +pub const SRGB8_ALPHA8: u32 = 0x8C43; +pub const RED: u32 = 0x1903; +pub const RG: u32 = 0x8227; +pub const R8: u32 = 0x8229; +pub const RG8: u32 = 0x822B; +pub const DEPTH_COMPONENT: u32 = 0x1902; +pub const DEPTH_COMPONENT24: u32 = 0x81A6; + +pub const UNSIGNED_BYTE: u32 = 0x1401; +pub const UNSIGNED_SHORT: u32 = 0x1403; +pub const UNSIGNED_INT: u32 = 0x1405; +pub const FLOAT: u32 = 0x1406; + +pub const ARRAY_BUFFER: u32 = 0x8892; +pub const ELEMENT_ARRAY_BUFFER: u32 = 0x8893; +pub const STATIC_DRAW: u32 = 0x88E4; +pub const DYNAMIC_DRAW: u32 = 0x88E8; + +pub const TRIANGLES: u32 = 0x0004; + +pub const FRAMEBUFFER: u32 = 0x8D40; +pub const COLOR_ATTACHMENT0: u32 = 0x8CE0; +pub const DEPTH_ATTACHMENT: u32 = 0x8D00; +pub const FRAMEBUFFER_COMPLETE: u32 = 0x8CD5; +pub const UNPACK_ALIGNMENT: u32 = 0x0CF5; +pub const TEXTURE_3D: u32 = 0x806F; +pub const TEXTURE_2D_ARRAY: u32 = 0x8C1A; +pub const TEXTURE_WRAP_R: u32 = 0x8072; +pub const RGBA16F: u32 = 0x881A; +pub const HALF_FLOAT: u32 = 0x140B; +pub const COLOR_ATTACHMENT1: u32 = 0x8CE1; +pub const COLOR_ATTACHMENT2: u32 = 0x8CE2; +pub const COLOR_ATTACHMENT3: u32 = 0x8CE3; +pub const MAX_COLOR_ATTACHMENTS: u32 = 0x8CDF; +pub const MAX_DRAW_BUFFERS: u32 = 0x8824; +pub const LINEAR_MIPMAP_LINEAR: i32 = 0x2703; +pub const NEAREST_MIPMAP_NEAREST: i32 = 0x2700; + +#[link(wasm_import_module = "env")] +extern "C" { + fn glClearColor(r: f32, g: f32, b: f32, a: f32); + fn glClear(mask: u32); + fn glViewport(x: i32, y: i32, w: i32, h: i32); + fn glEnable(cap: u32); + fn glDisable(cap: u32); + fn glCullFace(mode: u32); + fn glDepthMask(flag: u32); + fn glColorMask(red: u32, green: u32, blue: u32, alpha: u32); + fn glBlendFunc(sfactor: u32, dfactor: u32); + fn glGetInteger(pname: u32) -> i32; + fn glReadPixels( + x: i32, + y: i32, + width: i32, + height: i32, + format: u32, + type_: u32, + ptr: *mut u8, + len: u32, + ); + + fn glCreateShader(type_: u32) -> u32; + fn glShaderSource(shader: u32, ptr: *const u8, len: u32); + fn glCompileShader(shader: u32); + fn glGetShaderiv(shader: u32, pname: u32) -> i32; + fn glGetShaderInfoLog(shader: u32, buf: *mut u8, len: u32) -> i32; + fn glDeleteShader(shader: u32); + + fn glCreateProgram() -> u32; + fn glAttachShader(program: u32, shader: u32); + fn glLinkProgram(program: u32); + fn glGetProgramiv(program: u32, pname: u32) -> i32; + fn glGetProgramInfoLog(program: u32, buf: *mut u8, len: u32) -> i32; + fn glUseProgram(program: u32); + fn glDeleteProgram(program: u32); + + fn glGetUniformLocation(program: u32, ptr: *const u8, len: u32) -> i32; + fn glUniform1i(loc: i32, value: i32); + fn glUniform1f(loc: i32, value: f32); + fn glUniform2f(loc: i32, x: f32, y: f32); + fn glUniform3f(loc: i32, x: f32, y: f32, z: f32); + fn glUniform4f(loc: i32, x: f32, y: f32, z: f32, w: f32); + fn glUniformMatrix4fv(loc: i32, count: i32, transpose: u32, ptr: *const f32); + fn glUniform3fv(loc: i32, count: i32, ptr: *const f32); + fn glUniform1fv(loc: i32, count: i32, ptr: *const f32); + + fn glGenTexture() -> u32; + fn glDeleteTexture(tex: u32); + fn glBindTexture(target: u32, tex: u32); + fn glActiveTexture(unit: u32); + fn glTexParameteri(target: u32, pname: u32, param: i32); + fn glTexImage2D( + target: u32, + level: i32, + internalFormat: i32, + width: i32, + height: i32, + border: i32, + format: u32, + type_: u32, + ptr: *const u8, + len: u32, + ); + + fn glGenBuffer() -> u32; + fn glDeleteBuffer(buf: u32); + fn glBindBuffer(target: u32, buf: u32); + fn glBufferData(target: u32, ptr: *const u8, len: u32, usage: u32); + + fn glGenVertexArray() -> u32; + fn glDeleteVertexArray(vao: u32); + fn glBindVertexArray(vao: u32); + fn glVertexAttribPointer( + index: u32, + size: i32, + type_: u32, + normalized: u32, + stride: i32, + offset: u32, + ); + fn glEnableVertexAttribArray(index: u32); + fn glDisableVertexAttribArray(index: u32); + + fn glDrawArrays(mode: u32, first: i32, count: i32); + fn glDrawElements(mode: u32, count: i32, type_: u32, offset: u32); + fn glVertexAttribDivisor(index: u32, divisor: u32); + fn glDrawElementsInstanced(mode: u32, count: i32, type_: u32, offset: u32, primcount: i32); + fn glDrawArraysInstanced(mode: u32, first: i32, count: i32, primcount: i32); + + fn glGenFramebuffer() -> u32; + fn glDeleteFramebuffer(fbo: u32); + fn glBindFramebuffer(target: u32, fbo: u32); + fn glFramebufferTexture2D(target: u32, attachment: u32, texTarget: u32, tex: u32, level: i32); + fn glCheckFramebufferStatus(target: u32) -> u32; + fn glDrawBuffers(ptr: *const u32, len: u32); + fn glPixelStorei(pname: u32, param: i32); + fn glTexImage3D( + target: u32, + level: i32, + internalFormat: i32, + width: i32, + height: i32, + depth: i32, + border: i32, + format: u32, + type_: u32, + ptr: *const u8, + len: u32, + ); + fn glTexSubImage3D( + target: u32, + level: i32, + xoffset: i32, + yoffset: i32, + zoffset: i32, + width: i32, + height: i32, + depth: i32, + format: u32, + type_: u32, + ptr: *const u8, + len: u32, + ); + fn glGenerateMipmap(target: u32); + fn glCapHalfFloatTarget() -> i32; + fn glFinish(); +} + +// Wrappers +pub fn clear_color(r: f32, g: f32, b: f32, a: f32) { + unsafe { + glClearColor(r, g, b, a); + } +} + +pub fn clear(mask: u32) { + unsafe { + glClear(mask); + } +} + +pub fn finish() { + unsafe { + glFinish(); + } +} + +pub fn viewport(x: i32, y: i32, w: i32, h: i32) { + unsafe { + glViewport(x, y, w, h); + } +} + +pub fn enable(cap: u32) { + unsafe { + glEnable(cap); + } +} + +pub fn disable(cap: u32) { + unsafe { + glDisable(cap); + } +} + +pub fn cull_face(mode: u32) { + unsafe { + glCullFace(mode); + } +} + +pub fn blend_func(sfactor: u32, dfactor: u32) { + unsafe { + glBlendFunc(sfactor, dfactor); + } +} + +pub fn depth_mask(flag: bool) { + unsafe { + glDepthMask(if flag { 1 } else { 0 }); + } +} + +pub fn color_mask(r: bool, g: bool, b: bool, a: bool) { + unsafe { + glColorMask( + if r { 1 } else { 0 }, + if g { 1 } else { 0 }, + if b { 1 } else { 0 }, + if a { 1 } else { 0 }, + ); + } +} + +pub fn create_shader(type_: u32) -> u32 { + unsafe { glCreateShader(type_) } +} + +pub fn shader_source(shader: u32, src: &[u8]) { + unsafe { + glShaderSource(shader, src.as_ptr(), src.len() as u32); + } +} + +pub fn compile_shader(shader: u32) { + unsafe { + glCompileShader(shader); + } +} + +pub fn get_shaderiv(shader: u32, pname: u32) -> i32 { + unsafe { glGetShaderiv(shader, pname) } +} + +pub fn get_shader_info_log(shader: u32, buf: &mut [u8]) -> usize { + unsafe { glGetShaderInfoLog(shader, buf.as_mut_ptr(), buf.len() as u32) as usize } +} + +pub fn delete_shader(shader: u32) { + unsafe { + glDeleteShader(shader); + } +} + +pub fn create_program() -> u32 { + unsafe { glCreateProgram() } +} + +pub fn attach_shader(program: u32, shader: u32) { + unsafe { + glAttachShader(program, shader); + } +} + +pub fn link_program(program: u32) { + unsafe { + glLinkProgram(program); + } +} + +pub fn get_programiv(program: u32, pname: u32) -> i32 { + unsafe { glGetProgramiv(program, pname) } +} + +pub fn get_program_info_log(program: u32, buf: &mut [u8]) -> usize { + unsafe { glGetProgramInfoLog(program, buf.as_mut_ptr(), buf.len() as u32) as usize } +} + +pub fn use_program(program: u32) { + unsafe { + glUseProgram(program); + } +} + +pub fn delete_program(program: u32) { + unsafe { + glDeleteProgram(program); + } +} + +pub fn get_uniform_location(program: u32, name: &str) -> i32 { + unsafe { glGetUniformLocation(program, name.as_ptr(), name.len() as u32) } +} + +pub fn uniform1i(location: i32, value: i32) { + unsafe { + glUniform1i(location, value); + } +} + +pub fn uniform1f(location: i32, value: f32) { + unsafe { + glUniform1f(location, value); + } +} + +pub fn uniform2f(location: i32, x: f32, y: f32) { + unsafe { + glUniform2f(location, x, y); + } +} + +pub fn uniform3f(location: i32, x: f32, y: f32, z: f32) { + unsafe { + glUniform3f(location, x, y, z); + } +} + +pub fn uniform4f(location: i32, x: f32, y: f32, z: f32, w: f32) { + unsafe { + glUniform4f(location, x, y, z, w); + } +} + +pub fn uniform1fv(location: i32, values: &[f32]) { + unsafe { + glUniform1fv(location, values.len() as i32, values.as_ptr()); + } +} + +pub fn uniform3fv(location: i32, values: &[f32]) { + unsafe { + glUniform3fv(location, (values.len() / 3) as i32, values.as_ptr()); + } +} + +pub fn uniform_matrix4fv(location: i32, transpose: bool, values: &[f32; 16]) { + unsafe { + glUniformMatrix4fv(location, 1, if transpose { 1 } else { 0 }, values.as_ptr()); + } +} + +pub fn uniform_matrix4fv_array(location: i32, values: &[f32]) { + unsafe { + glUniformMatrix4fv(location, (values.len() / 16) as i32, 0, values.as_ptr()); + } +} + +pub fn read_pixels( + x: i32, + y: i32, + width: i32, + height: i32, + format: u32, + type_: u32, + data: &mut [u8], +) { + unsafe { + glReadPixels( + x, + y, + width, + height, + format, + type_, + data.as_mut_ptr(), + data.len() as u32, + ); + } +} + +pub fn vertex_attrib_divisor(index: u32, divisor: u32) { + unsafe { + glVertexAttribDivisor(index, divisor); + } +} + +pub fn draw_elements_instanced(mode: u32, count: i32, type_: u32, offset: u32, primcount: i32) { + unsafe { + glDrawElementsInstanced(mode, count, type_, offset, primcount); + } +} + +pub fn draw_arrays_instanced(mode: u32, first: i32, count: i32, primcount: i32) { + unsafe { + glDrawArraysInstanced(mode, first, count, primcount); + } +} + +pub fn gen_texture() -> u32 { + unsafe { glGenTexture() } +} + +pub fn delete_texture(texture: u32) { + unsafe { + glDeleteTexture(texture); + } +} + +pub fn bind_texture(target: u32, texture: u32) { + unsafe { + glBindTexture(target, texture); + } +} + +pub fn active_texture(unit: u32) { + unsafe { + glActiveTexture(unit); + } +} + +pub fn tex_parameteri(target: u32, pname: u32, param: i32) { + unsafe { + glTexParameteri(target, pname, param); + } +} + +pub fn tex_image_2d( + target: u32, + level: i32, + internal_format: i32, + width: i32, + height: i32, + border: i32, + format: u32, + type_: u32, + data: Option<&[u8]>, +) { + let (ptr, len) = match data { + Some(d) => (d.as_ptr(), d.len() as u32), + None => (std::ptr::null(), 0), + }; + unsafe { + glTexImage2D( + target, + level, + internal_format, + width, + height, + border, + format, + type_, + ptr, + len, + ); + } +} + +pub fn gen_buffer() -> u32 { + unsafe { glGenBuffer() } +} + +pub fn delete_buffer(buffer: u32) { + unsafe { + glDeleteBuffer(buffer); + } +} + +pub fn bind_buffer(target: u32, buffer: u32) { + unsafe { + glBindBuffer(target, buffer); + } +} + +pub fn buffer_data(target: u32, data: &[u8], usage: u32) { + unsafe { + glBufferData(target, data.as_ptr(), data.len() as u32, usage); + } +} + +pub fn gen_vertex_array() -> u32 { + unsafe { glGenVertexArray() } +} + +pub fn delete_vertex_array(vao: u32) { + unsafe { + glDeleteVertexArray(vao); + } +} + +pub fn bind_vertex_array(vao: u32) { + unsafe { + glBindVertexArray(vao); + } +} + +pub fn vertex_attrib_pointer( + index: u32, + size: i32, + type_: u32, + normalized: bool, + stride: i32, + offset: u32, +) { + unsafe { + glVertexAttribPointer( + index, + size, + type_, + if normalized { 1 } else { 0 }, + stride, + offset, + ); + } +} + +pub fn enable_vertex_attrib_array(index: u32) { + unsafe { + glEnableVertexAttribArray(index); + } +} + +pub fn disable_vertex_attrib_array(index: u32) { + unsafe { + glDisableVertexAttribArray(index); + } +} + +pub fn draw_arrays(mode: u32, first: i32, count: i32) { + unsafe { + glDrawArrays(mode, first, count); + } +} + +pub fn draw_elements(mode: u32, count: i32, type_: u32, offset: u32) { + unsafe { + glDrawElements(mode, count, type_, offset); + } +} + +pub fn gen_framebuffer() -> u32 { + unsafe { glGenFramebuffer() } +} + +pub fn delete_framebuffer(fbo: u32) { + unsafe { + glDeleteFramebuffer(fbo); + } +} + +pub fn bind_framebuffer(target: u32, framebuffer: u32) { + unsafe { + glBindFramebuffer(target, framebuffer); + } +} + +pub fn framebuffer_texture_2d( + target: u32, + attachment: u32, + textarget: u32, + texture: u32, + level: i32, +) { + unsafe { + glFramebufferTexture2D(target, attachment, textarget, texture, level); + } +} + +pub fn check_framebuffer_status(target: u32) -> u32 { + unsafe { glCheckFramebufferStatus(target) } +} + +pub fn draw_buffers(buffers: &[u32]) { + unsafe { + glDrawBuffers(buffers.as_ptr(), buffers.len() as u32); + } +} + +pub fn pixel_storei(pname: u32, param: i32) { + unsafe { + glPixelStorei(pname, param); + } +} + +pub fn disable_draw_buffer() { + unsafe { + glDrawBuffers([0].as_ptr(), 1); // gl.NONE + } +} + +#[allow(clippy::too_many_arguments)] +pub fn tex_image_3d( + target: u32, + level: i32, + internal_format: i32, + width: i32, + height: i32, + depth: i32, + border: i32, + format: u32, + type_: u32, + data: Option<&[u8]>, +) { + let (ptr, len) = match data { + Some(d) => (d.as_ptr(), d.len() as u32), + None => (core::ptr::null(), 0), + }; + unsafe { + glTexImage3D( + target, + level, + internal_format, + width, + height, + depth, + border, + format, + type_, + ptr, + len, + ); + } +} + +#[allow(clippy::too_many_arguments)] +pub fn tex_sub_image_3d( + target: u32, + level: i32, + xoffset: i32, + yoffset: i32, + zoffset: i32, + width: i32, + height: i32, + depth: i32, + format: u32, + type_: u32, + data: &[u8], +) { + unsafe { + glTexSubImage3D( + target, + level, + xoffset, + yoffset, + zoffset, + width, + height, + depth, + format, + type_, + data.as_ptr(), + data.len() as u32, + ); + } +} + +pub fn generate_mipmap(target: u32) { + unsafe { + glGenerateMipmap(target); + } +} + +pub fn get_integer(pname: u32) -> i32 { + unsafe { glGetInteger(pname) } +} + +/// WebGL2 half-float color-attachment support, probed via extension at init. +pub fn cap_half_float_target() -> bool { + unsafe { glCapHalfFloatTarget() != 0 } +} diff --git a/client-rust/source/platform/src/web/mod.rs b/client-rust/source/platform/src/web/mod.rs new file mode 100644 index 00000000..840415a6 --- /dev/null +++ b/client-rust/source/platform/src/web/mod.rs @@ -0,0 +1,216 @@ +//! Web platform implementation. + +pub mod gl; +pub mod net; + +use successor_engine_core::input::Key; + +#[link(wasm_import_module = "env")] +extern "C" { + fn js_log(ptr: *const u8, len: u32); + fn js_init(ptr: *const u8, len: u32, width: i32, height: i32); + fn js_get_canvas_size(width: *mut i32, height: *mut i32); + fn js_now_ms() -> f64; + fn js_is_key_down(key: u32) -> u32; + fn js_set_cursor_visible(visible: u32); + fn js_poll_char() -> i32; + fn js_poll_scroll_x() -> f32; + fn js_poll_scroll_y() -> f32; + fn js_get_mouse_x() -> f32; + fn js_get_mouse_y() -> f32; + fn js_mouse_button_down(button: u32) -> u32; + fn js_launch_context_len() -> u32; + fn js_launch_context_copy(ptr: *mut u8, max_len: u32) -> u32; + fn js_creator_mode() -> u32; + fn js_creator_ready(); + fn js_creator_message_len() -> u32; + fn js_creator_message_copy(ptr: *mut u8, max_len: u32) -> u32; + fn js_creator_message_discard(); + fn js_creator_post_create(ptr: *const u8, len: u32) -> u32; + fn js_creator_post_select(ptr: *const u8, len: u32) -> u32; + fn js_audio_unlock(); + fn js_audio_play( + ptr: *const u8, + len: u32, + key: u32, + gain: f32, + pan: f32, + looped: u32, + polyphony: u32, + ) -> u32; + fn js_audio_stop(key: u32); + fn js_audio_active_voices() -> u32; +} + +fn web_log_sink(s: &str) { + unsafe { + js_log(s.as_ptr(), s.len() as u32); + } +} + +pub fn init(title: &str, w: i32, h: i32) -> bool { + successor_engine_core::rt::log::set_sink(web_log_sink); + unsafe { + js_init(title.as_ptr(), title.len() as u32, w, h); + } + true +} + +pub fn should_quit() -> bool { + false +} + +pub fn begin_frame() {} + +pub fn end_frame() {} + +pub fn deinit() {} + +pub fn framebuffer_size() -> (i32, i32) { + let mut w = 0; + let mut h = 0; + unsafe { + js_get_canvas_size(&mut w, &mut h); + } + (w, h) +} + +pub fn mouse_position() -> (f32, f32) { + unsafe { (js_get_mouse_x(), js_get_mouse_y()) } +} + +pub fn mouse_button_down(button: i32) -> bool { + button >= 0 && unsafe { js_mouse_button_down(button as u32) != 0 } +} + +pub fn launch_context() -> Option<Vec<u8>> { + let len = unsafe { js_launch_context_len() } as usize; + if len == 0 || len > 1024 * 1024 { + return None; + } + let mut bytes = vec![0; len]; + let copied = unsafe { js_launch_context_copy(bytes.as_mut_ptr(), len as u32) } as usize; + (copied == len).then_some(bytes) +} + +/// Creator parent bridge is only active for the public `?mode=creator` child +/// surface. It cannot become an alternate launch or account path. +pub fn creator_mode() -> bool { + unsafe { js_creator_mode() != 0 } +} + +/// Announce the child after its Rust creator state is ready to receive the +/// parent’s bounded roster projection. +pub fn creator_ready() { + unsafe { js_creator_ready() } +} + +/// Takes one normalized parent message. The JavaScript bridge enforces exact +/// source/origin and a fixed queue; this is a second byte bound before parsing. +pub fn take_creator_message() -> Option<Vec<u8>> { + const MAX_MESSAGE_BYTES: usize = 16 * 1024; + let len = unsafe { js_creator_message_len() } as usize; + if len == 0 { + return None; + } + if len > MAX_MESSAGE_BYTES { + unsafe { js_creator_message_discard() }; + return None; + } + let mut bytes = vec![0; len]; + let copied = unsafe { js_creator_message_copy(bytes.as_mut_ptr(), len as u32) } as usize; + (copied == len).then_some(bytes) +} + +/// Sends the fixed creator-create envelope. JavaScript parses and rebuilds it +/// before `postMessage`, so arbitrary fields can never cross the iframe fence. +pub fn post_creator_create(message: &str) -> bool { + const MAX_CREATE_BYTES: usize = 4 * 1024; + !message.is_empty() + && message.len() <= MAX_CREATE_BYTES + && unsafe { js_creator_post_create(message.as_ptr(), message.len() as u32) != 0 } +} + +/// Sends a stable, bounded opaque character id for the parent’s one-shot +/// handoff. The browser side validates it again before it leaves the frame. +pub fn post_creator_select(character_id: &str) -> bool { + !character_id.is_empty() + && character_id.len() <= 128 + && unsafe { js_creator_post_select(character_id.as_ptr(), character_id.len() as u32) != 0 } +} + +pub fn unlock_audio() { + unsafe { js_audio_unlock() } +} + +pub fn audio_play(path: &str, key: u32, gain: f32, pan: f32, looped: bool, polyphony: u32) -> bool { + unsafe { + js_audio_play( + path.as_ptr(), + path.len() as u32, + key, + gain, + pan, + looped as u32, + polyphony, + ) != 0 + } +} + +pub fn audio_stop(key: u32) { + unsafe { js_audio_stop(key) } +} + +pub fn audio_active_voices() -> u32 { + unsafe { js_audio_active_voices() } +} + +pub fn now_ms() -> f64 { + unsafe { js_now_ms() } +} + +pub fn is_key_down(key: Key) -> bool { + unsafe { js_is_key_down(key as u32) != 0 } +} + +pub fn set_cursor_visible(visible: bool) { + unsafe { + js_set_cursor_visible(if visible { 1 } else { 0 }); + } +} + +pub fn poll_text_input() -> Option<char> { + let res = unsafe { js_poll_char() }; + if res >= 0 { + std::char::from_u32(res as u32) + } else { + None + } +} + +pub fn poll_scroll_delta() -> Option<(f32, f32)> { + let delta = unsafe { (js_poll_scroll_x(), js_poll_scroll_y()) }; + if delta.0 == 0.0 && delta.1 == 0.0 { + None + } else { + Some(delta) + } +} + +pub mod http { + pub use super::net::http_post_json; +} + +pub fn read_pixels_rgba(width: i32, height: i32) -> Vec<u8> { + let mut pixels = vec![0; (width.max(0) * height.max(0) * 4) as usize]; + gl::read_pixels( + 0, + 0, + width, + height, + gl::RGBA, + gl::UNSIGNED_BYTE, + &mut pixels, + ); + pixels +} diff --git a/client-rust/source/platform/src/web/net.rs b/client-rust/source/platform/src/web/net.rs new file mode 100644 index 00000000..ae6bc026 --- /dev/null +++ b/client-rust/source/platform/src/web/net.rs @@ -0,0 +1,169 @@ +//! Web WebSocket and fetch transport implementation. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WsEvent { + Open, + Frame(usize), + Closed, + Error, + None, +} + +pub struct WsHandle { + pub(crate) id: u32, +} + +#[link(wasm_import_module = "env")] +extern "C" { + fn js_ws_connect(url_ptr: *const u8, url_len: u32) -> u32; + fn js_ws_send(id: u32, data_ptr: *const u8, data_len: u32); + fn js_ws_poll(id: u32, buf_ptr: *mut u8, max_len: u32) -> i32; + #[allow(dead_code)] + fn js_ws_close(id: u32); + + fn js_fetch_post_json( + url_ptr: *const u8, + url_len: u32, + body_ptr: *const u8, + body_len: u32, + out_buf_ptr: *mut u8, + out_buf_max_len: u32, + ) -> i32; + + // Two-phase blob fetch: call with a null/zero buffer to learn the total + // length, then call again with an allocated buffer to copy. Returns the + // resource's total byte length on success (>=0), or -1 on error. The shim + // caches the last fetched url so the network hit happens once. + fn js_fetch_get( + url_ptr: *const u8, + url_len: u32, + out_buf_ptr: *mut u8, + out_buf_max_len: u32, + ) -> i32; + + // Async asset channel. begin starts a shim-side fetch (pack-index and + // object-store aware, exactly like js_fetch_get's fallback chain) and + // returns a nonzero handle, or 0 when the request cannot start. poll + // mirrors the two-phase protocol with one extra state: -1 pending, + // -2 failed, >=0 ready (byte length). + fn js_asset_begin(path_ptr: *const u8, path_len: u32) -> u32; + fn js_asset_poll(id: u32, out_buf_ptr: *mut u8, out_buf_max_len: u32) -> i32; +} + +pub fn ws_connect(url_str: &str) -> Result<WsHandle, String> { + let id = unsafe { js_ws_connect(url_str.as_ptr(), url_str.len() as u32) }; + if id == 0 { + Err("Failed to connect WebSocket".to_string()) + } else { + Ok(WsHandle { id }) + } +} + +pub fn ws_send(handle: &mut WsHandle, data: &[u8]) { + unsafe { + js_ws_send(handle.id, data.as_ptr(), data.len() as u32); + } +} + +pub fn ws_poll(handle: &mut WsHandle, out_buf: &mut Vec<u8>) -> WsEvent { + let capacity = out_buf.capacity(); + if capacity < 65536 { + out_buf.reserve(65536 - capacity); + } + + let spare_ptr = out_buf.as_mut_ptr(); + let res = unsafe { js_ws_poll(handle.id, spare_ptr, 65536) }; + + match res { + 0 => WsEvent::None, + -1 => WsEvent::Closed, + -2 => WsEvent::Error, + -3 => WsEvent::Open, + len if len > 0 => { + unsafe { + out_buf.set_len(len as usize); + } + WsEvent::Frame(len as usize) + } + _ => WsEvent::Error, + } +} + +pub fn http_post_json(url_str: &str, body: &[u8]) -> Result<Vec<u8>, String> { + let mut out_buf = vec![0u8; 65536]; + let res = unsafe { + js_fetch_post_json( + url_str.as_ptr(), + url_str.len() as u32, + body.as_ptr(), + body.len() as u32, + out_buf.as_mut_ptr(), + out_buf.len() as u32, + ) + }; + if res < 0 { + Err(format!("fetch_post_json failed with code {}", res)) + } else { + out_buf.truncate(res as usize); + Ok(out_buf) + } +} + +/// HTTP GET returning the raw response body via the two-phase shim protocol. +pub fn http_get(url_str: &str) -> Result<Vec<u8>, String> { + let total = unsafe { + js_fetch_get( + url_str.as_ptr(), + url_str.len() as u32, + core::ptr::null_mut(), + 0, + ) + }; + if total < 0 { + return Err(format!("fetch_get failed for {url_str}")); + } + let mut buf = vec![0u8; total as usize]; + let written = unsafe { + js_fetch_get( + url_str.as_ptr(), + url_str.len() as u32, + buf.as_mut_ptr(), + buf.len() as u32, + ) + }; + if written < 0 { + return Err(format!("fetch_get copy failed for {url_str}")); + } + buf.truncate(written as usize); + Ok(buf) +} + +/// Start an asynchronous fetch of `stable_id`; `None` when the shim cannot +/// start the request. +pub fn asset_begin(stable_id: &str) -> Option<u32> { + let id = unsafe { js_asset_begin(stable_id.as_ptr(), stable_id.len() as u32) }; + if id == 0 { + None + } else { + Some(id) + } +} + +/// Poll an in-flight async fetch; on `Ready` the bytes are copied out and the +/// shim-side entry is consumed. +pub fn asset_poll(id: u32) -> crate::AssetPoll { + let total = unsafe { js_asset_poll(id, core::ptr::null_mut(), 0) }; + if total == -1 { + return crate::AssetPoll::Pending; + } + if total < 0 { + return crate::AssetPoll::Failed; + } + let mut buf = vec![0u8; total as usize]; + let written = unsafe { js_asset_poll(id, buf.as_mut_ptr(), total as u32) }; + if written < 0 { + return crate::AssetPoll::Failed; + } + buf.truncate(written as usize); + crate::AssetPoll::Ready(buf) +} diff --git a/client-rust/tools/bake-assets/Cargo.lock b/client-rust/tools/bake-assets/Cargo.lock new file mode 100644 index 00000000..05875a7a --- /dev/null +++ b/client-rust/tools/bake-assets/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bake-assets" +version = "0.0.1" diff --git a/client-rust/tools/bake-assets/Cargo.toml b/client-rust/tools/bake-assets/Cargo.toml new file mode 100644 index 00000000..d1fdfd45 --- /dev/null +++ b/client-rust/tools/bake-assets/Cargo.toml @@ -0,0 +1,19 @@ +# Non-shipped, build-time asset baker: rasterizes the client-3d SVG icon +# vocabulary into a committed A8 atlas consumed by the Rust client UI. Detached +# from the client workspace (own [workspace] table) so it never enters the +# size/alloc gates and pulls no deps into the shipped binary. +[package] +name = "bake-assets" +version = "0.0.1" +edition = "2021" +license = "MIT" +publish = false + +[[bin]] +name = "bake-assets" +path = "src/main.rs" + +[workspace] + +[profile.release] +opt-level = 2 diff --git a/client-rust/tools/bake-assets/src/main.rs b/client-rust/tools/bake-assets/src/main.rs new file mode 100644 index 00000000..4a1c5765 --- /dev/null +++ b/client-rust/tools/bake-assets/src/main.rs @@ -0,0 +1,619 @@ +//! Rasterizes the `client-3d` SVG icon vocabulary (`src/ui/icons.ts`, viewBox +//! 0 0 24, stroke-width 1.5, round caps/joins) into a committed A8 atlas the +//! Rust client UI samples. Distance-field stroking: every primitive (rect/ +//! circle/ellipse/path incl. arcs + cubics) flattens to polylines, then each +//! output pixel takes coverage from its distance to the nearest segment — which +//! yields round caps and joins for free with cheap anti-aliasing. +//! +//! Run: `cargo run --release --manifest-path tools/bake-assets/Cargo.toml` +//! Outputs (committed): `source/app/assets/ui/icons.a8` + `icons.json`. + +use std::f32::consts::PI; +use std::fs; +use std::path::Path; + +const VIEWBOX: f32 = 24.0; +const STROKE_HALF: f32 = 0.75; // stroke-width 1.5 / 2 +const CELL: usize = 32; +const COLS: usize = 8; + +type Pt = [f32; 2]; +type Polyline = Vec<Pt>; + +fn main() { + // Resolve paths relative to the client-rust workspace root (two levels up + // from tools/bake-assets/). + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .to_path_buf(); + let icons_ts = root.join("../client-3d/src/ui/icons.ts"); + let src = fs::read_to_string(&icons_ts) + .unwrap_or_else(|e| panic!("read {}: {e}", icons_ts.display())); + + let mut entries = extract_icons(&src); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + println!("baking {} icons", entries.len()); + + let rows = entries.len().div_ceil(COLS); + let aw = COLS * CELL; + let ah = rows * CELL; + let mut atlas = vec![0u8; aw * ah]; + + let mut json_icons = String::new(); + for (k, (id, svg)) in entries.iter().enumerate() { + let polylines = parse_svg(svg); + let cell = rasterize(&polylines, CELL); + let (col, row) = (k % COLS, k / COLS); + blit(&mut atlas, aw, &cell, CELL, col * CELL, row * CELL); + if k > 0 { + json_icons.push(','); + } + json_icons.push_str(&format!( + "{{\"id\":\"{id}\",\"col\":{col},\"row\":{row}}}" + )); + } + + let out_dir = root.join("source/app/assets/ui"); + fs::create_dir_all(&out_dir).unwrap(); + fs::write(out_dir.join("icons.a8"), &atlas).unwrap(); + let json = format!( + "{{\"cell\":{CELL},\"cols\":{COLS},\"width\":{aw},\"height\":{ah},\"icons\":[{json_icons}]}}" + ); + fs::write(out_dir.join("icons.json"), json).unwrap(); + println!("wrote {}/icons.a8 ({aw}x{ah}) + icons.json", out_dir.display()); +} + +// ── icons.ts extraction ───────────────────────────────────────────────────── + +/// Extract `(id, inner-svg)` for each `UI_ICONS` entry. `inner-svg` is the +/// concatenation of the single-quoted string literals passed to `icon(...)`. +fn extract_icons(src: &str) -> Vec<(String, String)> { + let b = src.as_bytes(); + let mut out = Vec::new(); + let needle = b"icon("; + let mut i = 0; + while i + needle.len() <= b.len() { + if &b[i..i + needle.len()] == needle { + if let Some(id) = key_before(b, i) { + let (svg, end) = collect_arg(b, i + needle.len()); + out.push((id, svg)); + i = end; + continue; + } + } + i += 1; + } + out +} + +/// Walk backward from the `i` (`icon(` start) over `whitespace : whitespace` to +/// read the preceding key token (quoted or bare identifier). +fn key_before(b: &[u8], i: usize) -> Option<String> { + let mut j = i; + while j > 0 && b[j - 1].is_ascii_whitespace() { + j -= 1; + } + if j == 0 || b[j - 1] != b':' { + return None; + } + j -= 1; + while j > 0 && b[j - 1].is_ascii_whitespace() { + j -= 1; + } + if j == 0 { + return None; + } + if b[j - 1] == b'"' { + let end = j - 1; + let mut k = end; + while k > 0 && b[k - 1] != b'"' { + k -= 1; + } + Some(String::from_utf8_lossy(&b[k..end]).into_owned()) + } else { + let end = j; + let mut k = end; + while k > 0 && (b[k - 1].is_ascii_alphanumeric() || b[k - 1] == b'-') { + k -= 1; + } + if k == end { + None + } else { + Some(String::from_utf8_lossy(&b[k..end]).into_owned()) + } + } +} + +/// From just after `icon(`, gather the concatenated single-quoted literals until +/// the matching close paren. Returns the joined string and the index past `)`. +fn collect_arg(b: &[u8], mut i: usize) -> (String, usize) { + let mut svg = String::new(); + while i < b.len() { + match b[i] { + b')' => { + i += 1; + break; + } + b'\'' => { + i += 1; + let start = i; + while i < b.len() && b[i] != b'\'' { + i += 1; + } + svg.push_str(&String::from_utf8_lossy(&b[start..i])); + i += 1; + } + _ => i += 1, + } + } + (svg, i) +} + +// ── SVG primitive parsing → polylines ─────────────────────────────────────── + +fn parse_svg(svg: &str) -> Vec<Polyline> { + let mut lines = Vec::new(); + let b = svg.as_bytes(); + let mut i = 0; + while i < b.len() { + if b[i] == b'<' { + let (tag, attrs, next) = read_tag(b, i); + i = next; + match tag.as_str() { + "path" => { + if let Some(d) = attr(&attrs, "d") { + parse_path(&d, &mut lines); + } + } + "rect" => lines.extend(rect_polyline(&attrs)), + "circle" => { + let cx = attr_f(&attrs, "cx"); + let cy = attr_f(&attrs, "cy"); + let r = attr_f(&attrs, "r"); + lines.push(ellipse_polyline(cx, cy, r, r)); + } + "ellipse" => { + let cx = attr_f(&attrs, "cx"); + let cy = attr_f(&attrs, "cy"); + let rx = attr_f(&attrs, "rx"); + let ry = attr_f(&attrs, "ry"); + lines.push(ellipse_polyline(cx, cy, rx, ry)); + } + _ => {} + } + } else { + i += 1; + } + } + lines +} + +fn read_tag(b: &[u8], start: usize) -> (String, Vec<(String, String)>, usize) { + let mut i = start + 1; + let name_start = i; + while i < b.len() && (b[i].is_ascii_alphanumeric()) { + i += 1; + } + let tag = String::from_utf8_lossy(&b[name_start..i]).into_owned(); + let mut attrs = Vec::new(); + while i < b.len() && b[i] != b'>' { + if b[i].is_ascii_alphabetic() { + let ks = i; + while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'-') { + i += 1; + } + let key = String::from_utf8_lossy(&b[ks..i]).into_owned(); + // skip = and quote + while i < b.len() && b[i] != b'"' && b[i] != b'>' { + i += 1; + } + if i < b.len() && b[i] == b'"' { + i += 1; + let vs = i; + while i < b.len() && b[i] != b'"' { + i += 1; + } + let val = String::from_utf8_lossy(&b[vs..i]).into_owned(); + i += 1; + attrs.push((key, val)); + } + } else { + i += 1; + } + } + if i < b.len() { + i += 1; // past '>' + } + (tag, attrs, i) +} + +fn attr(attrs: &[(String, String)], k: &str) -> Option<String> { + attrs.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone()) +} +fn attr_f(attrs: &[(String, String)], k: &str) -> f32 { + attr(attrs, k).and_then(|v| v.parse().ok()).unwrap_or(0.0) +} + +fn rect_polyline(attrs: &[(String, String)]) -> Vec<Polyline> { + let x = attr_f(attrs, "x"); + let y = attr_f(attrs, "y"); + let w = attr_f(attrs, "width"); + let h = attr_f(attrs, "height"); + let rx = attr(attrs, "rx").and_then(|v| v.parse().ok()).unwrap_or(0.0); + let ry = attr(attrs, "ry").and_then(|v| v.parse().ok()).unwrap_or(rx); + let mut p = Vec::new(); + if rx <= 0.0 && ry <= 0.0 { + p.push([x, y]); + p.push([x + w, y]); + p.push([x + w, y + h]); + p.push([x, y + h]); + p.push([x, y]); + return vec![p]; + } + let arc = |p: &mut Vec<Pt>, cx: f32, cy: f32, a0: f32, a1: f32| { + let n = 8; + for i in 0..=n { + let t = a0 + (a1 - a0) * i as f32 / n as f32; + p.push([cx + rx * t.cos(), cy + ry * t.sin()]); + } + }; + p.push([x + rx, y]); + p.push([x + w - rx, y]); + arc(&mut p, x + w - rx, y + ry, -PI / 2.0, 0.0); + p.push([x + w, y + h - ry]); + arc(&mut p, x + w - rx, y + h - ry, 0.0, PI / 2.0); + p.push([x + rx, y + h]); + arc(&mut p, x + rx, y + h - ry, PI / 2.0, PI); + p.push([x, y + ry]); + arc(&mut p, x + rx, y + ry, PI, 1.5 * PI); + vec![p] +} + +fn ellipse_polyline(cx: f32, cy: f32, rx: f32, ry: f32) -> Polyline { + let n = 48; + let mut p = Vec::with_capacity(n + 1); + for i in 0..=n { + let t = 2.0 * PI * i as f32 / n as f32; + p.push([cx + rx * t.cos(), cy + ry * t.sin()]); + } + p +} + +// ── SVG path `d` → polylines ──────────────────────────────────────────────── + +struct Cursor<'a> { + b: &'a [u8], + i: usize, +} +impl<'a> Cursor<'a> { + fn skip_sep(&mut self) { + while self.i < self.b.len() { + let c = self.b[self.i]; + if c == b',' || c.is_ascii_whitespace() { + self.i += 1; + } else { + break; + } + } + } + fn peek_cmd(&mut self) -> Option<u8> { + self.skip_sep(); + if self.i < self.b.len() && self.b[self.i].is_ascii_alphabetic() { + let c = self.b[self.i]; + self.i += 1; + Some(c) + } else { + None + } + } + fn num(&mut self) -> Option<f32> { + self.skip_sep(); + let start = self.i; + if self.i < self.b.len() && (self.b[self.i] == b'-' || self.b[self.i] == b'+') { + self.i += 1; + } + let mut seen = false; + while self.i < self.b.len() && self.b[self.i].is_ascii_digit() { + self.i += 1; + seen = true; + } + if self.i < self.b.len() && self.b[self.i] == b'.' { + self.i += 1; + while self.i < self.b.len() && self.b[self.i].is_ascii_digit() { + self.i += 1; + seen = true; + } + } + if seen && self.i < self.b.len() && (self.b[self.i] == b'e' || self.b[self.i] == b'E') { + self.i += 1; + if self.i < self.b.len() && (self.b[self.i] == b'-' || self.b[self.i] == b'+') { + self.i += 1; + } + while self.i < self.b.len() && self.b[self.i].is_ascii_digit() { + self.i += 1; + } + } + if !seen { + self.i = start; + return None; + } + std::str::from_utf8(&self.b[start..self.i]).ok()?.parse().ok() + } + fn has_num(&mut self) -> bool { + self.skip_sep(); + self.i < self.b.len() + && (self.b[self.i].is_ascii_digit() + || self.b[self.i] == b'-' + || self.b[self.i] == b'+' + || self.b[self.i] == b'.') + } +} + +fn parse_path(d: &str, out: &mut Vec<Polyline>) { + let mut c = Cursor { b: d.as_bytes(), i: 0 }; + let mut cur: Pt = [0.0, 0.0]; + let mut start: Pt = [0.0, 0.0]; + let mut poly: Polyline = Vec::new(); + let mut cmd = 0u8; + + macro_rules! flush { + () => { + if poly.len() > 1 { + out.push(std::mem::take(&mut poly)); + } else { + poly.clear(); + } + }; + } + + loop { + let next = c.peek_cmd(); + match next { + Some(k) => cmd = k, + None => { + if !c.has_num() { + break; + } + // implicit repeat: M/m repeats as L/l + cmd = match cmd { + b'M' => b'L', + b'm' => b'l', + other => other, + }; + } + } + let rel = cmd.is_ascii_lowercase(); + match cmd.to_ascii_uppercase() { + b'M' => { + let (x, y) = (c.num().unwrap(), c.num().unwrap()); + flush!(); + cur = if rel { [cur[0] + x, cur[1] + y] } else { [x, y] }; + start = cur; + poly.push(cur); + } + b'L' => { + let (x, y) = (c.num().unwrap(), c.num().unwrap()); + cur = if rel { [cur[0] + x, cur[1] + y] } else { [x, y] }; + poly.push(cur); + } + b'H' => { + let x = c.num().unwrap(); + cur = if rel { [cur[0] + x, cur[1]] } else { [x, cur[1]] }; + poly.push(cur); + } + b'V' => { + let y = c.num().unwrap(); + cur = if rel { [cur[0], cur[1] + y] } else { [cur[0], y] }; + poly.push(cur); + } + b'C' => { + let n: Vec<f32> = (0..6).map(|_| c.num().unwrap()).collect(); + let (p1, p2, p3) = if rel { + ( + [cur[0] + n[0], cur[1] + n[1]], + [cur[0] + n[2], cur[1] + n[3]], + [cur[0] + n[4], cur[1] + n[5]], + ) + } else { + ([n[0], n[1]], [n[2], n[3]], [n[4], n[5]]) + }; + cubic(cur, p1, p2, p3, &mut poly); + cur = p3; + } + b'Q' => { + let n: Vec<f32> = (0..4).map(|_| c.num().unwrap()).collect(); + let (p1, p2) = if rel { + ([cur[0] + n[0], cur[1] + n[1]], [cur[0] + n[2], cur[1] + n[3]]) + } else { + ([n[0], n[1]], [n[2], n[3]]) + }; + quad(cur, p1, p2, &mut poly); + cur = p2; + } + b'A' => { + let rx = c.num().unwrap(); + let ry = c.num().unwrap(); + let rot = c.num().unwrap(); + let large = c.num().unwrap() != 0.0; + let sweep = c.num().unwrap() != 0.0; + let (ex, ey) = (c.num().unwrap(), c.num().unwrap()); + let end = if rel { [cur[0] + ex, cur[1] + ey] } else { [ex, ey] }; + arc_to(cur, rx, ry, rot, large, sweep, end, &mut poly); + cur = end; + } + b'Z' => { + poly.push(start); + flush!(); + cur = start; + } + _ => { + // Unknown command: consume a number to avoid an infinite loop. + let _ = c.num(); + } + } + } + flush!(); +} + +fn cubic(p0: Pt, p1: Pt, p2: Pt, p3: Pt, out: &mut Polyline) { + let n = 16; + for i in 1..=n { + let t = i as f32 / n as f32; + let u = 1.0 - t; + let x = u * u * u * p0[0] + 3.0 * u * u * t * p1[0] + 3.0 * u * t * t * p2[0] + t * t * t * p3[0]; + let y = u * u * u * p0[1] + 3.0 * u * u * t * p1[1] + 3.0 * u * t * t * p2[1] + t * t * t * p3[1]; + out.push([x, y]); + } +} +fn quad(p0: Pt, p1: Pt, p2: Pt, out: &mut Polyline) { + let n = 14; + for i in 1..=n { + let t = i as f32 / n as f32; + let u = 1.0 - t; + let x = u * u * p0[0] + 2.0 * u * t * p1[0] + t * t * p2[0]; + let y = u * u * p0[1] + 2.0 * u * t * p1[1] + t * t * p2[1]; + out.push([x, y]); + } +} + +/// SVG elliptical-arc endpoint parametrization (spec F.6.5), sampled to a +/// polyline appended after the current point. +#[allow(clippy::too_many_arguments)] +fn arc_to(p0: Pt, mut rx: f32, mut ry: f32, rot_deg: f32, large: bool, sweep: bool, p1: Pt, out: &mut Polyline) { + if rx == 0.0 || ry == 0.0 || (p0[0] == p1[0] && p0[1] == p1[1]) { + out.push(p1); + return; + } + rx = rx.abs(); + ry = ry.abs(); + let phi = rot_deg * PI / 180.0; + let (cp, sp) = (phi.cos(), phi.sin()); + let dx = (p0[0] - p1[0]) / 2.0; + let dy = (p0[1] - p1[1]) / 2.0; + let x1 = cp * dx + sp * dy; + let y1 = -sp * dx + cp * dy; + // Correct out-of-range radii. + let lam = x1 * x1 / (rx * rx) + y1 * y1 / (ry * ry); + if lam > 1.0 { + let s = lam.sqrt(); + rx *= s; + ry *= s; + } + let sign = if large != sweep { 1.0 } else { -1.0 }; + let num = (rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1).max(0.0); + let den = rx * rx * y1 * y1 + ry * ry * x1 * x1; + let co = sign * (num / den).sqrt(); + let cx1 = co * rx * y1 / ry; + let cy1 = -co * ry * x1 / rx; + let cx = cp * cx1 - sp * cy1 + (p0[0] + p1[0]) / 2.0; + let cy = sp * cx1 + cp * cy1 + (p0[1] + p1[1]) / 2.0; + let ang = |ux: f32, uy: f32, vx: f32, vy: f32| -> f32 { + let dot = ux * vx + uy * vy; + let len = (ux * ux + uy * uy).sqrt() * (vx * vx + vy * vy).sqrt(); + let mut a = (dot / len).clamp(-1.0, 1.0).acos(); + if ux * vy - uy * vx < 0.0 { + a = -a; + } + a + }; + let theta1 = ang(1.0, 0.0, (x1 - cx1) / rx, (y1 - cy1) / ry); + let mut dtheta = ang((x1 - cx1) / rx, (y1 - cy1) / ry, (-x1 - cx1) / rx, (-y1 - cy1) / ry); + if !sweep && dtheta > 0.0 { + dtheta -= 2.0 * PI; + } else if sweep && dtheta < 0.0 { + dtheta += 2.0 * PI; + } + let steps = (dtheta.abs() / (PI / 16.0)).ceil().max(2.0) as usize; + for i in 1..=steps { + let t = theta1 + dtheta * i as f32 / steps as f32; + let (ct, st) = (t.cos(), t.sin()); + let ex = cp * (rx * ct) - sp * (ry * st) + cx; + let ey = sp * (rx * ct) + cp * (ry * st) + cy; + out.push([ex, ey]); + } +} + +// ── distance-field rasterization ──────────────────────────────────────────── + +fn rasterize(polylines: &[Polyline], size: usize) -> Vec<u8> { + let mut out = vec![0u8; size * size]; + let scale = VIEWBOX / size as f32; + let aa = 0.6 * scale; + for iy in 0..size { + for ix in 0..size { + let px = (ix as f32 + 0.5) * scale; + let py = (iy as f32 + 0.5) * scale; + let mut best = f32::MAX; + for line in polylines { + for w in line.windows(2) { + let d = dist_seg(px, py, w[0], w[1]); + if d < best { + best = d; + } + } + } + let cov = ((STROKE_HALF + aa - best) / (2.0 * aa)).clamp(0.0, 1.0); + out[iy * size + ix] = (cov * 255.0 + 0.5) as u8; + } + } + out +} + +fn dist_seg(px: f32, py: f32, a: Pt, b: Pt) -> f32 { + let (vx, vy) = (b[0] - a[0], b[1] - a[1]); + let (wx, wy) = (px - a[0], py - a[1]); + let len2 = vx * vx + vy * vy; + let t = if len2 <= 1e-9 { 0.0 } else { ((wx * vx + wy * vy) / len2).clamp(0.0, 1.0) }; + let cx = a[0] + t * vx; + let cy = a[1] + t * vy; + let (dx, dy) = (px - cx, py - cy); + (dx * dx + dy * dy).sqrt() +} + +fn blit(atlas: &mut [u8], aw: usize, cell: &[u8], cs: usize, ox: usize, oy: usize) { + for y in 0..cs { + for x in 0..cs { + atlas[(oy + y) * aw + ox + x] = cell[y * cs + x]; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_and_rasterizes_close_icon() { + // The 'close' X is two crossing strokes: a rasterized cell must have + // lit pixels near the center and the diagonal must be non-empty. + let svg = "<path d=\"M6.75 6.75l10.5 10.5M17.25 6.75l-10.5 10.5\"/>"; + let lines = parse_svg(svg); + assert_eq!(lines.len(), 2, "two strokes"); + let cell = rasterize(&lines, CELL); + let lit = cell.iter().filter(|&&a| a > 40).count(); + assert!(lit > 20, "close icon has visible strokes, got {lit}"); + // center pixel should be near a stroke (the X crosses at 12,12 -> center) + let c = cell[(CELL / 2) * CELL + CELL / 2]; + assert!(c > 60, "center of X is lit, got {c}"); + } + + #[test] + fn parses_arc_command() { + // reload uses an arc; ensure it yields a non-trivial polyline. + let svg = "<path d=\"M19.5 12a7.5 7.5 0 1 1-2.6-5.7\"/>"; + let lines = parse_svg(svg); + assert_eq!(lines.len(), 1); + assert!(lines[0].len() > 8, "arc flattened to many points"); + } + + #[test] + fn key_extraction_handles_quoted_and_bare() { + let src = "bank: icon('<circle cx=\"1\" cy=\"1\" r=\"1\"/>'), \"clone-facility\": icon('<path d=\"M0 0L1 1\"/>'),"; + let e = extract_icons(src); + assert!(e.iter().any(|(id, _)| id == "bank")); + assert!(e.iter().any(|(id, _)| id == "clone-facility")); + } +} diff --git a/client-rust/tools/boot-closure.mjs b/client-rust/tools/boot-closure.mjs new file mode 100755 index 00000000..50d3f835 --- /dev/null +++ b/client-rust/tools/boot-closure.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// Derives the Rust web client's boot closure from authored data — never a +// hand-maintained list. Shared by tools/web-release.mjs (release packs) and +// tools/dev-serve.mjs (development manifest). Everything here is resolved +// through the same manifests the runtime consumes, so a content change moves +// the closure automatically. +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +const REQUIRED_BODY_ASSETS = [ + "assets/pawn-pack/pawn_male.glb", + "assets/pawn-pack/pawn_female.glb", +]; + +// Read synchronously at ConnectedScene::build; absence is a hard launch error. +const BOOT_DOCUMENTS = [ + "render/props-mapping.json", + "successor-slice/open-desert-slice.json", + "successor-audio/sfx/manifest.json", + "assets/pawn-pack/equipment/manifest.json", + "assets/pawn-pack/equipment/wardrobe_palette.json", + "assets/pawn-pack/weapons/weapons_manifest.json", +]; + +// Fixed starter outfit (canonical context: items under_bodysuit and +// boots_canvas_ankle are the immutable two-piece every character owns). +const STARTER_OUTFIT_IDS = new Set(["under_bodysuit", "boots_canvas_ankle"]); + +/** + * @returns {Promise<{boot: string[], audioCore: string[]}>} stable ids sorted. + */ +export async function deriveBootClosure(repoRoot) { + const repo = resolve(repoRoot); + const boot = new Set([...REQUIRED_BODY_ASSETS, ...BOOT_DOCUMENTS]); + + const equipmentManifest = JSON.parse(await readFile( + join(repo, "client-3d/public/assets/pawn-pack/equipment/manifest.json"), + "utf8", + )); + for (const item of equipmentManifest.items ?? []) { + if (!STARTER_OUTFIT_IDS.has(String(item.id ?? "").toLowerCase())) continue; + for (const field of ["glb", "glbFemale"]) { + const glb = item[field]; + if (typeof glb === "string" && glb.length > 0) boot.add(`assets/pawn-pack/equipment/${glb}`); + } + } + + // Core audio: every clip id named by the Rust trigger map. Clip ids are + // string literals in triggers.rs; intersect with the manifest's real ids. + const audioManifest = JSON.parse(await readFile( + join(repo, "client/public/successor-audio/sfx/manifest.json"), + "utf8", + )); + const triggerSource = await readFile(join(repo, "client-rust/source/app/src/audio/triggers.rs"), "utf8"); + const literals = new Set([...triggerSource.matchAll(/"([a-z][a-z0-9_]+)"/gu)].map((match) => match[1])); + const audioCore = []; + for (const clip of audioManifest.clips ?? []) { + if (!literals.has(clip.id)) continue; + const path = String(clip.path ?? "").replace(/^\//u, ""); + if (path.startsWith("successor-audio/")) { + boot.add(path); + audioCore.push(path); + } + } + + return { boot: [...boot].sort(), audioCore: audioCore.sort() }; +} diff --git a/client-rust/tools/capture-ui-parity.py b/client-rust/tools/capture-ui-parity.py new file mode 100755 index 00000000..3f9dca0a --- /dev/null +++ b/client-rust/tools/capture-ui-parity.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +"""Capture the replayable Successor pregame/loading UI stage matrix.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import struct +import subprocess +import time +from pathlib import Path + +STAGES = ( + "entry", + "connecting", + "roster", + "roster-empty", + "create-profile", + "create-summary", + "loading", +) +BMP_SIGNATURE = b"BM" + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def bmp_size(path: Path) -> tuple[int, int]: + with path.open("rb") as source: + header = source.read(54) + if len(header) != 54 or header[:2] != BMP_SIGNATURE: + raise RuntimeError(f"capture is not a BMP: {path}") + + file_size = struct.unpack_from("<I", header, 2)[0] + pixel_offset = struct.unpack_from("<I", header, 10)[0] + dib_size = struct.unpack_from("<I", header, 14)[0] + width, height = struct.unpack_from("<ii", header, 18) + planes, bits_per_pixel = struct.unpack_from("<HH", header, 26) + compression = struct.unpack_from("<I", header, 30)[0] + image_size = struct.unpack_from("<I", header, 34)[0] + if width <= 0 or height <= 0: + raise RuntimeError(f"capture has invalid BMP dimensions: {path}") + if ( + dib_size != 40 + or pixel_offset != 54 + or planes != 1 + or bits_per_pixel != 24 + or compression != 0 + ): + raise RuntimeError(f"capture does not match the Successor BMP format: {path}") + + row_bytes = (width * 3 + 3) & ~3 + expected_image_size = row_bytes * height + expected_file_size = pixel_offset + expected_image_size + actual_file_size = path.stat().st_size + if ( + image_size != expected_image_size + or file_size != expected_file_size + or actual_file_size != expected_file_size + ): + raise RuntimeError(f"capture is a truncated or malformed BMP: {path}") + return width, height + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--binary", + default="target/release/successor", + help="dev-tools-enabled successor binary, relative to client-rust unless absolute", + ) + parser.add_argument( + "--out", + help="new artifact directory (default: out/ui-parity/<UTC timestamp>)", + ) + parser.add_argument("--frames", type=int, default=24) + parser.add_argument( + "--loading-sequence-count", + type=int, + default=25, + help="400 ms original-client loading samples (0 disables)", + ) + parser.add_argument( + "--loading-sequence-step", + type=int, + default=24, + help="60 Hz frames advanced per 400 ms loading sample", + ) + parser.add_argument( + "--stages", + nargs="+", + choices=STAGES, + default=list(STAGES), + ) + parser.add_argument( + "--ui-surfaces", + nargs="+", + default=["actions", "inventory"], + help="registered window ids to capture; actions must remain first", + ) + return parser.parse_args() + + +def run_capture( + binary: Path, + root: Path, + destination: Path, + label: str, + arguments: list[str], + metadata: dict[str, object], +) -> dict[str, object]: + command = [str(binary), *arguments, "--screenshot", str(destination)] + started = time.monotonic_ns() + completed = subprocess.run(command, cwd=root, capture_output=True, text=True) + duration_ns = time.monotonic_ns() - started + if completed.returncode != 0: + raise RuntimeError( + f"{label} failed with exit {completed.returncode}: " + f"{completed.stderr[-2000:]}" + ) + if not destination.is_file(): + raise RuntimeError(f"{label} did not create {destination}") + width, height = bmp_size(destination) + metadata.update( + { + "path": destination.name, + "width": width, + "height": height, + "bytes": destination.stat().st_size, + "sha256": sha256(destination), + "duration_ns": duration_ns, + "command": command, + } + ) + return metadata + + +def capture_stage( + binary: Path, + root: Path, + destination: Path, + stage: str, + frames: int, +) -> dict[str, object]: + return run_capture( + binary, + root, + destination, + f"stage {stage!r}", + [ + "--demo", + "pregame", + "--stage", + stage, + "--frames", + str(frames), + ], + {"stage": stage, "frames": frames}, + ) + +def capture_surface( + binary: Path, + root: Path, + destination: Path, + surface: str, + frames: int, +) -> dict[str, object]: + return run_capture( + binary, + root, + destination, + f"UI surface {surface!r}", + [ + "--demo", + "ui", + "--surface", + surface, + "--frames", + str(frames), + ], + {"demo": "ui", "surface": surface, "frames": frames}, + ) + + +def main() -> int: + args = parse_args() + if args.frames < 1: + raise SystemExit("--frames must be positive") + if args.loading_sequence_count < 0: + raise SystemExit("--loading-sequence-count must be non-negative") + if args.loading_sequence_step < 1: + raise SystemExit("--loading-sequence-step must be positive") + if not args.ui_surfaces or args.ui_surfaces[0] != "actions": + raise SystemExit("--ui-surfaces must begin with actions") + if len(args.ui_surfaces) != len(set(args.ui_surfaces)): + raise SystemExit("--ui-surfaces must not contain duplicates") + + root = Path(__file__).resolve().parents[1] + binary = Path(args.binary) + if not binary.is_absolute(): + binary = root / binary + binary = binary.resolve() + if not binary.is_file(): + raise SystemExit(f"successor binary does not exist: {binary}") + + stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + artifacts = Path(args.out) if args.out else root / "out" / "ui-parity" / stamp + if not artifacts.is_absolute(): + artifacts = root / artifacts + artifacts = artifacts.resolve() + artifacts.mkdir(parents=True, exist_ok=False) + ui_surfaces = [ + capture_surface( + binary, + root, + artifacts / ("ui-overview.bmp" if surface == "actions" else f"ui-{surface}.bmp"), + surface, + args.frames, + ) + for surface in args.ui_surfaces + ] + ui_overview = ui_surfaces[0] + + captures = [ + capture_stage( + binary, + root, + artifacts / f"pregame-{stage}.bmp", + stage, + args.frames, + ) + for stage in args.stages + ] + loading_sequence = [ + capture_stage( + binary, + root, + artifacts / f"loading-animation-{index:03}.bmp", + "loading", + 1 + index * args.loading_sequence_step, + ) + for index in range(args.loading_sequence_count) + ] + + manifest = { + "schema_version": 1, + "created_at": dt.datetime.now(dt.timezone.utc).isoformat(), + "binary": str(binary), + "binary_sha256": sha256(binary), + "frames": args.frames, + "loading_sequence_step": args.loading_sequence_step, + "ui_overview": ui_overview, + "ui_surfaces": ui_surfaces, + "captures": captures, + "loading_sequence": loading_sequence, + } + manifest_path = artifacts / "capture-manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + print( + f"captured {len(ui_surfaces)} UI surfaces, {len(captures)} stages and " + f"{len(loading_sequence)} loading frames in {artifacts}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/client-rust/tools/connect-local.sh b/client-rust/tools/connect-local.sh new file mode 100755 index 00000000..de583583 --- /dev/null +++ b/client-rust/tools/connect-local.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# connect-local.sh — start the dev-capable Rust client against the local +# testing authority (scripts/server-local-persistent.mjs, loopback only). +# +# Brings the server up if it is not answering, creates a durable roster +# character on first run (the shard rejects bare dev identities for ids the +# character store already owns), then execs client-rust/out/bin/successor-dev +# with the documented dev-identity join. +# +# Usage: +# client-rust/tools/connect-local.sh [options] [-- <client args>] +# +# Options: +# --name NAME Roster character name (letters/hyphens, 3-16). +# Created on first use. Default: local-dev +# --profession ID Initial profession for a new character: +# marksman|scout|craftsman|medic|brawler. Default: marksman +# --port PORT Authority port. Default: 28093 +# --control-port N Client control listener; 0 = ephemeral (printed at +# startup). Default: 0 +# --build Force `make -C client-rust dev` before launch. +# --no-server Fail instead of starting the authority when down. +# -h, --help This text. +# +# Anything after `--` is passed to the client verbatim (e.g. --spawn-area, +# --auto-walk, --quality high). + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # client-rust/ +REPO="$(cd "$ROOT/.." && pwd)" +BIN="$ROOT/out/bin/successor-dev" + +HOST=127.0.0.1 +PORT=28093 +NAME="local-dev" +PROFESSION="marksman" +CONTROL_PORT=0 +BUILD=0 +START_SERVER=1 +EXTRA=() + +usage() { sed -n '2,26p' "${BASH_SOURCE[0]}"; } + +while [ $# -gt 0 ]; do + case "$1" in + --name) NAME="${2:?--name needs a value}"; shift 2 ;; + --profession) PROFESSION="${2:?--profession needs a value}"; shift 2 ;; + --port) PORT="${2:?--port needs a value}"; shift 2 ;; + --control-port) CONTROL_PORT="${2:?--control-port needs a value}"; shift 2 ;; + --build) BUILD=1; shift ;; + --no-server) START_SERVER=0; shift ;; + -h|--help) usage; exit 0 ;; + --) shift; EXTRA+=("$@"); break ;; + *) echo "connect-local: unknown option '$1' (use -- for client args)" >&2; exit 2 ;; + esac +done + +case "$NAME" in + *[!A-Za-z-]*|''|?|??|?????????????????*|*--*|*-|-*) + echo "connect-local: --name must be 3-16 letters with single hyphens" >&2; exit 2 ;; +esac +case "$PROFESSION" in + marksman|scout|craftsman|medic|brawler) ;; + *) echo "connect-local: --profession must be marksman|scout|craftsman|medic|brawler" >&2; exit 2 ;; +esac + +BASE="http://$HOST:$PORT" +ENDPOINT="ws://$HOST:$PORT" + +# --- 1. authority ------------------------------------------------------------ + +if curl -fsS --max-time 2 "$BASE/game/status" >/dev/null 2>&1; then + echo "connect-local: authority already up at $ENDPOINT" +else + if [ "$START_SERVER" -eq 0 ]; then + echo "connect-local: no authority at $ENDPOINT and --no-server given" >&2; exit 1 + fi + echo "connect-local: starting local authority on port $PORT" + (cd "$REPO" && GAME_AUTHORITY_SERVER_PORT="$PORT" pnpm server:local:persistent) + for _ in $(seq 1 120); do + if curl -fsS --max-time 2 "$BASE/healthz" >/dev/null 2>&1; then break; fi + sleep 1 + done + curl -fsS --max-time 2 "$BASE/healthz" >/dev/null \ + || { echo "connect-local: authority did not become healthy" >&2; exit 1; } +fi + +# --- 2. dev client binary ---------------------------------------------------- + +if [ "$BUILD" -eq 1 ] || [ ! -x "$BIN" ]; then + echo "connect-local: building successor-dev" + make -C "$ROOT" dev +fi + +# --- 3. durable roster character --------------------------------------------- + +CHARACTERS=$(curl -fsS --max-time 5 "$BASE/game/characters") +CHARACTER_ID=$(printf '%s' "$CHARACTERS" | python3 -c ' +import json, sys +name = sys.argv[1] +for record in json.load(sys.stdin).get("characters", []): + if record.get("name") == name: + print(record.get("id", "")) + break +' "$NAME") + +if [ -z "$CHARACTER_ID" ]; then + echo "connect-local: creating character '$NAME' ($PROFESSION)" + CREATED=$(curl -fsS --max-time 5 -X POST "$BASE/game/characters" \ + -H 'content-type: application/json' \ + -d "{\"name\":\"$NAME\",\"appearance\":{\"body\":\"male\",\"skinTone\":\"#c78f62\",\"hair\":\"hair_mop\",\"hairMat\":\"hair_raven\",\"face\":null},\"initialProfessionId\":\"$PROFESSION\"}") \ + || { echo "connect-local: character create failed (name taken? run with another --name)" >&2; exit 1; } + CHARACTER_ID=$(printf '%s' "$CREATED" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') +fi + +# --- 4. launch ---------------------------------------------------------------- + +echo "connect-local: character '$NAME' = $CHARACTER_ID" +echo "connect-local: launching successor-dev against $ENDPOINT (control-port $CONTROL_PORT)" +cd "$ROOT" +exec out/bin/successor-dev \ + --dev-identity \ + --endpoint "$ENDPOINT" \ + --player-id "$CHARACTER_ID" --actor-id "$CHARACTER_ID" \ + --character-id "$CHARACTER_ID" \ + --control-port "$CONTROL_PORT" \ + ${EXTRA[@]+"${EXTRA[@]}"} diff --git a/client-rust/tools/dev-serve.mjs b/client-rust/tools/dev-serve.mjs new file mode 100755 index 00000000..683c0764 --- /dev/null +++ b/client-rust/tools/dev-serve.mjs @@ -0,0 +1,262 @@ +#!/usr/bin/env node +// Loopback-only development server for the Rust web client. Serves the built +// shim/wasm from --root and maps asset URL prefixes straight to repo sources, +// so `make web` never copies the asset tree. `--release <dir>` instead serves +// an assembled out/web-release verbatim (verification of the real artifact). +// With --launch dev (default) the served index.html carries a dev-identity +// launch context pointing at a local authority (default 127.0.0.1:28093). +import { createServer } from "node:http"; +import { readFile, readdir, stat } from "node:fs/promises"; +import { extname, join, normalize, resolve, sep } from "node:path"; +import { deriveBootClosure } from "./boot-closure.mjs"; + +const CLIENT_RUST = resolve(import.meta.dirname, ".."); +const REPO = resolve(CLIENT_RUST, ".."); + +const CONTENT_TYPES = new Map([ + [".html", "text/html; charset=utf-8"], + [".js", "application/javascript"], + [".mjs", "application/javascript"], + [".css", "text/css; charset=utf-8"], + [".json", "application/json"], + [".wasm", "application/wasm"], + [".glb", "model/gltf-binary"], + [".png", "image/png"], + [".jpg", "image/jpeg"], + [".webp", "image/webp"], + [".mp3", "audio/mpeg"], + [".ogg", "audio/ogg"], + [".wav", "audio/wav"], + [".spak", "application/octet-stream"], +]); + +function parseArgs(argv) { + const args = { port: 8080, root: join(CLIENT_RUST, "out/web"), release: null, launch: "dev", player: "local-dev", authority: "127.0.0.1:28093" }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--port") args.port = Number(argv[++i]); + else if (arg === "--root") args.root = resolve(argv[++i]); + else if (arg === "--release") args.release = resolve(argv[++i]); + else if (arg === "--launch") args.launch = argv[++i]; + else if (arg === "--player-id") args.player = argv[++i]; + else if (arg === "--authority") args.authority = argv[++i]; + else throw new Error(`unknown argument: ${arg}`); + } + if (!Number.isInteger(args.port) || args.port <= 0) throw new Error("--port must be a positive integer"); + if (!["dev", "none"].includes(args.launch)) throw new Error("--launch must be dev or none"); + return args; +} + +const args = parseArgs(process.argv.slice(2)); +const serveRoot = args.release ?? args.root; +const repoMapping = !args.release; + +// Release mode also serves the content-addressed object namespace: object +// sha → release file, straight from the assembled release manifest. +const objectIndex = new Map(); +if (args.release) { + const manifest = JSON.parse(await readFile(join(serveRoot, "release-manifest.json"), "utf8")); + for (const file of manifest.files ?? []) { + if (typeof file.sha256 === "string") objectIndex.set(file.sha256, file.path); + } +} + +// Mirrors NativePlatform::read_asset (source/platform/src/lib.rs): stable ids +// resolve against repo asset roots; parity-assets are the six demo GLBs. +const PREFIX_MAPS = [ + { prefix: "/assets/", root: join(REPO, "client-3d/public/assets") }, + { prefix: "/successor-slice/", root: join(REPO, "client/public/successor-slice") }, + { prefix: "/successor-audio/", root: join(REPO, "client/public/successor-audio") }, +]; +const RENDER_FILES = new Map([ + ["/render/props-mapping.json", join(REPO, "client-3d/src/render/props-mapping.json")], +]); +const PARITY_FILES = new Map([ + ["commerce_facility.glb", "client-3d/public/assets/world-items/commerce_facility.glb"], + ["lightning_carbine.glb", "client-3d/public/assets/pawn-pack/weapons/custom/lightning_carbine.glb"], + ["mossmuff_adult.glb", "client-3d/public/assets/creatures/mossmuff_adult.glb"], + ["successor_food_beer_mug.glb", "client-3d/public/assets/wave-props/everyday-wave-20260719/prepared-foods/successor_food_beer_mug.glb"], + ["field_cap.glb", "client-3d/public/assets/items/custom/accessories/field_cap.glb"], + ["megalith_brick_hex.glb", "client-3d/public/assets/world-items/megalith_brick_hex.glb"], +]); + +function resolveRepoFile(pathname) { + if (pathname.startsWith("/objects/")) { + const sha = pathname.slice("/objects/".length); + const mapped = objectIndex.get(sha); + return mapped ? join(serveRoot, mapped) : null; + } + if (!repoMapping) return null; + if (RENDER_FILES.has(pathname)) return RENDER_FILES.get(pathname); + if (pathname.startsWith("/parity-assets/")) { + const name = pathname.slice("/parity-assets/".length); + const mapped = PARITY_FILES.get(name); + return mapped ? join(REPO, mapped) : null; + } + for (const { prefix, root } of PREFIX_MAPS) { + if (!pathname.startsWith(prefix)) continue; + const relative = pathname.slice(prefix.length); + const candidate = normalize(join(root, relative)); + if (!candidate.startsWith(root + sep)) return null; + return candidate; + } + return null; +} + +function resolveRootFile(pathname) { + const relative = pathname.replace(/^\/+/u, "") || "index.html"; + const candidate = normalize(join(serveRoot, relative)); + if (!candidate.startsWith(serveRoot + sep) && candidate !== serveRoot) return null; + return candidate; +} + +async function walkFiles(root, prefix, out = []) { + let entries; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch { + return out; + } + for (const entry of entries) { + const path = join(root, entry.name); + if (entry.isDirectory()) await walkFiles(path, `${prefix}${entry.name}/`, out); + else if (entry.isFile()) out.push(`${prefix}${entry.name}`); + } + return out; +} + +// Development manifest: real sizes, zero hashes (the dev shim never consults +// content hashes). Regenerated per request; a stat walk is milliseconds. The +// boot closure is the same derivation the release builder uses, so a dev boot +// downloads exactly what a production boot would. +let bootClosureCache = null; +async function devManifest() { + const files = []; + if (repoMapping) { + for (const { prefix, root } of PREFIX_MAPS) { + for (const path of await walkFiles(root, "")) { + const info = await stat(join(root, path)); + files.push({ path: `${prefix.slice(1)}${path}`, bytes: info.size, sha256: "0".repeat(64) }); + } + } + const mapping = RENDER_FILES.get("/render/props-mapping.json"); + files.push({ path: "render/props-mapping.json", bytes: (await stat(mapping)).size, sha256: "0".repeat(64) }); + } else { + for (const path of await walkFiles(serveRoot, "")) { + if (path === "release-manifest.json" || path === "current.json") continue; + const info = await stat(join(serveRoot, path)); + files.push({ path, bytes: info.size, sha256: "0".repeat(64) }); + } + } + files.sort((a, b) => a.path.localeCompare(b.path)); + if (repoMapping) { + if (!bootClosureCache) bootClosureCache = await deriveBootClosure(REPO); + return { + schema: "successor.rust-web-release.v2", + sourceCommit: "dev", + clientReleaseId: "successor-rust-dev@local", + serverReleaseId: "local", + storefrontOrigin: `http://127.0.0.1:${args.port}`, + gameOrigin: `ws://${args.authority}`, + chatOrigin: `ws://${args.authority}`, + files, + boot: bootClosureCache.boot, + packIndex: {}, + }; + } + const initialAssets = files + .map((file) => file.path) + .filter((path) => path.startsWith("assets/") || path.startsWith("render/") || path.startsWith("successor-audio/") || path.startsWith("successor-slice/")) + .sort(); + return { + schema: "successor.rust-web-release.v1", + sourceCommit: "dev", + clientReleaseId: "successor-rust-dev@local", + serverReleaseId: "local", + storefrontOrigin: `http://127.0.0.1:${args.port}`, + gameOrigin: `ws://${args.authority}`, + chatOrigin: `ws://${args.authority}`, + files, + initialAssets, + }; +} + +function percentEncode(value) { + return encodeURIComponent(value).replace(/%20/gu, "%20"); +} + +async function devLaunchContext() { + const statusUrl = `http://${args.authority}/game/status`; + let shard = "open-desert-persistent"; + try { + const response = await fetch(statusUrl, { signal: AbortSignal.timeout(3000) }); + if (!response.ok) throw new Error(`status ${response.status}`); + const status = await response.json(); + if (typeof status.shardId === "string" && status.shardId.length > 0) shard = status.shardId; + } catch (error) { + console.error(`dev-serve: --launch dev needs a local authority at ${statusUrl}`); + console.error(`dev-serve: start one with \`pnpm server:local:persistent\` (${error.message})`); + process.exit(1); + } + return { + schema: "successor.launch-context.v1", + gameTicket: "dev-identity", + chatTicket: "dev-chat", + characterId: args.player, + expiresAt: Date.now() + 3_600_000, + endpoints: { + game: `ws://${args.authority}`, + chat: `ws://${args.authority}/chat/ws?playerId=${percentEncode(args.player)}`, + }, + release: { client: "successor-rust-dev@local", server: "local", shard }, + }; +} + +let launchScript = null; +if (args.launch === "dev") { + const context = await devLaunchContext(); + launchScript = `<script>window.__SUCCESSOR_LAUNCH_CONTEXT = ${JSON.stringify(JSON.stringify(context))};</script>\n`; +} + +const server = createServer(async (request, response) => { + try { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const pathname = decodeURIComponent(url.pathname); + if (pathname === "/release-manifest.json" && repoMapping) { + response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }); + response.end(JSON.stringify(await devManifest(), null, 2)); + return; + } + const file = resolveRepoFile(pathname) ?? resolveRootFile(pathname); + if (!file) { + response.writeHead(403, { "content-type": "text/plain" }); + response.end("forbidden\n"); + return; + } + let body; + try { + body = await readFile(file); + } catch { + response.writeHead(404, { "content-type": "text/plain" }); + response.end(`not found: ${pathname}\n`); + return; + } + if (pathname === "/" || pathname === "/index.html") { + let html = body.toString("utf8"); + if (launchScript) html = html.replace('<script type="module" src="./successor.js"></script>', `${launchScript} <script type="module" src="./successor.js"></script>`); + body = Buffer.from(html); + } + response.writeHead(200, { + "content-type": CONTENT_TYPES.get(extname(file).toLowerCase()) ?? "application/octet-stream", + "cache-control": "no-store", + }); + response.end(body); + } catch (error) { + response.writeHead(500, { "content-type": "text/plain" }); + response.end(`dev-serve error: ${error.message}\n`); + } +}); + +server.listen(args.port, "127.0.0.1", () => { + console.log(`dev-serve: http://127.0.0.1:${args.port}/ (root ${serveRoot}${repoMapping ? " + repo asset mapping" : ""}, launch ${args.launch})`); +}); diff --git a/client-rust/tools/web-release.mjs b/client-rust/tools/web-release.mjs new file mode 100755 index 00000000..0f0ae0bc --- /dev/null +++ b/client-rust/tools/web-release.mjs @@ -0,0 +1,324 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { cp, mkdir, readFile, readdir, rm, stat, unlink, writeFile } from "node:fs/promises"; +import { extname, join, relative, resolve, sep } from "node:path"; +import { deriveBootClosure } from "./boot-closure.mjs"; + +const CLIENT_RELEASE_ID = /^[A-Za-z0-9][A-Za-z0-9._@-]{0,127}$/u; +const SERVER_RELEASE_ID = /^[A-Za-z0-9][A-Za-z0-9._@-]{0,255}$/u; +const COMMIT = /^[0-9a-f]{40}$/u; +const RUNTIME_EXTENSIONS = new Set([".glb", ".gltf", ".png", ".jpg", ".jpeg", ".webp", ".mp3", ".ogg", ".wav", ".json"]); +const CONTENT_TYPES = new Map([ + [".html", "text/html; charset=utf-8"], [".js", "application/javascript"], + [".wasm", "application/wasm"], [".json", "application/json"], + [".glb", "model/gltf-binary"], [".gltf", "model/gltf+json"], + [".png", "image/png"], [".jpg", "image/jpeg"], [".jpeg", "image/jpeg"], + [".webp", "image/webp"], [".mp3", "audio/mpeg"], [".ogg", "audio/ogg"], [".wav", "audio/wav"], +]); + +function args(argv) { + const values = { out: "out/web-release", local_dev: false }; + for (let index = 0; index < argv.length; index += 1) { + const key = argv[index]; + if (key === "--local-dev") { values.local_dev = true; continue; } + if (!["--source-commit", "--client-release-id", "--server-release-id", "--storefront-origin", "--game-origin", "--chat-origin", "--out"].includes(key)) throw new Error(`unknown argument ${key}`); + values[key.slice(2).replaceAll("-", "_")] = argv[++index]; + } + for (const key of ["source_commit", "client_release_id", "server_release_id", "storefront_origin", "game_origin", "chat_origin"]) if (!values[key]) throw new Error(`--${key.replaceAll("_", "-")} is required`); + if (!COMMIT.test(values.source_commit)) throw new Error("source commit must be an exact lowercase commit"); + if (!CLIENT_RELEASE_ID.test(values.client_release_id) || !SERVER_RELEASE_ID.test(values.server_release_id)) throw new Error("release ids are invalid"); + if (values.local_dev) { + if (!/^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/u.test(values.storefront_origin)) throw new Error("--local-dev storefront_origin must be one exact loopback HTTP origin"); + for (const key of ["game_origin", "chat_origin"]) if (!/^ws:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/u.test(values[key])) throw new Error(`--local-dev ${key} must be one exact loopback WS origin`); + } else { + if (!/^https:\/\/[^/*?#]+$/u.test(values.storefront_origin)) throw new Error("storefront_origin must be one exact HTTPS origin"); + for (const key of ["game_origin", "chat_origin"]) if (!/^wss:\/\/[^/*?#]+$/u.test(values[key])) throw new Error(`${key} must be one exact WSS origin`); + } + return values; +} + +async function filesUnder(root, current = root) { + const entries = await readdir(current, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const path = join(current, entry.name); + if (entry.isDirectory()) files.push(...await filesUnder(root, path)); + else if (entry.isFile()) files.push(path); + } + return files.sort(); +} + +function strings(value, found = []) { + if (typeof value === "string") found.push(value); + else if (Array.isArray(value)) for (const item of value) strings(item, found); + else if (value && typeof value === "object") for (const item of Object.values(value)) strings(item, found); + return found; +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +const options = args(process.argv.slice(2)); +const root = resolve(import.meta.dirname, ".."); +const repo = resolve(root, ".."); +const out = resolve(root, options.out); +await rm(out, { recursive: true, force: true }); +await mkdir(out, { recursive: true }); +await cp(resolve(root, "target/wasm32-unknown-unknown/release/successor_client.wasm"), join(out, "successor.wasm")); +await cp(resolve(root, "web/index.html"), join(out, "index.html")); + +const sourceShim = await readFile(resolve(root, "web/successor.js"), "utf8"); +const assertBootstrapOrder = (shim, label) => { + const calls = ["await fetchInitialAssets();", "await prepareWebAudio();", "await waitForHostedLaunch();"]; + const positions = calls.map(call => { + const first = shim.indexOf(call); + if (first < 0 || first !== shim.lastIndexOf(call)) throw new Error(`${label} shim must contain exactly one ${call}`); + return first; + }); + if (!(positions[0] < positions[1] && positions[1] < positions[2])) throw new Error(`${label} shim bootstrap is reordered`); +}; +assertBootstrapOrder(sourceShim, "source"); +const developmentBlock = /const successorBuild = Object\.freeze\(\{[\s\S]*?\n\}\);/u; +if (!developmentBlock.test(sourceShim)) throw new Error("web build configuration block not found"); +// --local-dev assembles the release artifact with the development launch path +// intact, for full release-path verification against a loopback authority. +// Publication pipelines never pass it; the shipped beta always strips this. +const localDev = options.local_dev; +let releaseShim = sourceShim.replace(developmentBlock, `const successorBuild = Object.freeze(${JSON.stringify({ + allowDevLaunch: localDev, + storefrontOrigin: options.storefront_origin, + clientReleaseId: options.client_release_id, + serverReleaseId: options.server_release_id, + gameOrigin: options.game_origin, + chatOrigin: options.chat_origin, + objectStore: true, +}, null, 4)});`); +if (!localDev) { + releaseShim = releaseShim.replace( + /function takeDevelopmentLaunch\(\) \{[\s\S]*?\n\}\n\n(?=function validHostedLaunch)/u, + "function takeDevelopmentLaunch() { return \"\"; }\n\n", + ); + releaseShim = releaseShim + .replace('new URLSearchParams(window.location.search).get("mode") === "creator"', "false") + .replace('new URLSearchParams(location.search).has("disable-half-float")', "false") + .replace( + / const params = new URLSearchParams\(window\.location\.search\);[\s\S]*? window\.__successorRenderReady = false;/u, + ` const demoSelector = 0; + await fetchInitialAssets(); + await prepareWebAudio(); + showLoading("CONNECTING", "WAITING FOR LAUNCH", 1); + await waitForHostedLaunch(); + showLoading("ENTERING WORLD", "BUILDING SCENE", 1); + window.__successorRenderReady = false;`, + ); +} +assertBootstrapOrder(releaseShim, "release"); +if (!localDev && /URLSearchParams|__SUCCESSOR_LAUNCH_CONTEXT|params\.get\("launch"\)|params\.get\("demo"\)/u.test(releaseShim)) throw new Error("release shim contains a URL launch or developer probe path"); +if (localDev) console.warn("web-release: --local-dev build retains the development launch path; never publish this artifact"); +await writeFile(join(out, "successor.js"), releaseShim); + +const sliceRoot = resolve(repo, "client/public/successor-slice"); +await cp(sliceRoot, join(out, "successor-slice"), { recursive: true }); +const pawnPackRoot = resolve(repo, "client-3d/public/assets/pawn-pack"); +await cp(pawnPackRoot, join(out, "assets/pawn-pack"), { recursive: true }); +const audioRoot = resolve(repo, "client/public/successor-audio"); +await cp(audioRoot, join(out, "successor-audio"), { recursive: true }); +await mkdir(join(out, "render"), { recursive: true }); +const propsMapping = resolve(repo, "client-3d/src/render/props-mapping.json"); +await cp(propsMapping, join(out, "render/props-mapping.json")); + +const sourceDocs = [propsMapping, ...await filesUnder(sliceRoot)]; +const references = new Set(); +for (const document of sourceDocs.filter(path => extname(path) === ".json")) { + for (const value of strings(JSON.parse(await readFile(document, "utf8")))) { + const clean = value.split(/[?#]/u, 1)[0].replace(/^\.\//u, "").replace(/^\//u, ""); + if (RUNTIME_EXTENSIONS.has(extname(clean).toLowerCase()) && !clean.startsWith("successor-slice/")) references.add(clean); + } +} +const rustSources = [resolve(root, "source/app/src/pawn/creatures.rs")]; +for (const source of rustSources) { + const text = await readFile(source, "utf8"); + for (const match of text.matchAll(/["']\/?((?:assets|successor-audio)\/[^"'?#]+\.(?:glb|gltf|png|jpe?g|webp|mp3|ogg|wav))["']/giu)) { + references.add(match[1]); + } +} +for (const category of ["mineral", "chemical", "gas", "water"]) references.add(`assets/world-items/extractor_${category}.glb`); +references.add("assets/world-items/podtent_scout.glb"); +references.add("assets/world-items/campfire_scout.glb"); +const publicRoots = [resolve(repo, "client-3d/public"), resolve(repo, "client/public")]; +const candidates = (await Promise.all(publicRoots.map(path => filesUnder(path)))).flat(); +for (const reference of [...references].sort()) { + const normalized = reference.replaceAll("/", sep); + const matches = candidates.filter(path => path.endsWith(normalized) || relative(resolve(repo, "client-3d/public"), path) === normalized || relative(resolve(repo, "client/public"), path) === normalized); + if (matches.length === 0) throw new Error(`required runtime asset is missing: ${reference}`); + const source = matches.sort((a, b) => a.length - b.length)[0]; + const marker = source.includes(`${sep}client-3d${sep}public${sep}`) ? resolve(repo, "client-3d/public") : resolve(repo, "client/public"); + const targetPath = relative(marker, source); + const destination = join(out, targetPath); + await mkdir(resolve(destination, ".."), { recursive: true }); + await cp(source, destination); +} +const pointerOrigin = "https://release.invalid"; +await writeFile(join(out, "current.json"), `${JSON.stringify({ + releaseId: options.client_release_id, + launchPage: `${pointerOrigin}/index.html`, + entryScript: `${pointerOrigin}/successor.js`, + styles: [], + assetBaseUrl: `${pointerOrigin}/`, + storeOrigin: options.storefront_origin, +}, null, 2)}\n`); + +// ── Asset packs (successor.assetpack.v1) ──────────────────────────────────── +// The shim unpacks packs into its stable-id byte cache; the Rust runtime only +// ever reads by stable id. Wardrobe/weapon GLBs stay standalone files: they +// stream on demand (and individually content-address in the object store). +const PACK_MAGIC = Buffer.from("SPAK1\n", "ascii"); +const WARDROBE_PREFIXES = ["assets/pawn-pack/equipment/", "assets/pawn-pack/weapons/"]; + +async function writePack(name, entries, packIndex) { + if (entries.length === 0) throw new Error(`pack ${name} would be empty`); + const payloads = []; + const indexEntries = []; + let offset = 0; + for (const entry of entries.sort((a, b) => a.path.localeCompare(b.path))) { + const bytes = await readFile(entry.absolute); + indexEntries.push({ path: entry.path, offset, bytes: bytes.byteLength, sha256: sha256(bytes) }); + payloads.push(bytes); + packIndex[entry.path] = { pack: `packs/${name}`, offset, bytes: bytes.byteLength }; + offset += bytes.byteLength; + await unlink(entry.absolute); + } + const indexJson = Buffer.from(JSON.stringify({ schema: "successor.assetpack.v1", entries: indexEntries }), "utf8"); + const header = Buffer.alloc(4); + header.writeUInt32LE(indexJson.byteLength, 0); + const packPath = join(out, "packs", name); + await mkdir(resolve(packPath, ".."), { recursive: true }); + await writeFile(packPath, Buffer.concat([PACK_MAGIC, header, indexJson, ...payloads])); +} + +const { boot, audioCore } = await deriveBootClosure(repo); +const bootSet = new Set(boot); +const audioCoreSet = new Set(audioCore); +const distFiles = (await filesUnder(out)) + .map((absolute) => ({ absolute, path: relative(out, absolute).split(sep).join("/") })) + .filter((file) => file.path !== "current.json"); +for (const id of boot) { + if (!distFiles.some((file) => file.path === id)) throw new Error(`boot asset missing from release tree: ${id}`); +} +const packIndex = {}; + +// ── Region packs (successor.region.v1 derivation) ────────────────────────── +// Prop models stream by streaming region: 8×8 map chunks = 128×128 cells. +// Membership comes from the canonical slice's prop placements resolved through +// the checked-in props mapping — the same inputs ConnectedScene consumes — +// never a hand list. Regions under the merge threshold fold into one sparse +// pack per area. The runtime never reads the map bundle; the slice cells are +// authoritative here. +const REGION_CELLS = 128; +const REGION_MERGE_BYTES = 256 * 1024; +const sliceDoc = JSON.parse(await readFile(join(out, "successor-slice/open-desert-slice.json"), "utf8")); +const mappingDoc = JSON.parse(await readFile(join(out, "render/props-mapping.json"), "utf8")); +const assetBase = String(mappingDoc.assetBase ?? "/assets/world-items/").replace(/^\//u, ""); +const mappingEntries = mappingDoc.entries ?? {}; +const propRegions = new Map(); // stable id -> { areaId, rx, ry } +const spawnRegions = new Map(); // areaId -> Set("rx,ry") of the player 3x3 +{ + const slicePlayer = (sliceDoc.actors ?? []).find((actor) => actor.id === "player" || actor.actorId === "player"); + if (!slicePlayer?.cell) throw new Error("release: authored player spawn cell not found in slice"); + const spawnRegion = { rx: Math.floor(slicePlayer.cell.x / REGION_CELLS), ry: Math.floor(slicePlayer.cell.y / REGION_CELLS) }; + // Only the player's current region stages at boot / holds the travel + // transition; the 3×3 neighborhood streams in right after via the watcher. + const neighborhood = new Set([`${spawnRegion.rx}-${spawnRegion.ry}`]); + spawnRegions.set(slicePlayer.areaId ?? "open-desert-overworld", neighborhood); +} +for (const prop of sliceDoc.props ?? []) { + if (prop.visible === false) continue; + const entry = mappingEntries[prop.assetKey] ?? mappingEntries[prop.kind]; + if (!entry || entry.skip === true || typeof entry.glb !== "string") continue; + const stableId = entry.glb.startsWith("/") ? entry.glb.slice(1) : `${assetBase}${entry.glb}`; + const areaId = prop.areaId ?? "open-desert-overworld"; + const rx = Math.floor((prop.cell?.x ?? 0) / REGION_CELLS); + const ry = Math.floor((prop.cell?.y ?? 0) / REGION_CELLS); + if (!propRegions.has(stableId)) propRegions.set(stableId, { areaId, rx, ry }); +} +const regionOfFile = (path) => propRegions.get(path); + +const bootPack = []; +const audioBoot = []; +const audioRest = []; +const worldRest = []; +const regionBuckets = new Map(); // "areaId/rx-ry" -> files +for (const file of distFiles) { + if (audioCoreSet.has(file.path)) audioBoot.push(file); + else if (bootSet.has(file.path)) bootPack.push(file); + else if (file.path.startsWith("successor-audio/")) audioRest.push(file); + else if (regionOfFile(file.path)) { + const region = regionOfFile(file.path); + const key = `${region.areaId}/${region.rx}-${region.ry}`; + if (!regionBuckets.has(key)) regionBuckets.set(key, []); + regionBuckets.get(key).push(file); + } + // Creature GLBs stay standalone: they stream individually on first AOI + // appearance. Wardrobe stays standalone for on-demand streaming. + else if (file.path.startsWith("assets/creatures/")) { /* standalone */ } + else if (file.path.startsWith("assets/") && !WARDROBE_PREFIXES.some((prefix) => file.path.startsWith(prefix))) worldRest.push(file); +} +// Audio boots in a second burst: the visual scene never waits for sound. +await writePack("boot.spak", bootPack, packIndex); +await writePack("audio-boot.spak", audioBoot, packIndex); +await writePack("audio-rest.spak", audioRest, packIndex); +await writePack("world-rest.spak", worldRest, packIndex); +// Small regions merge into one sparse pack per area. +const sparseBuckets = new Map(); // areaId -> files +for (const [key, files] of [...regionBuckets.entries()].sort()) { + let bytes = 0; + for (const file of files) bytes += (await stat(file.absolute)).size; + const areaId = key.split("/")[0]; + if (bytes < REGION_MERGE_BYTES) { + if (!sparseBuckets.has(areaId)) sparseBuckets.set(areaId, []); + sparseBuckets.get(areaId).push(...files); + regionBuckets.delete(key); + } +} +const bootPacks = []; +for (const [key, files] of [...regionBuckets.entries()].sort()) { + const name = `region/${key}.spak`; + await writePack(name, files, packIndex); + const [areaId, region] = key.split("/"); + if (spawnRegions.get(areaId)?.has(region)) bootPacks.push(`packs/${name}`); +} +for (const [areaId, files] of [...sparseBuckets.entries()].sort()) { + const name = `region/${areaId}/sparse.spak`; + await writePack(name, files, packIndex); + // A sparse pack covers the whole area's small regions; it stages at boot + // whenever any of its regions neighbor the spawn. + const covers = new Set(files.map((file) => { + const region = regionOfFile(file.path); + return `${region.rx}-${region.ry}`; + })); + if ([...covers].some((region) => spawnRegions.get(areaId)?.has(region))) bootPacks.push(`packs/${name}`); +} + +const inventory = []; +for (const path of await filesUnder(out)) { + if (path.endsWith("release-manifest.json") || path.endsWith("current.json")) continue; + const bytes = await readFile(path); + const name = relative(out, path).split(sep).join("/"); + inventory.push({ path: name, bytes: bytes.byteLength, sha256: sha256(bytes), contentType: CONTENT_TYPES.get(extname(name).toLowerCase()) ?? "application/octet-stream" }); +} +const manifest = { + schema: "successor.rust-web-release.v2", + sourceCommit: options.source_commit, + clientReleaseId: options.client_release_id, + serverReleaseId: options.server_release_id, + storefrontOrigin: options.storefront_origin, + gameOrigin: options.game_origin, + chatOrigin: options.chat_origin, + files: inventory, + boot, + bootPacks, + packIndex, +}; +await writeFile(join(out, "release-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); +console.log(JSON.stringify({ out, files: inventory.length, boot: boot.length, packed: Object.keys(packIndex).length, bytes: inventory.reduce((sum, file) => sum + file.bytes, 0), manifestSha256: sha256(Buffer.from(JSON.stringify(manifest))) }, null, 2)); diff --git a/client-rust/web/index.html b/client-rust/web/index.html new file mode 100644 index 00000000..502573c9 --- /dev/null +++ b/client-rust/web/index.html @@ -0,0 +1,34 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" /> + <title>Successor (Rust client) + + + +

+
+

SUCCESSOR

+
PREPARING CLIENT
+
+
INITIALIZING
+
+
+ + + + diff --git a/client-rust/web/successor.js b/client-rust/web/successor.js new file mode 100644 index 00000000..43be632a --- /dev/null +++ b/client-rust/web/successor.js @@ -0,0 +1,1578 @@ +// Successor Rust Client — WebGL2, WebSockets, Fetch, and Input JS loader. +"use strict"; + +// `tools/web-release.mjs` replaces this complete development block when it +// assembles a public artifact. Release builds fail closed on every blank field. +const successorBuild = Object.freeze({ + allowDevLaunch: true, + storefrontOrigin: "", + clientReleaseId: "", + serverReleaseId: "", + gameOrigin: "", + chatOrigin: "", + objectStore: false +}); + +const canvas = document.getElementById("app"); +const loading = document.getElementById("loading"); +const loadingPhase = document.getElementById("loading-phase"); +const loadingBar = document.getElementById("loading-bar"); +const loadingDetail = document.getElementById("loading-detail"); + +function showLoading(phase, detail = "", fraction = 0) { + loadingPhase.textContent = phase; + loadingDetail.textContent = detail; + loadingBar.style.width = `${Math.max(0, Math.min(1, fraction)) * 100}%`; +} + +function failLoading(error) { + loading.classList.add("error"); + showLoading("UNABLE TO ENTER WORLD", String(error).replaceAll(/[?#].*/g, ""), 0); +} + +function finishLoading() { + loading.classList.add("hidden"); + window.setTimeout(() => loading.remove(), 220); +} +const gl = canvas.getContext("webgl2"); + +let webglContextLost = false; +canvas.addEventListener("webglcontextlost", e => { + e.preventDefault(); + webglContextLost = true; + window.__successorRenderError = "WebGL context lost; reconnecting renderer"; +}); +canvas.addEventListener("webglcontextrestored", () => { + webglContextLost = false; + window.__successorRenderError = null; + if (typeof wasmExports.context_restored === "function") wasmExports.context_restored(); +}); +if (!gl) { + console.error("WebGL2 is not supported by this browser."); +} + +// Input state tracking. Indices are the stable engine-core Key discriminants. +const keyState = new Uint8Array(39); +const keyMap = { + "KeyW": 0, "KeyA": 1, "KeyS": 2, "KeyD": 3, + "ArrowUp": 4, "ArrowDown": 5, "ArrowLeft": 6, "ArrowRight": 7, + "Space": 8, "Enter": 9, "Escape": 10, "Backspace": 11, + "ShiftLeft": 12, "Backquote": 13, "KeyR": 14, "KeyF": 15, + "KeyI": 16, "KeyC": 17, "Semicolon": 18, "KeyO": 19, + "Tab": 20, "KeyV": 21, "KeyX": 22, "KeyN": 23, + ...Object.fromEntries(Array.from({length: 10}, (_, i) => [`Digit${i}`, 24 + i])), + "KeyP": 34, "KeyK": 35, "KeyB": 36, "KeyM": 37, "KeyG": 38 +}; + +window.addEventListener("keydown", (e) => { + const code = keyMap[e.code]; + if (code !== undefined) { + keyState[code] = 1; + } + if (e.isTrusted) authorizeAudioGesture(); +}); + +window.addEventListener("keyup", (e) => { + const code = keyMap[e.code]; + if (code !== undefined) { + keyState[code] = 0; + } +}); + +function releaseMovementInput(reason) { + keyState.fill(0); + mouseButtons.fill(0); + if (typeof wasmExports.release_movement_input === "function") { + wasmExports.release_movement_input(reason); + } +} +window.addEventListener("blur", () => releaseMovementInput(0)); +document.addEventListener("visibilitychange", () => { + if (document.visibilityState !== "visible") releaseMovementInput(1); +}); +window.addEventListener("pagehide", () => releaseMovementInput(1)); + +let mouseX = 0, mouseY = 0; +const mouseButtons = new Uint8Array(3); +function updatePointer(e) { + const r = canvas.getBoundingClientRect(); + mouseX = (e.clientX - r.left) * canvas.width / r.width; + mouseY = (e.clientY - r.top) * canvas.height / r.height; +} +canvas.addEventListener("pointermove", updatePointer); +canvas.addEventListener("pointerdown", e => { + updatePointer(e); + if (e.button < mouseButtons.length) mouseButtons[e.button] = 1; + canvas.setPointerCapture(e.pointerId); + if (e.isTrusted) authorizeAudioGesture(); +}); +function releasePointer(e) { + updatePointer(e); + if (e.button < mouseButtons.length) mouseButtons[e.button] = 0; + if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId); +} +canvas.addEventListener("pointerup", releasePointer); +canvas.addEventListener("pointercancel", e => { + mouseButtons.fill(0); + if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId); +}); +canvas.addEventListener("lostpointercapture", () => mouseButtons.fill(0)); +const charQueue = []; +window.addEventListener("keypress", (e) => { + if (e.key.length === 1) { + charQueue.push(e.key.charCodeAt(0)); + } +}); + +// Resource table: WebGL objects referenced by integer ID from WASM. +// Index 0 = null/invalid. + +let scrollX = 0; +let scrollY = 0; +window.addEventListener("wheel", (e) => { + scrollX += e.deltaX; + scrollY += e.deltaY; +}, { passive: true }); +const glResources = [null]; + +function glAlloc(obj) { + glResources.push(obj); + return glResources.length - 1; +} + +let launchContextText = ""; +let hostedLaunch = false; +const creatorMode = new URLSearchParams(window.location.search).get("mode") === "creator"; + +function takeDevelopmentLaunch() { + if (!successorBuild.allowDevLaunch) return ""; + const supplied = window.__SUCCESSOR_LAUNCH_CONTEXT; + if (typeof supplied === "string" && supplied.trim()) return supplied; + const params = new URLSearchParams(window.location.search); + const raw = params.get("launchContext") || params.get("launch"); + if (!raw) return ""; + try { + return params.has("launchContext") ? decodeURIComponent(raw) : atob(raw); + } catch (_) { + return raw; + } +} + +function validHostedLaunch(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + if (Object.keys(value).sort().join(",") !== "characterId,chatTicket,endpoints,expiresAt,gameTicket,release,schema") return false; + if (value.schema !== "successor.launch-context.v1") return false; + if (typeof value.gameTicket !== "string" || value.gameTicket.length < 32) return false; + if (typeof value.chatTicket !== "string" || value.chatTicket.length < 32 || value.chatTicket === value.gameTicket) return false; + if (typeof value.characterId !== "string" || value.characterId.length < 1 || value.characterId.length > 128) return false; + if (!Number.isSafeInteger(value.expiresAt) || value.expiresAt <= Date.now() || value.expiresAt > Date.now() + 50000) return false; + if (value.release === null || typeof value.release !== "object") return false; + if (value.release.client !== successorBuild.clientReleaseId || value.release.server !== successorBuild.serverReleaseId) return false; + if (value.endpoints === null || typeof value.endpoints !== "object") return false; + try { + return new URL(value.endpoints.game).origin === successorBuild.gameOrigin + && new URL(value.endpoints.chat).origin === successorBuild.chatOrigin; + } catch (_) { + return false; + } +} + +// The workshop has no account/session capability. It is deliberately a tiny +// exact-origin relay for its five versioned creator messages, installed before +// WASM boot so a parent response cannot race child initialization. +const CREATOR_READY = "successor.creator.ready.v1"; +const CREATOR_CREATE = "successor.creator.create.v1"; +const CREATOR_SELECT = "successor.creator.select.v1"; +const CREATOR_STATE = "successor.creator.state.v1"; +const CREATOR_CREATE_RESULT = "successor.creator.create-result.v1"; +const CREATOR_MAX_QUEUE = 8; +const CREATOR_MAX_MESSAGE_BYTES = 16 * 1024; +const CREATOR_MAX_ROSTER = 10; +const CREATOR_FACE_KEYS = ["eyes", "brows", "nose", "mouth", "eyeColor", "browColor", "lipColor"]; +const creatorTextEncoder = new TextEncoder(); + +function isPlainRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function hasOnlyKeys(value, keys) { + return isPlainRecord(value) && Object.keys(value).every(key => keys.includes(key)); +} + +function isSafeText(value, min, max) { + return typeof value === "string" + && value.length >= min + && value.length <= max + && /^[\x20-\x7e]*$/u.test(value); +} + +function isCreatorCharacterId(value) { + return typeof value === "string" + && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value); +} + +function isCreatorRequestId(value) { + return typeof value === "string" + && /^[A-Za-z0-9._:-]{1,64}$/u.test(value); +} + +function isCreatorName(value) { + return typeof value === "string" + && /^[A-Za-z]+(?:-[A-Za-z]+)*$/u.test(value) + && value.length >= 3 + && value.length <= 16; +} + +function isCreatorProfession(value) { + return typeof value === "string" && /^[a-z][a-z_-]{0,31}$/u.test(value); +} + +function normalizeCreatorAppearance(value) { + if (!hasOnlyKeys(value, ["body", "skinTone", "hair", "hairMat", "face"])) return null; + if ((value.body !== "male" && value.body !== "female") + || !isSafeText(value.skinTone, 1, 64) + || !isSafeText(value.hairMat, 1, 64) + || (value.hair !== null && !isSafeText(value.hair, 1, 64))) { + return null; + } + let face = null; + if (value.face !== null) { + if (!hasOnlyKeys(value.face, CREATOR_FACE_KEYS) + || !CREATOR_FACE_KEYS.every(key => isSafeText(value.face[key], 1, 64))) { + return null; + } + face = { + eyes: value.face.eyes, + brows: value.face.brows, + nose: value.face.nose, + mouth: value.face.mouth, + eyeColor: value.face.eyeColor, + browColor: value.face.browColor, + lipColor: value.face.lipColor + }; + } + return { + body: value.body, + skinTone: value.skinTone, + hair: value.hair, + hairMat: value.hairMat, + face + }; +} + +function safeCreatorWorn(value) { + return hasOnlyKeys(value, ["item", "colors"]) + && isSafeText(value.item, 1, 128) + && Array.isArray(value.colors) + && value.colors.length <= 8 + && value.colors.every(color => isSafeText(color, 1, 64)); +} + +function normalizeCreatorRecord(value) { + if (!hasOnlyKeys(value, ["id", "name", "initialProfessionId", "worldEntryClaimed", "appearance", "worn"]) + || !isCreatorCharacterId(value.id) + || !isCreatorName(value.name) + || (value.initialProfessionId !== null && !isCreatorProfession(value.initialProfessionId)) + || typeof value.worldEntryClaimed !== "boolean") { + return null; + } + const appearance = normalizeCreatorAppearance(value.appearance); + if (appearance === null + || (value.worn !== undefined + && (!Array.isArray(value.worn) + || value.worn.length > 8 + || !value.worn.every(safeCreatorWorn)))) { + return null; + } + // Keep exactly the roster projection consumed by Rust. Worn pieces are + // checked above but not queued because the current screen does not render + // wardrobe; this prevents dormant arbitrary payloads crossing the fence. + return { + id: value.id, + name: value.name, + initialProfessionId: value.initialProfessionId, + worldEntryClaimed: value.worldEntryClaimed, + appearance + }; +} + +function normalizeCreatorState(value) { + if (!hasOnlyKeys(value, ["type", "characters", "selectedCharacterId"]) + || value.type !== CREATOR_STATE + || !Array.isArray(value.characters) + || value.characters.length > CREATOR_MAX_ROSTER) { + return null; + } + const characters = value.characters.map(normalizeCreatorRecord); + if (characters.some(character => character === null)) return null; + const ids = new Set(); + for (const character of characters) { + if (ids.has(character.id)) return null; + ids.add(character.id); + } + if (value.selectedCharacterId !== undefined + && (!isCreatorCharacterId(value.selectedCharacterId) || !ids.has(value.selectedCharacterId))) { + return null; + } + return { + type: CREATOR_STATE, + characters, + ...(value.selectedCharacterId === undefined ? {} : { selectedCharacterId: value.selectedCharacterId }) + }; +} + +function normalizeCreatorCreateResult(value) { + if (!hasOnlyKeys(value, ["type", "requestId", "ok", "error"]) + || value.type !== CREATOR_CREATE_RESULT + || !isCreatorRequestId(value.requestId) + || typeof value.ok !== "boolean") { + return null; + } + if (value.ok && value.error !== undefined) return null; + if (value.error !== undefined && !isSafeText(value.error, 1, 128)) return null; + return { + type: CREATOR_CREATE_RESULT, + requestId: value.requestId, + ok: value.ok, + ...(value.error === undefined ? {} : { error: value.error }) + }; +} + +function normalizeCreatorCreate(value) { + if (!hasOnlyKeys(value, ["type", "requestId", "character"]) + || value.type !== CREATOR_CREATE + || !isCreatorRequestId(value.requestId) + || !hasOnlyKeys(value.character, ["name", "initialProfessionId", "appearance"]) + || !isCreatorName(value.character.name) + || !isCreatorProfession(value.character.initialProfessionId)) { + return null; + } + const appearance = normalizeCreatorAppearance(value.character.appearance); + if (appearance === null) return null; + return { + type: CREATOR_CREATE, + requestId: value.requestId, + character: { + name: value.character.name, + initialProfessionId: value.character.initialProfessionId, + appearance + } + }; +} + +function exactCreatorOrigin(value) { + if (typeof value !== "string" || !value) return null; + try { + const parsed = new URL(value); + return parsed.protocol === "https:" + && !parsed.username + && !parsed.password + && parsed.pathname === "/" + && !parsed.search + && !parsed.hash + && !parsed.hostname.includes("*") + && parsed.origin === value + ? parsed.origin + : null; + } catch (_) { + return null; + } +} + +function createCreatorBridge() { + const origin = exactCreatorOrigin(successorBuild.storefrontOrigin); + if (!origin || window.parent === window) { + throw new Error("hosted creator is not configured"); + } + const queue = []; + const enqueue = message => { + const bytes = creatorTextEncoder.encode(JSON.stringify(message)); + if (bytes.byteLength === 0 || bytes.byteLength > CREATOR_MAX_MESSAGE_BYTES) return; + if (message.type === CREATOR_STATE) { + for (let index = queue.length - 1; index >= 0; index -= 1) { + if (queue[index].type === CREATOR_STATE) queue.splice(index, 1); + } + } + if (queue.length >= CREATOR_MAX_QUEUE) { + const stateIndex = queue.findIndex(entry => entry.type === CREATOR_STATE); + if (stateIndex >= 0) queue.splice(stateIndex, 1); + else return; + } + queue.push({ type: message.type, bytes }); + }; + window.addEventListener("message", event => { + if (event.source !== window.parent || event.origin !== origin) return; + const state = normalizeCreatorState(event.data); + if (state !== null) { + enqueue(state); + return; + } + const result = normalizeCreatorCreateResult(event.data); + if (result !== null) enqueue(result); + }); + return { + ready: () => window.parent.postMessage({ type: CREATOR_READY }, origin), + messageLength: () => queue[0]?.bytes.byteLength ?? 0, + copyMessage: (ptr, maxLen) => { + const entry = queue.shift(); + if (!entry || entry.bytes.byteLength > maxLen || !wasmMemory) return 0; + new Uint8Array(wasmMemory.buffer, ptr, entry.bytes.byteLength).set(entry.bytes); + return entry.bytes.byteLength; + }, + discardMessage: () => { queue.shift(); }, + postCreate: raw => { + let parsed; + try { + parsed = JSON.parse(raw); + } catch (_) { + return false; + } + const message = normalizeCreatorCreate(parsed); + if (message === null) return false; + window.parent.postMessage(message, origin); + return true; + }, + postSelect: characterId => { + if (!isCreatorCharacterId(characterId)) return false; + window.parent.postMessage({ type: CREATOR_SELECT, characterId }, origin); + return true; + } + }; +} + +let creatorBridge = null; +let creatorStartupError = null; +if (creatorMode) { + try { + creatorBridge = createCreatorBridge(); + } catch (error) { + creatorStartupError = error; + } +} + +function waitForHostedLaunch(timeoutMs = 30000) { + const development = takeDevelopmentLaunch(); + if (development) { + launchContextText = development; + return Promise.resolve(); + } + const configured = [ + successorBuild.storefrontOrigin, + successorBuild.clientReleaseId, + successorBuild.serverReleaseId, + successorBuild.gameOrigin, + successorBuild.chatOrigin + ].every(value => typeof value === "string" && value.length > 0); + if (!configured || window.parent === window) return Promise.reject(new Error("hosted launch is not configured")); + return new Promise((resolve, reject) => { + let settled = false; + const ready = () => window.parent.postMessage({ + type: "successor.client.ready.v1", + releaseId: successorBuild.clientReleaseId + }, successorBuild.storefrontOrigin); + const finish = callback => { + if (settled) return; + settled = true; + window.removeEventListener("message", onMessage); + clearInterval(retry); + clearTimeout(timeout); + callback(); + }; + const onMessage = event => { + if (event.source !== window.parent || event.origin !== successorBuild.storefrontOrigin) return; + const data = event.data; + if (data === null || typeof data !== "object" || Object.keys(data).sort().join(",") !== "launch,type") return; + if (data.type !== "successor.launch.v1" || !validHostedLaunch(data.launch)) return; + launchContextText = JSON.stringify(data.launch); + hostedLaunch = true; + finish(resolve); + }; + window.addEventListener("message", onMessage); + const retry = setInterval(ready, 2000); + const timeout = setTimeout(() => finish(() => reject(new Error("hosted launch timed out"))), timeoutMs); + ready(); + }); +} + +function installHostedExitHandler() { + if (!hostedLaunch) return; + let running = false; + window.addEventListener("message", event => { + if (running || event.source !== window.parent || event.origin !== successorBuild.storefrontOrigin) return; + if (event.data === null || typeof event.data !== "object") return; + if (Object.keys(event.data).join(",") !== "type" || event.data.type !== "successor.client.exit-world.v1") return; + running = true; + const started = typeof wasmExports.net_exit_world === "function" && wasmExports.net_exit_world() === 1; + const deadline = performance.now() + 1250; + const finish = ok => { + window.parent.postMessage({ type: "successor.client.exit-world-result.v1", ok }, successorBuild.storefrontOrigin); + running = false; + }; + if (!started) { + finish(false); + return; + } + const waitForClose = () => { + if (typeof wasmExports.net_exit_complete === "function" && wasmExports.net_exit_complete() === 1) finish(true); + else if (performance.now() >= deadline) finish(false); + else setTimeout(waitForClose, 25); + }; + waitForClose(); + }); +} +const AUDIO_MANIFEST_PATH = "successor-audio/sfx/manifest.json"; +let audioContext = null; +let audioMaster = null; +let audioPrepared = false; +let audioAvailable = false; +let gestureUnlocked = false; +let audioSequence = 0; +const audioBuffers = new Map(); +const audioVoices = new Set(); +const audioVoicesByKey = new Map(); +const pendingAudioLoops = new Map(); +const audioRecent = []; +const audioErrors = []; + +function audioError(path, error) { + const cleanPath = String(path).replace(/[?#].*/s, "").slice(0, 180); + const name = String(error?.name || "Error").replace(/[?#].*/s, "").slice(0, 48); + audioErrors.push(`${cleanPath}:${name}`); + if (audioErrors.length > 16) audioErrors.shift(); +} + +function ensureAudioContext() { + if (!audioContext) { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return null; + audioContext = new Ctx(); + audioMaster = audioContext.createGain(); + audioMaster.connect(audioContext.destination); + } + return audioContext; +} + +function voiceCursor(voice) { + const elapsed = Math.max(0, audioContext.currentTime - voice.startedAt); + return voice.looped && voice.duration > 0 ? elapsed % voice.duration : elapsed; +} + +function removeVoice(voice) { + if (!audioVoices.delete(voice)) return; + const keyed = audioVoicesByKey.get(voice.key); + if (keyed) { + keyed.delete(voice); + if (keyed.size === 0) audioVoicesByKey.delete(voice.key); + } + updateMasterGain(); +} + +function stopVoice(voice) { + removeVoice(voice); + voice.source.onended = null; + try { voice.source.stop(); } catch (_) {} +} + +function greatestCursor(voices) { + let selected = null; + let cursor = -1; + for (const voice of voices) { + const candidate = voiceCursor(voice); + if (candidate > cursor) { + cursor = candidate; + selected = voice; + } + } + return selected; +} + +function voiceLimit(polyphony, looped) { + const p = Math.max(1, Math.trunc(polyphony)); + return looped ? p : Math.max(p, Math.min(64, Math.ceil(p * 2.5), p + 12)); +} + +function concurrencyGain(count) { + return count <= 8 ? 1 : Math.max(0.38, Math.min(1, 1 / Math.sqrt(1 + (count - 8) * 0.72))); +} + +function updateMasterGain() { + if (audioMaster) audioMaster.gain.value = concurrencyGain(audioVoices.size); +} + +function audioStop(key) { + pendingAudioLoops.delete(key >>> 0); + for (const voice of [...(audioVoicesByKey.get(key >>> 0) || [])]) stopVoice(voice); +} + +function audioPlay(path, key, gain, pan, looped, polyphony) { + path = String(path); + key >>>= 0; + gain = Math.max(0, Math.min(4, Number(gain))); + pan = Math.max(-1, Math.min(1, Number(pan))); + polyphony = Math.max(1, Math.trunc(polyphony)); + const intent = { path, key, gain, pan, looped: Boolean(looped), polyphony }; + if (!gestureUnlocked || audioContext?.state !== "running") { + if (intent.looped) pendingAudioLoops.set(key, intent); + return false; + } + const buffer = audioBuffers.get(path); + if (!buffer) { + ensureClipDecode(path); + return false; + } + if (intent.looped) audioStop(key); + const keyed = audioVoicesByKey.get(key); + const limit = voiceLimit(polyphony, intent.looped); + if (keyed && keyed.size >= limit) { + const victim = greatestCursor(keyed); + if (victim) stopVoice(victim); + } + if (audioVoices.size >= 64) { + const victim = greatestCursor(audioVoices); + if (victim) stopVoice(victim); + } + const source = audioContext.createBufferSource(); + const gainNode = audioContext.createGain(); + const panNode = audioContext.createStereoPanner(); + source.buffer = buffer; + source.loop = intent.looped; + gainNode.gain.value = gain; + panNode.pan.value = pan; + source.connect(gainNode).connect(panNode).connect(audioMaster); + const voice = { + source, key, looped: intent.looped, duration: buffer.duration, + startedAt: audioContext.currentTime, sequence: ++audioSequence + }; + audioVoices.add(voice); + if (!audioVoicesByKey.has(key)) audioVoicesByKey.set(key, new Set()); + audioVoicesByKey.get(key).add(voice); + source.onended = () => removeVoice(voice); + updateMasterGain(); + source.start(); + audioRecent.push({ path: path.replace(/[?#].*/s, ""), key, gain, pan }); + if (audioRecent.length > 64) audioRecent.shift(); + return true; +} + +async function resumeAuthorizedAudio() { + const context = ensureAudioContext(); + if (!context || !gestureUnlocked) return; + if (context.state !== "running") await context.resume(); + if (context.state !== "running") return; + const pending = [...pendingAudioLoops.values()]; + pendingAudioLoops.clear(); + for (const intent of pending) { + audioPlay(intent.path, intent.key, intent.gain, intent.pan, true, intent.polyphony); + } +} + +function authorizeAudioGesture() { + if (!gestureUnlocked) gestureUnlocked = true; + resumeAuthorizedAudio().catch(error => audioError("audio-resume", error)); +} + +function audioUnlock() { + if (gestureUnlocked) resumeAuthorizedAudio().catch(error => audioError("audio-resume", error)); +} + +function normalizedAudioPath(path) { + if (typeof path !== "string") return null; + const trimmed = path.startsWith("/") ? path.slice(1) : path; + if (!trimmed.startsWith("successor-audio/") || trimmed.includes("..") || + trimmed.includes("\\") || trimmed.includes("://") || trimmed.startsWith("/")) return null; + return trimmed; +} + +function validateAudioManifest(value) { + if (!value || value.schema !== "successor-sfx-manifest-v1" || + !value.buses || typeof value.buses !== "object" || !Array.isArray(value.clips)) { + throw new TypeError("invalid audio manifest"); + } + const buses = new Set(Object.keys(value.buses)); + const ids = new Set(); + return value.clips.map(clip => { + const path = normalizedAudioPath(clip?.path); + if (!clip || typeof clip.id !== "string" || !clip.id || ids.has(clip.id) || !path || + !buses.has(clip.bus) || !Number.isFinite(clip.volume) || + !Number.isSafeInteger(clip.polyphony) || clip.polyphony <= 0) { + throw new TypeError("invalid audio clip"); + } + ids.add(clip.id); + return path; + }); +} + +function runVoicePolicySelfCheck() { + return voiceLimit(4, false) === 10 && voiceLimit(4, true) === 4 && + Math.abs(concurrencyGain(8) - 1) < 1e-9 && + Math.abs(concurrencyGain(9) - 1 / Math.sqrt(1.72)) < 1e-9 && + 65 > 64 && [0.1, 0.8, 0.3].reduce((best, value) => value > best ? value : best, -1) === 0.8; +} + +async function prepareWebAudio() { + const cache = globalThis.__successorFetchCache; + const manifestBytes = cache?.get(AUDIO_MANIFEST_PATH); + if (!manifestBytes) throw new Error("audio manifest missing from initial assets"); + const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)); + const paths = validateAudioManifest(manifest); + const context = ensureAudioContext(); + audioAvailable = Boolean(context); + if (!context) { + audioPrepared = true; + return; + } + // Core audio ships in its own pack fetched right after the visual boot + // stage; wait for it so panel/chat/footstep cues are decoded at entry. + if (releaseFiles?.has("packs/audio-boot.spak")) { + try { await fetchPackIntoCache("packs/audio-boot.spak"); } catch (error) { audioError("packs/audio-boot.spak", error); } + } + await decodeCachedClips(paths); + audioPrepared = true; +} + +async function decodeCachedClips(paths) { + const cache = globalThis.__successorFetchCache; + let next = 0; + const worker = async () => { + while (next < paths.length) { + const path = paths[next++]; + if (audioBuffers.has(path)) continue; + const bytes = cache.get(path); + if (!bytes) continue; // undecoded clips decode lazily on first trigger + try { + const copy = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + audioBuffers.set(path, await audioContext.decodeAudioData(copy)); + } catch (error) { + audioError(path, error); + } finally { + cache.delete(path); + } + } + }; + await Promise.all(Array.from({ length: Math.min(6, paths.length) }, worker)); +} + +// Lazy clip path: a trigger for an undecoded clip starts the fetch/decode and +// is itself skipped; subsequent triggers play normally. +const pendingClipDecodes = new Map(); +function ensureClipDecode(path) { + if (audioBuffers.has(path) || pendingClipDecodes.has(path) || !audioContext) return; + pendingClipDecodes.set(path, (async () => { + try { + const cache = globalThis.__successorFetchCache; + if (!cache.get(path)) { + const packed = packIndex[path]; + if (packed) await fetchPackIntoCache(packed.pack); + else await fetchStandaloneIntoCache(path); + } + await decodeCachedClips([path]); + } catch (error) { + audioError(path, error); + } finally { + pendingClipDecodes.delete(path); + } + })()); +} + +function deepFreeze(value) { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + for (const nested of Object.values(value)) deepFreeze(nested); + } + return value; +} + +Object.defineProperty(window, "__successorAudioProbe", { + configurable: false, + get: () => deepFreeze({ + ready: audioPrepared, + available: audioAvailable, + gestureUnlocked, + decodedCount: audioBuffers.size, + encodedClipCacheCount: [...(globalThis.__successorFetchCache?.keys() || [])] + .filter(path => path.startsWith("successor-audio/") && path.endsWith(".mp3")).length, + manifestCachePresent: Boolean(globalThis.__successorFetchCache?.has(AUDIO_MANIFEST_PATH)), + activeVoices: audioVoices.size, + activeVoiceCountsByKey: [...audioVoicesByKey].map(([key, voices]) => ({ key, count: voices.size })) + .sort((a, b) => a.key - b.key), + activeLoops: [...audioVoicesByKey].map(([key, voices]) => ({ + key, + activeSourceCount: [...voices].filter(voice => voice.looped).length, + sourceSequences: [...voices].filter(voice => voice.looped).map(voice => voice.sequence).sort((a, b) => a - b) + })).filter(loop => loop.activeSourceCount > 0).sort((a, b) => a.key - b.key), + masterConcurrencyGain: concurrencyGain(audioVoices.size), + voicePolicySelfCheck: runVoicePolicySelfCheck(), + recent: audioRecent.map(record => ({ ...record })), + errors: [...audioErrors] + }) +}); +window.__successorAudioState = () => audioContext?.state ?? "locked"; + +function glGet(id) { + return id > 0 && id < glResources.length ? glResources[id] : null; +} + +// WebSocket connections table +const wsConnections = [null]; + +let wasmMemory; +let wasmExports = {}; + +// Helper: Decode a string (ptr + len) from WASM memory +function getString(ptr, len) { + if (!wasmMemory) return ""; + const bytes = new Uint8Array(wasmMemory.buffer, ptr, len); + return new TextDecoder().decode(bytes); +} + +// Manifest files keyed by stable id, populated by fetchInitialAssets. In +// object-store releases assets resolve to content-addressed immutable objects +// so the browser/CDN cache survives release boundaries. +let releaseFiles = null; +let packIndex = {}; +function assetUrl(path) { + if (!successorBuild.objectStore) return path; + const file = releaseFiles?.get(path); + if (!file) throw new Error(`asset not in release manifest: ${path}`); + return `/objects/${file.sha256}`; +} + +// ── Asset packs (successor.assetpack.v1) ──────────────────────────────────── +// Packs are a pure transport concern: they are unpacked into the stable-id +// byte cache and the Rust runtime always reads by stable id. +const fetchedPacks = new Map(); +// Async asset channel state for js_asset_begin/js_asset_poll. Entries: +// { state: "pending" | "ready" | "error", bytes: Uint8Array | null }. +const assetRequests = new Map(); +let nextAssetHandle = 1; +function unpackPack(bytes) { + const magic = new TextDecoder().decode(bytes.subarray(0, 6)); + if (magic !== "SPAK1\n") throw new Error("bad pack magic"); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const indexLength = view.getUint32(6, true); + const indexStart = 10; + const index = JSON.parse(new TextDecoder().decode(bytes.subarray(indexStart, indexStart + indexLength))); + if (index.schema !== "successor.assetpack.v1" || !Array.isArray(index.entries)) throw new Error("bad pack index"); + const payloadStart = indexStart + indexLength; + return index.entries.map(entry => { + const slice = bytes.slice(payloadStart + entry.offset, payloadStart + entry.offset + entry.bytes); + if (slice.byteLength !== entry.bytes) throw new Error(`pack entry truncated: ${entry.path}`); + return [entry.path, slice]; + }); +} + +async function fetchPackIntoCache(packPath) { + let pending = fetchedPacks.get(packPath); + if (!pending) { + pending = (async () => { + const response = await fetch(assetUrl(packPath)); + if (!response.ok) throw new Error(`pack ${response.status}: ${packPath}`); + for (const [path, bytes] of unpackPack(new Uint8Array(await response.arrayBuffer()))) { + globalThis.__successorFetchCache.set(path, bytes); + } + })(); + fetchedPacks.set(packPath, pending); + } + return pending; +} + +function fetchPackIntoCacheSync(packPath) { + const xhr = new XMLHttpRequest(); + xhr.open("GET", assetUrl(packPath), false); // synchronous + xhr.overrideMimeType("text/plain; charset=x-user-defined"); + xhr.send(null); + if (xhr.status !== 200) throw new Error(`pack ${xhr.status}: ${packPath}`); + const text = xhr.responseText; + const bytes = new Uint8Array(text.length); + for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i) & 0xff; + for (const [path, entryBytes] of unpackPack(bytes)) { + globalThis.__successorFetchCache.set(path, entryBytes); + } +} + +async function fetchStandaloneIntoCache(path, onBytes) { + const file = releaseFiles.get(path); + const response = await fetch(assetUrl(path)); + if (!response.ok) throw new Error(`asset ${response.status}: ${path}`); + const bytes = new Uint8Array(await response.arrayBuffer()); + if (file && bytes.byteLength !== file.bytes) throw new Error(`asset size mismatch: ${path}`); + globalThis.__successorFetchCache.set(path, bytes); + if (onBytes) onBytes(bytes.byteLength); +} + +// Stage 1: visual boot pack + standalone boot documents — the world opens as +// soon as these land. Stage 2: core audio. Everything else streams after the +// first world frame (and on demand via the async asset channel). +async function fetchInitialAssets() { + showLoading("STREAMING WORLD", "READING RELEASE MANIFEST", 0); + const manifestResponse = await fetch("release-manifest.json", { cache: "no-store" }); + if (!manifestResponse.ok) throw new Error(`release manifest ${manifestResponse.status}`); + const manifest = await manifestResponse.json(); + releaseFiles = new Map(manifest.files.map(file => [file.path, file])); + packIndex = manifest.packIndex ?? {}; + const boot = manifest.boot ?? manifest.initialAssets; + if (!Array.isArray(boot) || boot.length === 0) throw new Error("release manifest has no boot stream"); + globalThis.__successorFetchCache = new Map(); + + const AUDIO_BOOT_PACK = "packs/audio-boot.spak"; + const stagePacks = new Set(); + const stageStandalone = []; + let total = 0; + for (const id of boot) { + const packed = packIndex[id]; + if (packed) { + if (packed.pack !== AUDIO_BOOT_PACK) { + stagePacks.add(packed.pack); + total += packed.bytes; + } + } else { + stageStandalone.push(id); + total += releaseFiles.get(id)?.bytes ?? 0; + } + } + // Spawn-neighborhood region packs stage with the visual boot so the first + // world frame has its props; every other region streams via the watcher. + for (const packPath of manifest.bootPacks ?? []) { + if (!stagePacks.has(packPath) && releaseFiles.has(packPath)) { + stagePacks.add(packPath); + total += releaseFiles.get(packPath)?.bytes ?? 0; + } + } + let loaded = 0; + const mib = value => (value / 1048576).toFixed(1); + const report = () => showLoading("STREAMING WORLD", `${mib(loaded)} / ${mib(total)} MiB`, total > 0 ? loaded / total : 1); + report(); + const stageJobs = [...stagePacks].map(async packPath => { + await fetchPackIntoCache(packPath); + loaded += releaseFiles.get(packPath)?.bytes ?? 0; + report(); + }); + let nextStandalone = 0; + const standaloneWorker = async () => { + while (nextStandalone < stageStandalone.length) { + const id = stageStandalone[nextStandalone++]; + await fetchStandaloneIntoCache(id, bytes => { loaded += bytes; report(); }); + } + }; + await Promise.all([...stageJobs, ...Array.from({ length: Math.min(6, stageStandalone.length) }, standaloneWorker)]); + if (releaseFiles.has(AUDIO_BOOT_PACK)) { + fetchPackIntoCache(AUDIO_BOOT_PACK).catch(error => audioError(AUDIO_BOOT_PACK, error)); + } +} + +// After the first world frame: pull the shared remainder packs only +// (audio-rest, world-rest). Wardrobe, creature, and region-pack bytes are +// deliberately NOT prefetched — they stream on demand through the async +// asset channel (wearing actor / creature entering AOI, region watcher). +// Races with on-demand requests are deduped by fetchedPacks / the byte cache. +function startBackgroundPrefetch() { + const packs = [...releaseFiles.keys()].filter(path => + path.startsWith("packs/") && + !path.startsWith("packs/region/") && + path !== "packs/boot.spak" && + !fetchedPacks.has(path)); + (async () => { + for (const packPath of packs) { + try { await fetchPackIntoCache(packPath); } catch (error) { audioError(packPath, error); } + } + })().catch(() => {}); +} + +let firstWorldFrame = false; +const importObject = { + env: { + // --- WebGL2 Functions --- + glClearColor: (r, g, b, a) => gl.clearColor(r, g, b, a), + glClear: (mask) => gl.clear(mask), + glFinish: () => gl.finish(), + glViewport: (x, y, w, h) => gl.viewport(x, y, w, h), + glEnable: (cap) => gl.enable(cap), + glDisable: (cap) => gl.disable(cap), + glCullFace: (mode) => gl.cullFace(mode), + glDepthMask: (flag) => gl.depthMask(flag !== 0), + glColorMask: (r, g, b, a) => gl.colorMask(r !== 0, g !== 0, b !== 0, a !== 0), + glBlendFunc: (s, d) => gl.blendFunc(s, d), + + glCreateShader: (type) => glAlloc(gl.createShader(type)), + glShaderSource: (shader, ptr, len) => { + const src = getString(ptr, len); + gl.shaderSource(glGet(shader), src); + }, + glCompileShader: (shader) => gl.compileShader(glGet(shader)), + glGetShaderiv: (shader, pname) => { + const param = gl.getShaderParameter(glGet(shader), pname); + return typeof param === "boolean" ? (param ? 1 : 0) : param; + }, + glGetShaderInfoLog: (shader, bufPtr, maxLen) => { + const log = gl.getShaderInfoLog(glGet(shader)) || ""; + const bytes = new TextEncoder().encode(log); + const truncated = bytes.subarray(0, maxLen); + const dest = new Uint8Array(wasmMemory.buffer, bufPtr, maxLen); + dest.set(truncated); + return truncated.length; + }, + glDeleteShader: (shader) => { + gl.deleteShader(glGet(shader)); + glResources[shader] = null; + }, + + glCreateProgram: () => glAlloc(gl.createProgram()), + glAttachShader: (program, shader) => gl.attachShader(glGet(program), glGet(shader)), + glLinkProgram: (program) => gl.linkProgram(glGet(program)), + glGetProgramiv: (program, pname) => { + const param = gl.getProgramParameter(glGet(program), pname); + return typeof param === "boolean" ? (param ? 1 : 0) : param; + }, + glGetProgramInfoLog: (program, bufPtr, maxLen) => { + const log = gl.getProgramInfoLog(glGet(program)) || ""; + const bytes = new TextEncoder().encode(log); + const truncated = bytes.subarray(0, maxLen); + const dest = new Uint8Array(wasmMemory.buffer, bufPtr, maxLen); + dest.set(truncated); + return truncated.length; + }, + glUseProgram: (program) => gl.useProgram(glGet(program)), + glDeleteProgram: (program) => { + gl.deleteProgram(glGet(program)); + glResources[program] = null; + }, + + glGetUniformLocation: (program, ptr, len) => { + const name = getString(ptr, len); + const loc = gl.getUniformLocation(glGet(program), name); + if (!loc) return -1; + return glAlloc(loc); + }, + glUniform1i: (loc, val) => gl.uniform1i(glGet(loc), val), + glUniform1f: (loc, val) => gl.uniform1f(glGet(loc), val), + glUniform2f: (loc, x, y) => gl.uniform2f(glGet(loc), x, y), + glUniform3f: (loc, x, y, z) => gl.uniform3f(glGet(loc), x, y, z), + glUniform4f: (loc, x, y, z, w) => gl.uniform4f(glGet(loc), x, y, z, w), + glUniformMatrix4fv: (loc, count, transpose, ptr) => { + const view = new Float32Array(wasmMemory.buffer, ptr, count * 16); + gl.uniformMatrix4fv(glGet(loc), transpose !== 0, view); + }, + glUniform3fv: (loc, count, ptr) => { + const view = new Float32Array(wasmMemory.buffer, ptr, count * 3); + gl.uniform3fv(glGet(loc), view); + }, + glUniform1fv: (loc, count, ptr) => { + const view = new Float32Array(wasmMemory.buffer, ptr, count); + gl.uniform1fv(glGet(loc), view); + }, + + glGenTexture: () => glAlloc(gl.createTexture()), + glDeleteTexture: (tex) => { + gl.deleteTexture(glGet(tex)); + glResources[tex] = null; + }, + glBindTexture: (target, tex) => gl.bindTexture(target, glGet(tex)), + glActiveTexture: (unit) => gl.activeTexture(unit), + glTexParameteri: (target, pname, param) => gl.texParameteri(target, pname, param), + glTexImage2D: (target, level, internalFormat, width, height, border, format, type, ptr, len) => { + const pixels = len > 0 ? new Uint8Array(wasmMemory.buffer, ptr, len) : null; + gl.texImage2D(target, level, internalFormat, width, height, border, format, type, pixels); + }, + + glGenBuffer: () => glAlloc(gl.createBuffer()), + glDeleteBuffer: (buf) => { + gl.deleteBuffer(glGet(buf)); + glResources[buf] = null; + }, + glBindBuffer: (target, buf) => gl.bindBuffer(target, glGet(buf)), + glGetInteger: (pname) => gl.getParameter(pname) | 0, + glReadPixels: (x, y, width, height, format, type, ptr, len) => { + const pixels = new Uint8Array(wasmMemory.buffer, ptr, len); + gl.readPixels(x, y, width, height, format, type, pixels); + }, + glBufferData: (target, ptr, len, usage) => { + const bytes = new Uint8Array(wasmMemory.buffer, ptr, len); + gl.bufferData(target, bytes, usage); + }, + + glGenVertexArray: () => glAlloc(gl.createVertexArray()), + glDeleteVertexArray: (vao) => { + gl.deleteVertexArray(glGet(vao)); + glResources[vao] = null; + }, + glBindVertexArray: (vao) => gl.bindVertexArray(glGet(vao)), + glVertexAttribPointer: (index, size, type, normalized, stride, offset) => { + gl.vertexAttribPointer(index, size, type, normalized !== 0, stride, offset); + }, + glEnableVertexAttribArray: (index) => gl.enableVertexAttribArray(index), + glDisableVertexAttribArray: (index) => gl.disableVertexAttribArray(index), + + glDrawArrays: (mode, first, count) => gl.drawArrays(mode, first, count), + glDrawElements: (mode, count, type, offset) => gl.drawElements(mode, count, type, offset), + glVertexAttribDivisor: (index, divisor) => gl.vertexAttribDivisor(index, divisor), + glDrawElementsInstanced: (mode, count, type, offset, primcount) => gl.drawElementsInstanced(mode, count, type, offset, primcount), + glDrawArraysInstanced: (mode, first, count, primcount) => gl.drawArraysInstanced(mode, first, count, primcount), + + glGenFramebuffer: () => glAlloc(gl.createFramebuffer()), + glDeleteFramebuffer: (fbo) => { + gl.deleteFramebuffer(glGet(fbo)); + glResources[fbo] = null; + }, + glBindFramebuffer: (target, fbo) => gl.bindFramebuffer(target, glGet(fbo)), + glFramebufferTexture2D: (target, attachment, texTarget, tex, level) => { + gl.framebufferTexture2D(target, attachment, texTarget, glGet(tex), level); + }, + glCheckFramebufferStatus: (target) => gl.checkFramebufferStatus(target), + glDrawBuffers: (ptr, len) => { + const attachments = new Uint32Array(wasmMemory.buffer, ptr, len); + gl.drawBuffers(Array.from(attachments)); + }, + glPixelStorei: (pname, param) => gl.pixelStorei(pname, param), + glTexImage3D: (target, level, internalFormat, width, height, depth, border, format, type, ptr, len) => { + const pixels = len > 0 ? new Uint8Array(wasmMemory.buffer, ptr, len) : null; + gl.texImage3D(target, level, internalFormat, width, height, depth, border, format, type, pixels); + }, + glTexSubImage3D: (target, level, xoffset, yoffset, zoffset, width, height, depth, format, type, ptr, len) => { + const pixels = new Uint8Array(wasmMemory.buffer, ptr, len); + gl.texSubImage3D(target, level, xoffset, yoffset, zoffset, width, height, depth, format, type, pixels); + }, + glGenerateMipmap: (target) => gl.generateMipmap(target), + glCapHalfFloatTarget: () => { + const disabled = new URLSearchParams(location.search).has("disable-half-float"); + const supported = Boolean(gl.getExtension("EXT_color_buffer_float") || + gl.getExtension("EXT_color_buffer_half_float")); + window.__successorHalfFloatTarget = supported && !disabled; + return window.__successorHalfFloatTarget ? 1 : 0; + }, + + // --- Window/Input/Time Functions --- + js_init: (titlePtr, titleLen, w, h) => { + const title = getString(titlePtr, titleLen); + console.log(`js_init: "${title}", target size ${w}x${h}`); + }, + js_log: (ptr, len) => { + const str = getString(ptr, len); + if (!window.__successorRenderLog) window.__successorRenderLog = []; + window.__successorRenderLog.push(str); + console.log(str); + }, + js_get_canvas_size: (w_ptr, h_ptr) => { + const w_arr = new Int32Array(wasmMemory.buffer, w_ptr, 1); + const h_arr = new Int32Array(wasmMemory.buffer, h_ptr, 1); + w_arr[0] = canvas.width; + h_arr[0] = canvas.height; + }, + js_get_mouse_x: () => mouseX, + js_get_mouse_y: () => mouseY, + js_mouse_button_down: (button) => button < mouseButtons.length ? mouseButtons[button] : 0, + js_launch_context_len: () => new TextEncoder().encode(launchContextText).length, + js_launch_context_copy: (ptr, maxLen) => { + const bytes = new TextEncoder().encode(launchContextText); + const len = Math.min(bytes.length, maxLen); + new Uint8Array(wasmMemory.buffer, ptr, len).set(bytes.subarray(0, len)); + return len; + }, + js_creator_mode: () => creatorMode ? 1 : 0, + js_creator_ready: () => { creatorBridge?.ready(); }, + js_creator_message_len: () => creatorBridge?.messageLength() ?? 0, + js_creator_message_copy: (ptr, maxLen) => creatorBridge?.copyMessage(ptr, maxLen) ?? 0, + js_creator_message_discard: () => { creatorBridge?.discardMessage(); }, + js_creator_post_create: (ptr, len) => creatorBridge?.postCreate(getString(ptr, len)) ? 1 : 0, + js_creator_post_select: (ptr, len) => creatorBridge?.postSelect(getString(ptr, len)) ? 1 : 0, + js_audio_unlock: () => audioUnlock(), + js_audio_play: (ptr, len, key, gain, pan, looped, polyphony) => + audioPlay(getString(ptr, len), key, gain, pan, looped !== 0, polyphony) ? 1 : 0, + js_audio_stop: key => audioStop(key), + js_audio_active_voices: () => audioVoices.size, + js_now_ms: () => performance.now(), + js_is_key_down: (key) => { + return key < keyState.length ? keyState[key] : 0; + }, + js_set_cursor_visible: (visible) => { + canvas.style.cursor = visible ? "default" : "none"; + }, + js_poll_char: () => { + return charQueue.length > 0 ? charQueue.shift() : -1; + }, + js_poll_scroll_x: () => { + const value = scrollX; scrollX = 0; return value; + }, + js_poll_scroll_y: () => { + const value = scrollY; scrollY = 0; return value; + }, + + // --- WebSocket & Fetch Functions --- + js_ws_connect: (urlPtr, urlLen) => { + const url = getString(urlPtr, urlLen); + try { + const ws = new WebSocket(url); + ws.binaryType = "arraybuffer"; + + const handle = wsConnections.length; + const state = { + ws, + open: false, + openReported: false, + closed: false, + error: false, + queue: [] + }; + + wsConnections.push(state); + + ws.onopen = () => { state.open = true; }; + ws.onmessage = (e) => { state.queue.push(new Uint8Array(e.data)); }; + ws.onclose = () => { state.closed = true; }; + ws.onerror = () => { state.error = true; }; + + return handle; + } catch (e) { + console.error("WebSocket connect failed:", e); + return 0; + } + }, + js_ws_send: (id, ptr, len) => { + const state = wsConnections[id]; + if (state && state.ws.readyState === WebSocket.OPEN) { + const bytes = new Uint8Array(wasmMemory.buffer, ptr, len).slice(); + state.ws.send(bytes); + } + }, + js_ws_poll: (id, bufPtr, maxLen) => { + const state = wsConnections[id]; + if (!state) return -2; + + if (state.error) { + state.error = false; + return -2; + } + + if (state.open && !state.openReported) { + state.openReported = true; + return -3; + } + + if (state.queue.length > 0) { + const msg = state.queue.shift(); + const len = Math.min(msg.length, maxLen); + const dest = new Uint8Array(wasmMemory.buffer, bufPtr, len); + dest.set(msg.subarray(0, len)); + return len; + } + + if (state.closed) { + return -1; + } + + return 0; + }, + js_ws_close: (id) => { + const state = wsConnections[id]; + if (state) { + state.ws.close(); + } + }, + js_fetch_post_json: (urlPtr, urlLen, bodyPtr, bodyLen, outPtr, outMaxLen) => { + const url = getString(urlPtr, urlLen); + const body = getString(bodyPtr, bodyLen); + + try { + const xhr = new XMLHttpRequest(); + xhr.open("POST", url, false); // synchronous XMLHttpRequest + xhr.setRequestHeader("Content-Type", "application/json"); + xhr.send(body); + + if (xhr.status === 200) { + const text = xhr.responseText; + const bytes = new TextEncoder().encode(text); + const len = Math.min(bytes.length, outMaxLen); + const dest = new Uint8Array(wasmMemory.buffer, outPtr, len); + dest.set(bytes.subarray(0, len)); + return len; + } else { + console.error("fetch_post_json server error status:", xhr.status); + return -1; + } + } catch (e) { + console.error("fetch_post_json network error:", e); + return -1; + } + }, + // Two-phase binary GET. First call (outMaxLen 0) fetches synchronously, + // caches by url, and returns the total byte length. Second call copies + // up to outMaxLen bytes from the cached blob. Returns -1 on error. + js_fetch_get: (urlPtr, urlLen, outPtr, outMaxLen) => { + const url = getString(urlPtr, urlLen); + try { + if (!globalThis.__successorFetchCache) globalThis.__successorFetchCache = new Map(); + const cache = globalThis.__successorFetchCache; + + let bytes = cache.get(url); + if (!bytes && packIndex[url]) { + // Rust demanded a packed asset before the background stream + // reached it: sync-fetch the whole pack once (logged — every + // hit here is a prefetch-ordering bug worth fixing). + console.warn("sync pack fallback:", url, "via", packIndex[url].pack); + try { + fetchPackIntoCacheSync(packIndex[url].pack); + } catch (error) { + console.error("pack fallback failed:", url, error); + return -1; + } + bytes = cache.get(url); + } + if (!bytes) { + console.warn("sync asset fallback:", url); + const xhr = new XMLHttpRequest(); + xhr.open("GET", assetUrl(url), false); // synchronous + xhr.overrideMimeType("text/plain; charset=x-user-defined"); + xhr.send(null); + if (xhr.status !== 200) { + console.error("fetch_get error status:", xhr.status, url); + return -1; + } + const text = xhr.responseText; + bytes = new Uint8Array(text.length); + for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i) & 0xff; + cache.set(url, bytes); + } + if (outMaxLen > 0 && outPtr !== 0) { + const len = Math.min(bytes.length, outMaxLen); + const dest = new Uint8Array(wasmMemory.buffer, outPtr, len); + dest.set(bytes.subarray(0, len)); + if (len >= bytes.length && url !== AUDIO_MANIFEST_PATH) cache.delete(url); + return len; + } + return bytes.length; + } catch (e) { + console.error("fetch_get network error:", e, url); + return -1; + } + }, + // Async asset channel backing Rust Platform::begin_asset. Starts a + // real (non-blocking) fetch through the same pack-index/object-store + // resolution as js_fetch_get, then returns a handle immediately. + js_asset_begin: (pathPtr, pathLen) => { + const path = getString(pathPtr, pathLen); + if (!globalThis.__successorFetchCache) globalThis.__successorFetchCache = new Map(); + const cache = globalThis.__successorFetchCache; + const handle = nextAssetHandle++; + const entry = { state: "pending", bytes: null }; + assetRequests.set(handle, entry); + const cached = cache.get(path); + if (cached) { + entry.state = "ready"; + entry.bytes = cached; + return handle; + } + const fail = (error) => { + console.error("async asset failed:", path, error); + entry.state = "error"; + }; + const complete = () => { + const bytes = cache.get(path); + if (bytes) { + entry.state = "ready"; + entry.bytes = bytes; + } else { + fail(new Error("asset missing after fetch")); + } + }; + const pack = packIndex[path]; + if (pack) fetchPackIntoCache(pack.pack).then(complete).catch(fail); + else fetchStandaloneIntoCache(path).then(complete).catch(fail); + return handle; + }, + // Poll an in-flight async fetch. -1 pending, -2 failed/unknown; once + // ready, a null buffer returns the length and the copy call delivers + // the bytes and consumes the entry (two-phase, like js_fetch_get). + js_asset_poll: (id, outPtr, outMaxLen) => { + const entry = assetRequests.get(id); + if (!entry) return -2; + if (entry.state === "pending") return -1; + if (entry.state === "error") { + assetRequests.delete(id); + return -2; + } + const bytes = entry.bytes; + if (outMaxLen > 0 && outPtr !== 0) { + const len = Math.min(bytes.length, outMaxLen); + const dest = new Uint8Array(wasmMemory.buffer, outPtr, len); + dest.set(bytes.subarray(0, len)); + assetRequests.delete(id); + return len; + } + return bytes.length; + } + } +}; + +let lastTime = performance.now(); + +// Load the WASM module +fetch("successor.wasm") + .then(response => response.arrayBuffer()) + .then(bytes => WebAssembly.instantiate(bytes, importObject)) + .then(async results => { + const instance = results.instance; + wasmMemory = instance.exports.memory; + wasmExports = instance.exports; + window.__successorMovementProbe = () => { + if (typeof wasmExports.movement_probe !== "function") return null; + const value = index => wasmExports.movement_probe(index); + const authoritative = [value(0), value(1)]; + if (!Number.isFinite(authoritative[0])) return null; + return { + authoritative, + predicted: [value(2), value(3)], + rendered: [value(4), value(5)], + correctionCells: value(6), + intent: [value(7), value(8), value(9) === 1], + appliedCommandId: value(10), + blockerCount: value(11), + presentedGroundY: value(12), + sampledGroundY: value(13), + frameDtMs: value(14), + lastChangeMs: value(15), + lastSendMs: value(16), + nextSendMs: value(17), + stopRetryUntilMs: value(18), + sampledAtMs: value(19), + }; + }; + + if (creatorStartupError) throw creatorStartupError; + const params = new URLSearchParams(window.location.search); + const demoName = params.get("demo"); + const demoSelector = creatorMode + ? 0 + : demoName === "material-parity" + ? 1 + : demoName === "terrain-material" + ? (params.get("biome") === "forest" ? 3 : 2) + : 0; + if (demoSelector === 0 && creatorMode) { + showLoading("CHARACTER WORKSHOP", "WAITING FOR ROSTER", 1); + } else if (demoSelector === 0) { + await fetchInitialAssets(); + await prepareWebAudio(); + showLoading("CONNECTING", "WAITING FOR LAUNCH", 1); + await waitForHostedLaunch(); + showLoading("ENTERING WORLD", "BUILDING SCENE", 1); + } + window.__successorRenderReady = false; + window.__successorRenderError = null; + window.__successorRenderProbe = null; + if (demoSelector === 1) { + const parityAssets = [ + "commerce_facility.glb", + "lightning_carbine.glb", + "mossmuff_adult.glb", + "successor_food_beer_mug.glb", + "field_cap.glb", + "megalith_brick_hex.glb" + ]; + globalThis.__successorFetchCache = new Map(); + await Promise.all(parityAssets.map(async name => { + const url = `parity-assets/${name}`; + const response = await fetch(url); + if (!response.ok) throw new Error(`asset fetch ${response.status}: ${url}`); + globalThis.__successorFetchCache.set( + url, + new Uint8Array(await response.arrayBuffer()) + ); + })); + } + if (typeof wasmExports.init === "function") { + wasmExports.init(demoSelector); + } + if (!creatorMode) installHostedExitHandler(); + // Kick the wasm networking runtime (optional export): connected play + // only. Creator mode has no tickets, launch context, or socket path. + if (!creatorMode && typeof wasmExports.net_connect === "function") { + try { wasmExports.net_connect(); } catch (e) { console.warn("net_connect:", e); } + } + + // Call resize on resize + function resizeCanvas() { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + if (typeof wasmExports.resize === "function") { + wasmExports.resize(canvas.width, canvas.height); + } + } + window.addEventListener("resize", resizeCanvas); + resizeCanvas(); + + let renderedFrames = 0; + function tick(time) { + const dt = (time - lastTime) / 1000.0; + lastTime = time; + try { + if (!creatorMode && typeof wasmExports.net_poll === "function" && demoSelector === 0) { + wasmExports.net_poll(); + } + if (!webglContextLost) { + if (typeof wasmExports.update === "function") { + wasmExports.update(dt); + } + if (typeof wasmExports.render === "function") { + wasmExports.render(); + } + } + if (creatorMode && typeof wasmExports.net_fatal === "function" && wasmExports.net_fatal() === 1) { + throw new Error("creator runtime entered fatal state"); + } + renderedFrames += 1; + if (creatorMode && demoSelector === 0 && renderedFrames > 2) { + window.__successorRenderReady = true; + if (!firstWorldFrame) { + firstWorldFrame = true; + finishLoading(); + startBackgroundPrefetch(); + } + } else if (demoSelector === 0 && typeof wasmExports.net_state === "function") { + const state = wasmExports.net_state(); + window.__successorNetState = state; + if (state === 4 && renderedFrames > 2) { + window.__successorRenderReady = true; + if (!firstWorldFrame) { + firstWorldFrame = true; + finishLoading(); + startBackgroundPrefetch(); + } + } + if (typeof wasmExports.net_fatal === "function" && wasmExports.net_fatal() === 1) { + throw new Error("connected runtime entered fatal state"); + } + } + if (demoSelector === 1 && renderedFrames === 120 && typeof wasmExports.probe_material_parity === "function") { + const passed = wasmExports.probe_material_parity(); + window.__successorRenderProbe = { + passed: passed === 1, + frame: renderedFrames, + width: canvas.width, + height: canvas.height + }; + window.__successorRenderReady = passed === 1; + if (passed !== 1) window.__successorRenderError = "material parity probe failed"; + } + if ((demoSelector === 2 || demoSelector === 3) && renderedFrames === 120 && typeof wasmExports.probe_terrain_material === "function") { + const passed = wasmExports.probe_terrain_material(); + if (passed !== 1) { + throw new Error("terrain material probe failed"); + } + window.__successorRenderProbe = { terrainMaterial: true }; + window.__successorRenderReady = true; + } + } catch (error) { + window.__successorRenderError = String(error); + failLoading(error); + console.error("render loop failed:", error); + return; + } + requestAnimationFrame(tick); + } + requestAnimationFrame(tick); + }) + .catch(err => { + console.error("WASM instantiation failed:", err); + window.__successorRenderReady = false; + window.__successorRenderError = String(err); + failLoading(err); + }); diff --git a/client-tui/src/account/hosted.ts b/client-tui/src/account/hosted.ts index e79bcf23..07db7fd1 100644 --- a/client-tui/src/account/hosted.ts +++ b/client-tui/src/account/hosted.ts @@ -95,6 +95,7 @@ function buildLaunchOptions( hosted: { gameTicket: envelope.gameTicket, chatTicket: envelope.chatTicket, + clientReleaseId: envelope.release.client, origin, onLegFailure, }, diff --git a/client-tui/src/account/origin.test.ts b/client-tui/src/account/origin.test.ts index 833c8689..ddeba799 100644 --- a/client-tui/src/account/origin.test.ts +++ b/client-tui/src/account/origin.test.ts @@ -47,6 +47,7 @@ describe("hosted origin admission", () => { characterId: "char_a", actorId: "char_a", gameTicket: "game-capability", + clientReleaseId: "test-client-release", origin: "https://www.successorgame.com", readyTimeoutMs: 3_000, }); diff --git a/client-tui/src/app.ts b/client-tui/src/app.ts index 409bb4cf..1d235132 100644 --- a/client-tui/src/app.ts +++ b/client-tui/src/app.ts @@ -182,6 +182,7 @@ export async function runTui(options: TuiOptions): Promise { characterId: options.characterId, ticket: options.ticket, gameTicket: options.hosted?.gameTicket, + clientReleaseId: options.hosted?.clientReleaseId, chatTicket: options.hosted?.chatTicket, origin: options.hosted?.origin, spawnArea: options.spawnArea, diff --git a/client-tui/src/game/session.ts b/client-tui/src/game/session.ts index 3210ab3e..89f9cdc9 100644 --- a/client-tui/src/game/session.ts +++ b/client-tui/src/game/session.ts @@ -296,6 +296,7 @@ export async function createGameSession(options: SessionOptions): Promise emit({ kind: "status", status: "chat-failed", message: "the chat leg closed" }), ...(options.origin ? { socketFactory: (wsUrl: string) => hostedChatSocket(wsUrl, options.origin!) } : {}), }); diff --git a/client-tui/src/options.ts b/client-tui/src/options.ts index 6c9f1904..b208b435 100644 --- a/client-tui/src/options.ts +++ b/client-tui/src/options.ts @@ -38,6 +38,7 @@ export interface TuiOptions { export interface HostedLaunch { gameTicket?: string; chatTicket?: string; + clientReleaseId?: string; /** Exact storefront Origin for matchmake/WS admission. Not a secret, but * carried only in memory alongside the tickets. */ origin?: string; diff --git a/client-tui/src/plain.ts b/client-tui/src/plain.ts index a9f0c86c..f8d930fa 100644 --- a/client-tui/src/plain.ts +++ b/client-tui/src/plain.ts @@ -55,6 +55,7 @@ export async function runPlain(options: TuiOptions): Promise { characterId: options.characterId, ticket: options.ticket, gameTicket: options.hosted?.gameTicket, + clientReleaseId: options.hosted?.clientReleaseId, chatTicket: options.hosted?.chatTicket, origin: options.hosted?.origin, spawnArea: options.spawnArea, diff --git a/client/package.json b/client/package.json index 3aac86ad..4168f2d7 100644 --- a/client/package.json +++ b/client/package.json @@ -17,7 +17,7 @@ "build": "tsc && vite build --config vite.headless.config.ts", "build:headless": "vite build --config vite.headless.config.ts", "lint": "eslint src", - "test": "vitest run --passWithNoTests", + "test": "vitest run --passWithNoTests --no-file-parallelism", "verify:slice-specs": "node scripts/verify-slice-specs.mjs" }, "devDependencies": { diff --git a/client/public/successor-slice/open-desert-map-bundle.json b/client/public/successor-slice/open-desert-map-bundle.json index 5413e7c7..140c4b0b 100644 --- a/client/public/successor-slice/open-desert-map-bundle.json +++ b/client/public/successor-slice/open-desert-map-bundle.json @@ -3,7 +3,7 @@ "source": { "schema": "successor.slice.v1", "path": "client/public/successor-slice/open-desert-slice.json", - "hash": "ad43d53f", + "hash": "09ef5115", "stateHash": "planetfall-v5-seed-424242-size-1024-rogues-18-desert-critters-48-verdance-critters-24-areas-open-desert-overworld-verdance-forest-overworld" }, "grid": { @@ -40443,8 +40443,7 @@ "dustgate-occupation-vendor-awning", "dustgate-occupation-wicker-display", "dustgate-pa-terminal", - "dustgate-trade-terminal", - "open-desert-shelter-house" + "dustgate-trade-terminal" ], "transitionIds": [], "cloneFacilityIds": [], @@ -40470,14 +40469,14 @@ "propIds": [ "dustgate-clone-pod", "dustgate-clone-terminal", - "dustgate-cloning-facility", - "open-desert-shelter-house" + "dustgate-cloning-facility" ], "transitionIds": [], "cloneFacilityIds": [ "dustgate-cloning-facility" ], "spawnPointIds": [ + "camera-follow:player", "clone-respawn:dustgate-cloning-facility" ] }, @@ -41718,7 +41717,9 @@ }, "cellCount": 256, "collisionCellCount": 0, - "propIds": [], + "propIds": [ + "dustgate-home-court" + ], "transitionIds": [], "cloneFacilityIds": [], "spawnPointIds": [] @@ -41738,7 +41739,9 @@ }, "cellCount": 256, "collisionCellCount": 0, - "propIds": [], + "propIds": [ + "dustgate-home-court" + ], "transitionIds": [], "cloneFacilityIds": [], "spawnPointIds": [ @@ -41761,13 +41764,13 @@ "cellCount": 256, "collisionCellCount": 1, "propIds": [ + "dustgate-home-starter", + "dustgate-home-wing", "travel-terminal-dustgate" ], "transitionIds": [], "cloneFacilityIds": [], - "spawnPointIds": [ - "camera-follow:player" - ] + "spawnPointIds": [] }, { "id": "open-desert-overworld:33,32", @@ -41784,7 +41787,9 @@ }, "cellCount": 256, "collisionCellCount": 0, - "propIds": [], + "propIds": [ + "dustgate-home-wing" + ], "transitionIds": [], "cloneFacilityIds": [], "spawnPointIds": [] @@ -43052,7 +43057,9 @@ }, "cellCount": 256, "collisionCellCount": 0, - "propIds": [], + "propIds": [ + "dustgate-home-wing" + ], "transitionIds": [], "cloneFacilityIds": [], "spawnPointIds": [] @@ -43072,7 +43079,9 @@ }, "cellCount": 256, "collisionCellCount": 0, - "propIds": [], + "propIds": [ + "dustgate-home-wing" + ], "transitionIds": [], "cloneFacilityIds": [], "spawnPointIds": [] @@ -83176,6 +83185,18 @@ } ] }, + { + "key": "515,500", + "areaId": "open-desert-overworld", + "x": 515, + "y": 500, + "sources": [ + { + "kind": "solid_prop", + "id": "dustgate-clone-pod" + } + ] + }, { "key": "503,501", "areaId": "open-desert-overworld", @@ -83224,18 +83245,6 @@ } ] }, - { - "key": "518,504", - "areaId": "open-desert-overworld", - "x": 518, - "y": 504, - "sources": [ - { - "kind": "solid_prop", - "id": "dustgate-clone-pod" - } - ] - }, { "key": "524,512", "areaId": "open-desert-overworld", @@ -84224,20 +84233,20 @@ "kind": "clone_pod", "assetKey": "clone_pod", "cell": { - "x": 518, - "y": 504 + "x": 515, + "y": 500 }, "size": { "w": 1, "h": 1 }, "center": { - "x": 518.5, - "y": 504.5 + "x": 515.5, + "y": 500.5 }, "foot": { - "x": 518.5, - "y": 505 + "x": 515.5, + "y": 501 }, "interactive": true, "solid": true, @@ -84300,9 +84309,9 @@ { "id": "dustgate-commerce-facility", "areaId": "open-desert-overworld", - "label": "Dustgate Commerce Facility", + "label": "Valley Market", "kind": "building", - "assetKey": "commerce_facility", + "assetKey": "valley_market", "cell": { "x": 500, "y": 498 @@ -84323,6 +84332,84 @@ "solid": false, "visible": true }, + { + "id": "dustgate-home-court", + "areaId": "open-desert-overworld", + "label": "Modular Court Home", + "kind": "building", + "assetKey": "home_modular_court", + "cell": { + "x": 493, + "y": 520 + }, + "size": { + "w": 10, + "h": 8 + }, + "center": { + "x": 498, + "y": 524 + }, + "foot": { + "x": 498, + "y": 528 + }, + "interactive": false, + "solid": false, + "visible": true + }, + { + "id": "dustgate-home-starter", + "areaId": "open-desert-overworld", + "label": "Modular Starter Home", + "kind": "building", + "assetKey": "home_modular_starter", + "cell": { + "x": 512, + "y": 512 + }, + "size": { + "w": 8, + "h": 6 + }, + "center": { + "x": 516, + "y": 515 + }, + "foot": { + "x": 516, + "y": 518 + }, + "interactive": false, + "solid": false, + "visible": true + }, + { + "id": "dustgate-home-wing", + "areaId": "open-desert-overworld", + "label": "Modular Wing Home", + "kind": "building", + "assetKey": "home_modular_wing", + "cell": { + "x": 519, + "y": 520 + }, + "size": { + "w": 12, + "h": 10 + }, + "center": { + "x": 525, + "y": 525 + }, + "foot": { + "x": 525, + "y": 530 + }, + "interactive": false, + "solid": false, + "visible": true + }, { "id": "dustgate-occupation-brick-stack", "areaId": "open-desert-overworld", @@ -87679,32 +87766,6 @@ "visible": true, "rotation": 180 }, - { - "id": "open-desert-shelter-house", - "areaId": "open-desert-overworld", - "label": "Shelter House", - "kind": "prop", - "assetKey": "house_shoulder", - "cell": { - "x": 509, - "y": 508 - }, - "size": { - "w": 5, - "h": 4 - }, - "center": { - "x": 511.5, - "y": 510 - }, - "foot": { - "x": 511.5, - "y": 512 - }, - "interactive": false, - "solid": false, - "visible": true - }, { "id": "open-desert-sparring-zone-campfire", "areaId": "open-desert-overworld", @@ -87935,8 +87996,8 @@ "actorId": "player", "label": "Field Observer", "cell": { - "x": 512, - "y": 513 + "x": 516, + "y": 511 }, "facing": "front" }, @@ -87972,8 +88033,8 @@ "areaId": "open-desert-overworld", "kind": "solid_prop", "cell": { - "x": 518, - "y": 504 + "x": 515, + "y": 500 }, "size": { "w": 1, @@ -88019,6 +88080,45 @@ "h": 9 } }, + { + "id": "dustgate-home-court", + "areaId": "open-desert-overworld", + "kind": "building", + "cell": { + "x": 493, + "y": 520 + }, + "size": { + "w": 10, + "h": 8 + } + }, + { + "id": "dustgate-home-starter", + "areaId": "open-desert-overworld", + "kind": "building", + "cell": { + "x": 512, + "y": 512 + }, + "size": { + "w": 8, + "h": 6 + } + }, + { + "id": "dustgate-home-wing", + "areaId": "open-desert-overworld", + "kind": "building", + "cell": { + "x": 519, + "y": 520 + }, + "size": { + "w": 12, + "h": 10 + } + }, { "id": "dustgate-pa-terminal", "areaId": "open-desert-overworld", @@ -171614,6 +171714,9 @@ "dustgate-clone-terminal", "dustgate-cloning-facility", "dustgate-commerce-facility", + "dustgate-home-court", + "dustgate-home-starter", + "dustgate-home-wing", "dustgate-occupation-brick-stack", "dustgate-occupation-bucket", "dustgate-occupation-burlap-sack", @@ -171739,7 +171842,6 @@ "open-desert-rogue-zone-018-footlocker", "open-desert-rogue-zone-018-grill", "open-desert-rogue-zone-018-shelter", - "open-desert-shelter-house", "open-desert-sparring-zone-campfire", "open-desert-sparring-zone-chair-a", "open-desert-sparring-zone-chair-b", @@ -179975,11 +180077,11 @@ "totalCells": 2097152, "chunkCount": 8192, "collisionCells": 168, - "propAnchors": 139, + "propAnchors": 141, "transitionTriggers": 0, "cloneFacilities": 1, "spawnPoints": 4, - "occluders": 123, + "occluders": 126, "lightingZones": 2, "audioZones": 2 } diff --git a/client/public/successor-slice/open-desert-slice.json b/client/public/successor-slice/open-desert-slice.json index 367fd2dd..4308a7cf 100644 --- a/client/public/successor-slice/open-desert-slice.json +++ b/client/public/successor-slice/open-desert-slice.json @@ -3657,8 +3657,8 @@ "poseSet": "walk", "direction": "front", "cell": { - "x": 512, - "y": 513 + "x": 516, + "y": 511 }, "route": [], "vitals": { @@ -3841,9 +3841,9 @@ "id": "dustgate-commerce-facility", "entity": "prop:open-desert-overworld:dustgate-commerce-facility", "areaId": "open-desert-overworld", - "label": "Dustgate Commerce Facility", + "label": "Valley Market", "kind": "building", - "assetKey": "commerce_facility", + "assetKey": "valley_market", "cell": { "x": 500, "y": 498 @@ -3857,191 +3857,2707 @@ "visible": true, "collisionBounds": [ { - "xMilli": 0, - "yMilli": 237, - "wMilli": 579, - "hMilli": 8368, - "id": "outer_shell_left" + "xMilli": 147, + "yMilli": 7237, + "wMilli": 358, + "hMilli": 547, + "id": "shell__wall_west_0" + }, + { + "xMilli": 147, + "yMilli": 342, + "wMilli": 358, + "hMilli": 2747, + "id": "shell__wall_west_1" + }, + { + "xMilli": 653, + "yMilli": 3089, + "wMilli": 168, + "hMilli": 4148, + "id": "shell__wall_west_back_0" + }, + { + "xMilli": 11495, + "yMilli": 7300, + "wMilli": 358, + "hMilli": 1474, + "id": "shell__wall_east_0" + }, + { + "xMilli": 11495, + "yMilli": 2142, + "wMilli": 358, + "hMilli": 3011, + "id": "shell__wall_east_1" + }, + { + "xMilli": 11495, + "yMilli": 342, + "wMilli": 358, + "hMilli": 305, + "id": "shell__wall_east_2" + }, + { + "xMilli": 11200, + "yMilli": 5153, + "wMilli": 168, + "hMilli": 2147, + "id": "shell__wall_east_back_0" + }, + { + "xMilli": 11495, + "yMilli": 647, + "wMilli": 105, + "hMilli": 1495, + "id": "shell__wall_east_back_1" + }, + { + "xMilli": 147, + "yMilli": 7237, + "wMilli": 674, + "hMilli": 358, + "id": "shell__cheek_w0_s" + }, + { + "xMilli": 147, + "yMilli": 2732, + "wMilli": 674, + "hMilli": 357, + "id": "shell__cheek_w0_n" + }, + { + "xMilli": 11200, + "yMilli": 7300, + "wMilli": 653, + "hMilli": 358, + "id": "shell__cheek_e0_s" + }, + { + "xMilli": 11200, + "yMilli": 4795, + "wMilli": 653, + "hMilli": 358, + "id": "shell__cheek_e0_n" + }, + { + "xMilli": 11495, + "yMilli": 2142, + "wMilli": 358, + "hMilli": 358, + "id": "shell__cheek_e1_s" + }, + { + "xMilli": 11495, + "yMilli": 289, + "wMilli": 358, + "hMilli": 358, + "id": "shell__cheek_e1_n" + }, + { + "xMilli": 147, + "yMilli": 5216, + "wMilli": 506, + "hMilli": 379, + "id": "shell__arc_pier" + }, + { + "xMilli": 11600, + "yMilli": 1100, + "wMilli": 253, + "hMilli": 305, + "id": "shell__plb_mullion" + }, + { + "xMilli": 147, + "yMilli": 342, + "wMilli": 6616, + "hMilli": 358, + "id": "shell__wall_north_w" + }, + { + "xMilli": 7974, + "yMilli": 342, + "wMilli": 3879, + "hMilli": 358, + "id": "shell__wall_north_e" + }, + { + "xMilli": 147, + "yMilli": 7426, + "wMilli": 4042, + "hMilli": 358, + "id": "shell__wall_south_w" + }, + { + "xMilli": 7179, + "yMilli": 7426, + "wMilli": 1684, + "hMilli": 358, + "id": "shell__wall_south_e" + }, + { + "xMilli": 8863, + "yMilli": 8416, + "wMilli": 2990, + "hMilli": 358, + "id": "shell__bay_south" + }, + { + "xMilli": 8863, + "yMilli": 7426, + "wMilli": 358, + "hMilli": 1348, + "id": "shell__bay_west" + }, + { + "xMilli": 3684, + "yMilli": 7784, + "wMilli": 505, + "hMilli": 1095, + "id": "shell__hood_pier_w" + }, + { + "xMilli": 7179, + "yMilli": 7784, + "wMilli": 505, + "hMilli": 1095, + "id": "shell__hood_pier_e" + }, + { + "xMilli": 179, + "yMilli": 8447, + "wMilli": 484, + "hMilli": 485, + "id": "shell__loggia_pier_0" + }, + { + "xMilli": 1737, + "yMilli": 8489, + "wMilli": 400, + "hMilli": 400, + "id": "shell__loggia_pier_1" + }, + { + "xMilli": 3074, + "yMilli": 8521, + "wMilli": 337, + "hMilli": 337, + "id": "shell__loggia_pier_2" + }, + { + "xMilli": 58, + "yMilli": 7426, + "wMilli": 447, + "hMilli": 448, + "id": "shell__lowcorner_corner_sw" + }, + { + "xMilli": 58, + "yMilli": 253, + "wMilli": 447, + "hMilli": 447, + "id": "shell__lowcorner_corner_nw" + }, + { + "xMilli": 11495, + "yMilli": 253, + "wMilli": 447, + "hMilli": 447, + "id": "shell__lowcorner_corner_ne" + }, + { + "xMilli": 11495, + "yMilli": 8416, + "wMilli": 447, + "hMilli": 447, + "id": "shell__lowcorner_corner_bay_se" + }, + { + "xMilli": 8774, + "yMilli": 8416, + "wMilli": 447, + "hMilli": 447, + "id": "shell__lowcorner_corner_bay_sw" + }, + { + "xMilli": 8774, + "yMilli": 7426, + "wMilli": 447, + "hMilli": 448, + "id": "shell__lowcorner_corner_bay_re" + }, + { + "xMilli": 42, + "yMilli": 2374, + "wMilli": 105, + "hMilli": 715, + "id": "shell__west_buttress" + }, + { + "xMilli": 505, + "yMilli": 2195, + "wMilli": 90, + "hMilli": 358, + "id": "shell__service_wall_0" + }, + { + "xMilli": 1595, + "yMilli": 2195, + "wMilli": 1200, + "hMilli": 358, + "id": "shell__service_wall_1" + }, + { + "xMilli": 4205, + "yMilli": 2195, + "wMilli": 1590, + "hMilli": 358, + "id": "shell__service_wall_2" + }, + { + "xMilli": 7205, + "yMilli": 2195, + "wMilli": 1590, + "hMilli": 358, + "id": "shell__service_wall_3" + }, + { + "xMilli": 10205, + "yMilli": 2195, + "wMilli": 1290, + "hMilli": 358, + "id": "shell__service_wall_4" + }, + { + "xMilli": 2616, + "yMilli": 2058, + "wMilli": 179, + "hMilli": 137, + "id": "shell__niche_bank_cheek_w" + }, + { + "xMilli": 4205, + "yMilli": 2058, + "wMilli": 179, + "hMilli": 137, + "id": "shell__niche_bank_cheek_e" + }, + { + "xMilli": 2795, + "yMilli": 1921, + "wMilli": 1410, + "hMilli": 137, + "id": "shell__niche_bank_back" + }, + { + "xMilli": 5616, + "yMilli": 2058, + "wMilli": 179, + "hMilli": 137, + "id": "shell__niche_trade_cheek_w" + }, + { + "xMilli": 7205, + "yMilli": 2058, + "wMilli": 179, + "hMilli": 137, + "id": "shell__niche_trade_cheek_e" + }, + { + "xMilli": 5795, + "yMilli": 1921, + "wMilli": 1410, + "hMilli": 137, + "id": "shell__niche_trade_back" + }, + { + "xMilli": 8616, + "yMilli": 2058, + "wMilli": 179, + "hMilli": 137, + "id": "shell__niche_assoc_cheek_w" + }, + { + "xMilli": 10205, + "yMilli": 2058, + "wMilli": 179, + "hMilli": 137, + "id": "shell__niche_assoc_cheek_e" + }, + { + "xMilli": 8795, + "yMilli": 1921, + "wMilli": 1410, + "hMilli": 137, + "id": "shell__niche_assoc_back" + }, + { + "xMilli": 2642, + "yMilli": 2553, + "wMilli": 126, + "hMilli": 368, + "id": "fixture__dock_bank_fin_w" + }, + { + "xMilli": 4232, + "yMilli": 2553, + "wMilli": 126, + "hMilli": 368, + "id": "fixture__dock_bank_fin_e" + }, + { + "xMilli": 2342, + "yMilli": 2553, + "wMilli": 421, + "hMilli": 263, + "id": "fixture__dock_bank_drawer" + }, + { + "xMilli": 4205, + "yMilli": 2553, + "wMilli": 484, + "hMilli": 358, + "id": "fixture__dock_bank_ledge" + }, + { + "xMilli": 5511, + "yMilli": 2753, + "wMilli": 63, + "hMilli": 63, + "id": "fixture__dock_trade_leg_w" + }, + { + "xMilli": 7426, + "yMilli": 2753, + "wMilli": 63, + "hMilli": 63, + "id": "fixture__dock_trade_leg_e" + }, + { + "xMilli": 7289, + "yMilli": 2553, + "wMilli": 422, + "hMilli": 315, + "id": "fixture__dock_trade_shelf" + }, + { + "xMilli": 8247, + "yMilli": 2553, + "wMilli": 611, + "hMilli": 94, + "id": "fixture__dock_assoc_notice" + }, + { + "xMilli": 10163, + "yMilli": 2553, + "wMilli": 569, + "hMilli": 421, + "id": "fixture__dock_assoc_bench" + }, + { + "xMilli": 821, + "yMilli": 3321, + "wMilli": 779, + "hMilli": 3495, + "id": "fixture__vendor_counter" + }, + { + "xMilli": 253, + "yMilli": 3247, + "wMilli": 400, + "hMilli": 1390, + "id": "fixture__goods_counter" + }, + { + "xMilli": 253, + "yMilli": 5658, + "wMilli": 400, + "hMilli": 1305, + "id": "fixture__stock_ledge" + }, + { + "xMilli": 11368, + "yMilli": 6005, + "wMilli": 474, + "hMilli": 906, + "id": "fixture__water_basin" + }, + { + "xMilli": 11600, + "yMilli": 742, + "wMilli": 242, + "hMilli": 358, + "id": "fixture__isolator_cabinet" + }, + { + "xMilli": 9684, + "yMilli": 7132, + "wMilli": 1474, + "hMilli": 442, + "id": "fixture__trainer_table" + }, + { + "xMilli": 9263, + "yMilli": 8016, + "wMilli": 800, + "hMilli": 337, + "id": "fixture__trainer_credenza" + }, + { + "xMilli": 10432, + "yMilli": 1795, + "wMilli": 1094, + "hMilli": 389, + "id": "fixture__boh_bench" + }, + { + "xMilli": 1642, + "yMilli": 1795, + "wMilli": 926, + "hMilli": 389, + "id": "fixture__boh_shelving" + }, + { + "xMilli": 11211, + "yMilli": 763, + "wMilli": 284, + "hMilli": 842, + "id": "fixture__boh_switchgear" + }, + { + "xMilli": 4532, + "yMilli": 1789, + "wMilli": 431, + "hMilli": 432, + "id": "fixture__boh_filter_0" + }, + { + "xMilli": 5037, + "yMilli": 1789, + "wMilli": 431, + "hMilli": 432, + "id": "fixture__boh_filter_1" + }, + { + "xMilli": 5684, + "yMilli": 1795, + "wMilli": 358, + "hMilli": 126, + "id": "fixture__boh_valve_panel" + }, + { + "xMilli": 3316, + "yMilli": 700, + "wMilli": 526, + "hMilli": 116, + "id": "fixture__boh_ladder" + }, + { + "xMilli": 10937, + "yMilli": 3237, + "wMilli": 558, + "hMilli": 1347, + "id": "fixture__nook_bench" + }, + { + "xMilli": 4326, + "yMilli": 6089, + "wMilli": 3453, + "hMilli": 85, + "id": "fixture__queue_rail" + }, + { + "xMilli": 474, + "yMilli": 8005, + "wMilli": 2421, + "hMilli": 653, + "id": "fixture__loggia_ledge" + }, + { + "xMilli": 2053, + "yMilli": 7995, + "wMilli": 1158, + "hMilli": 463, + "id": "fixture__loggia_bench" + } + ], + "interiorRegions": [ + { + "id": "hall", + "xMilli": 505, + "yMilli": 2553, + "wMilli": 10990, + "hMilli": 4873, + "floorTopM": 0.021052631578947368 + }, + { + "id": "back_of_house", + "xMilli": 505, + "yMilli": 700, + "wMilli": 10990, + "hMilli": 1358, + "floorTopM": 0.021052631578947368 + }, + { + "id": "trainer_bay", + "xMilli": 9221, + "yMilli": 7426, + "wMilli": 2274, + "hMilli": 990, + "floorTopM": 0.021052631578947368 + } + ], + "door": { + "blocker": { + "xMilli": 4400, + "yMilli": 7300, + "wMilli": 2568, + "hMilli": 95, + "id": "closed_door_panel" + }, + "interactRadiusCells": 3.1 + }, + "shelter": true, + "enterable": { + "floorHeightM": 0.021052631578947368, + "interiorBounds": [ + { + "xMilli": 505, + "yMilli": 2553, + "wMilli": 10990, + "hMilli": 4873 + }, + { + "xMilli": 505, + "yMilli": 700, + "wMilli": 10990, + "hMilli": 1358 + }, + { + "xMilli": 9221, + "yMilli": 7426, + "wMilli": 2274, + "hMilli": 990 + } + ] + } + }, + { + "id": "dustgate-home-starter", + "entity": "prop:open-desert-overworld:dustgate-home-starter", + "areaId": "open-desert-overworld", + "label": "Modular Starter Home", + "kind": "building", + "assetKey": "home_modular_starter", + "cell": { + "x": 512, + "y": 512 + }, + "size": { + "w": 8, + "h": 6 + }, + "interactive": false, + "solid": false, + "visible": true, + "collisionBounds": [ + { + "xMilli": 0, + "yMilli": 0, + "wMilli": 1000, + "hMilli": 1000, + "id": "wall_096_wall_cham_1c__sintered" + }, + { + "xMilli": 0, + "yMilli": 5000, + "wMilli": 1000, + "hMilli": 1000, + "id": "wall_097_wall_cham_1c__sintered" + }, + { + "xMilli": 7000, + "yMilli": 0, + "wMilli": 1000, + "hMilli": 1000, + "id": "wall_098_wall_cham_1c__sintered" + }, + { + "xMilli": 7000, + "yMilli": 5000, + "wMilli": 1000, + "hMilli": 1000, + "id": "wall_099_wall_cham_1c__sintered" + }, + { + "xMilli": 5000, + "yMilli": 0, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_100_wall_1c__sintered" + }, + { + "xMilli": 6000, + "yMilli": 0, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_101_window_slot_1c__sintered" + }, + { + "xMilli": 1000, + "yMilli": 0, + "wMilli": 2000, + "hMilli": 84, + "id": "wall_102_window_tall_2c__sintered" + }, + { + "xMilli": 3000, + "yMilli": 0, + "wMilli": 943, + "hMilli": 84, + "id": "wall_103_door_slide_2c__sintered__jamb_lo" + }, + { + "xMilli": 4891, + "yMilli": 0, + "wMilli": 109, + "hMilli": 84, + "id": "wall_103_door_slide_2c__sintered__jamb_hi" + }, + { + "xMilli": 1000, + "yMilli": 5916, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_106_wall_1c__sintered" + }, + { + "xMilli": 4000, + "yMilli": 5916, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_107_wall_1c__sintered" + }, + { + "xMilli": 5000, + "yMilli": 5916, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_108_window_slot_1c__sintered" + }, + { + "xMilli": 6000, + "yMilli": 5916, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_109_wall_1c__sintered" + }, + { + "xMilli": 2000, + "yMilli": 5916, + "wMilli": 2000, + "hMilli": 84, + "id": "wall_110_window_tall_2c__sintered" + }, + { + "xMilli": 0, + "yMilli": 1000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_111_window_slot_1c__sintered" + }, + { + "xMilli": 0, + "yMilli": 2000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_112_window_cler_1c__sintered" + }, + { + "xMilli": 0, + "yMilli": 3000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_113_wall_1c__sintered" + }, + { + "xMilli": 0, + "yMilli": 4000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_114_window_slot_1c__sintered" + }, + { + "xMilli": 7916, + "yMilli": 1000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_115_window_cler_1c__sintered" + }, + { + "xMilli": 7916, + "yMilli": 2000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_116_wall_1c__sintered" + }, + { + "xMilli": 7916, + "yMilli": 3000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_117_wall_1c__sintered" + }, + { + "xMilli": 7916, + "yMilli": 4000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_118_window_slot_1c__sintered" + }, + { + "xMilli": 0, + "yMilli": 2916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_119_wall_1c__plasterlined" + }, + { + "xMilli": 3000, + "yMilli": 2916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_120_wall_1c__plasterlined" + }, + { + "xMilli": 4000, + "yMilli": 2916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_121_wall_1c__plasterlined" + }, + { + "xMilli": 7000, + "yMilli": 2916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_122_wall_1c__plasterlined" + }, + { + "xMilli": 1000, + "yMilli": 2916, + "wMilli": 152, + "hMilli": 168, + "id": "wall_123_door_inner_2c__plasterlined__jamb_lo" + }, + { + "xMilli": 2057, + "yMilli": 2916, + "wMilli": 943, + "hMilli": 168, + "id": "wall_123_door_inner_2c__plasterlined__jamb_hi" + }, + { + "xMilli": 5000, + "yMilli": 2916, + "wMilli": 152, + "hMilli": 168, + "id": "wall_124_door_inner_2c__plasterlined__jamb_lo" + }, + { + "xMilli": 6057, + "yMilli": 2916, + "wMilli": 943, + "hMilli": 168, + "id": "wall_124_door_inner_2c__plasterlined__jamb_hi" + }, + { + "xMilli": 0, + "yMilli": 2844, + "wMilli": 156, + "hMilli": 312, + "id": "wall_125_wall_tee__sintered" + }, + { + "xMilli": 7844, + "yMilli": 2844, + "wMilli": 156, + "hMilli": 312, + "id": "wall_126_wall_tee__sintered" + }, + { + "xMilli": 3916, + "yMilli": 3000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_127_wall_1c__plasterlined" + }, + { + "xMilli": 3916, + "yMilli": 4000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_128_wall_1c__plasterlined" + }, + { + "xMilli": 3916, + "yMilli": 5000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_129_wall_1c__plasterlined" + }, + { + "xMilli": 3844, + "yMilli": 2844, + "wMilli": 312, + "hMilli": 312, + "id": "wall_130_wall_tee__plasterlined" + }, + { + "xMilli": 3844, + "yMilli": 5844, + "wMilli": 312, + "hMilli": 156, + "id": "wall_131_wall_tee__sintered" + }, + { + "xMilli": 5916, + "yMilli": 0, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_132_wall_1c__panelclad" + }, + { + "xMilli": 5916, + "yMilli": 1000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_133_wall_1c__panelclad" + }, + { + "xMilli": 5844, + "yMilli": 0, + "wMilli": 312, + "hMilli": 156, + "id": "wall_134_wall_tee__sintered" + }, + { + "xMilli": 1089, + "yMilli": 1153, + "wMilli": 822, + "hMilli": 694, + "id": "furniture_135_furn_table_1c__panelclad" + }, + { + "xMilli": 2326, + "yMilli": 1326, + "wMilli": 348, + "hMilli": 348, + "id": "furniture_136_furn_stool__panelclad" + }, + { + "xMilli": 1274, + "yMilli": 2237, + "wMilli": 452, + "hMilli": 526, + "id": "furniture_137_furn_chair_1c__panelclad" + }, + { + "xMilli": 1258, + "yMilli": 1258, + "wMilli": 484, + "hMilli": 484, + "id": "furniture_138_furn_pendant_1c__panelclad" + }, + { + "xMilli": 2326, + "yMilli": 326, + "wMilli": 348, + "hMilli": 348, + "id": "furniture_139_furn_stool__panelclad" + }, + { + "xMilli": 3547, + "yMilli": 2684, + "wMilli": 1906, + "hMilli": 632, + "id": "furniture_140_furn_counter_2c__panelclad" + }, + { + "xMilli": 0, + "yMilli": 1026, + "wMilli": 316, + "hMilli": 948, + "id": "furniture_141_furn_sink_1c__sintered" + }, + { + "xMilli": 0, + "yMilli": 2026, + "wMilli": 316, + "hMilli": 948, + "id": "furniture_142_furn_stove_1c__sintered" + }, + { + "xMilli": 5398, + "yMilli": 0, + "wMilli": 204, + "hMilli": 251, + "id": "furniture_143_furn_lamp_wall__panelclad" + }, + { + "xMilli": 398, + "yMilli": 2749, + "wMilli": 204, + "hMilli": 251, + "id": "furniture_144_furn_lamp_wall__panelclad" + }, + { + "xMilli": 7663, + "yMilli": 1595, + "wMilli": 337, + "hMilli": 1810, + "id": "furniture_145_furn_desk_2c__panelclad" + }, + { + "xMilli": 6237, + "yMilli": 2274, + "wMilli": 526, + "hMilli": 452, + "id": "furniture_146_furn_chair_1c__panelclad" + }, + { + "xMilli": 6163, + "yMilli": 0, + "wMilli": 674, + "hMilli": 295, + "id": "furniture_147_furn_crate_stack__panelclad" + }, + { + "xMilli": 7398, + "yMilli": 2749, + "wMilli": 204, + "hMilli": 251, + "id": "furniture_148_furn_lamp_wall__panelclad" + }, + { + "xMilli": 1553, + "yMilli": 5537, + "wMilli": 1894, + "hMilli": 463, + "id": "furniture_149_furn_bed_2c__panelclad" + }, + { + "xMilli": 0, + "yMilli": 3305, + "wMilli": 189, + "hMilli": 390, + "id": "furniture_150_furn_nightstand__panelclad" + }, + { + "xMilli": 0, + "yMilli": 4079, + "wMilli": 221, + "hMilli": 842, + "id": "furniture_151_furn_chest_1c__panelclad" + }, + { + "xMilli": 1398, + "yMilli": 5749, + "wMilli": 204, + "hMilli": 251, + "id": "furniture_152_furn_lamp_wall__panelclad" + }, + { + "xMilli": 4000, + "yMilli": 3584, + "wMilli": 842, + "hMilli": 1832, + "id": "furniture_153_xs_san_soaktub_2c__panelclad" + }, + { + "xMilli": 6142, + "yMilli": 5516, + "wMilli": 716, + "hMilli": 484, + "id": "furniture_154_xs_san_basin_1c__panelclad" + }, + { + "xMilli": 6037, + "yMilli": 5764, + "wMilli": 926, + "hMilli": 137, + "id": "furniture_155_xs_san_mirrorcab_1c__panelclad" + }, + { + "xMilli": 4268, + "yMilli": 3000, + "wMilli": 464, + "hMilli": 537, + "id": "furniture_156_xs_san_reclaimer_1c__panelclad" + }, + { + "xMilli": 4105, + "yMilli": 5806, + "wMilli": 790, + "hMilli": 110, + "id": "furniture_157_xs_san_towelbar_1c__panelclad" + }, + { + "xMilli": 6184, + "yMilli": 4353, + "wMilli": 632, + "hMilli": 294, + "id": "furniture_158_xs_san_drain_1c__panelclad" + }, + { + "xMilli": 7779, + "yMilli": 4079, + "wMilli": 221, + "hMilli": 842, + "id": "furniture_159_furn_chest_1c__panelclad" + }, + { + "xMilli": 7749, + "yMilli": 3398, + "wMilli": 251, + "hMilli": 204, + "id": "furniture_160_furn_lamp_wall__panelclad" + }, + { + "xMilli": 181, + "yMilli": 181, + "wMilli": 695, + "hMilli": 695, + "id": "furniture_161_furn_corner_hearth__panelclad" + }, + { + "xMilli": 7124, + "yMilli": 313, + "wMilli": 563, + "hMilli": 563, + "id": "furniture_162_furn_corner_shelf__panelclad" + }, + { + "xMilli": 313, + "yMilli": 5124, + "wMilli": 563, + "hMilli": 563, + "id": "furniture_163_furn_corner_bench__panelclad" + }, + { + "xMilli": 7124, + "yMilli": 5124, + "wMilli": 563, + "hMilli": 563, + "id": "furniture_164_furn_corner_store__panelclad" + } + ], + "interiorRegions": [ + { + "id": "living", + "xMilli": 0, + "yMilli": 0, + "wMilli": 6000, + "hMilli": 3000, + "floorTopM": 0.021052631578947368 + }, + { + "id": "study", + "xMilli": 6000, + "yMilli": 0, + "wMilli": 2000, + "hMilli": 3000, + "floorTopM": 0.021052631578947368 + }, + { + "id": "bedroom", + "xMilli": 0, + "yMilli": 3000, + "wMilli": 4000, + "hMilli": 3000, + "floorTopM": 0.021052631578947368 + }, + { + "id": "bath", + "xMilli": 4000, + "yMilli": 3000, + "wMilli": 4000, + "hMilli": 3000, + "floorTopM": 0.021052631578947368 + } + ], + "door": { + "blocker": { + "xMilli": 3943, + "yMilli": 0, + "wMilli": 948, + "hMilli": 26, + "id": "closed_exterior_entry" + }, + "interactRadiusCells": 3.1 + }, + "shelter": true, + "enterable": { + "floorHeightM": 0.021052631578947368, + "interiorBounds": [ + { + "xMilli": 0, + "yMilli": 0, + "wMilli": 6000, + "hMilli": 3000 + }, + { + "xMilli": 6000, + "yMilli": 0, + "wMilli": 2000, + "hMilli": 3000 + }, + { + "xMilli": 0, + "yMilli": 3000, + "wMilli": 4000, + "hMilli": 3000 + }, + { + "xMilli": 4000, + "yMilli": 3000, + "wMilli": 4000, + "hMilli": 3000 + } + ] + } + }, + { + "id": "dustgate-home-court", + "entity": "prop:open-desert-overworld:dustgate-home-court", + "areaId": "open-desert-overworld", + "label": "Modular Court Home", + "kind": "building", + "assetKey": "home_modular_court", + "cell": { + "x": 493, + "y": 520 + }, + "size": { + "w": 10, + "h": 8 + }, + "interactive": false, + "solid": false, + "visible": true, + "collisionBounds": [ + { + "xMilli": 0, + "yMilli": 0, + "wMilli": 1000, + "hMilli": 1000, + "id": "wall_112_wall_cham_1c__sintered" + }, + { + "xMilli": 0, + "yMilli": 7000, + "wMilli": 1000, + "hMilli": 1000, + "id": "wall_113_wall_cham_1c__sintered" + }, + { + "xMilli": 3000, + "yMilli": 0, + "wMilli": 1084, + "hMilli": 1000, + "id": "wall_114_wall_cham_1c__sintered" + }, + { + "xMilli": 9000, + "yMilli": 3916, + "wMilli": 1000, + "hMilli": 1084, + "id": "wall_115_wall_cham_1c__sintered" + }, + { + "xMilli": 9000, + "yMilli": 7000, + "wMilli": 1000, + "hMilli": 1000, + "id": "wall_116_wall_cham_1c__sintered" + }, + { + "xMilli": 1000, + "yMilli": 0, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_117_window_slot_1c__sintered" + }, + { + "xMilli": 2000, + "yMilli": 0, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_118_wall_1c__sintered" + }, + { + "xMilli": 0, + "yMilli": 1000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_119_window_slot_1c__sintered" + }, + { + "xMilli": 0, + "yMilli": 2000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_120_wall_1c__sintered" + }, + { + "xMilli": 0, + "yMilli": 3000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_121_window_cler_1c__sintered" + }, + { + "xMilli": 0, + "yMilli": 4000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_122_wall_1c__sintered" + }, + { + "xMilli": 0, + "yMilli": 5000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_123_window_slot_1c__sintered" + }, + { + "xMilli": 0, + "yMilli": 6000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_124_window_cler_1c__sintered" + }, + { + "xMilli": 1000, + "yMilli": 7916, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_125_wall_1c__sintered" + }, + { + "xMilli": 4000, + "yMilli": 7916, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_126_wall_1c__sintered" + }, + { + "xMilli": 5000, + "yMilli": 7916, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_127_wall_1c__sintered" + }, + { + "xMilli": 6000, + "yMilli": 7916, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_128_wall_1c__sintered" + }, + { + "xMilli": 7000, + "yMilli": 7916, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_129_wall_1c__sintered" + }, + { + "xMilli": 8000, + "yMilli": 7916, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_130_window_slot_1c__sintered" + }, + { + "xMilli": 2000, + "yMilli": 7916, + "wMilli": 2000, + "hMilli": 84, + "id": "wall_131_window_tall_2c__sintered" + }, + { + "xMilli": 9916, + "yMilli": 5000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_132_window_cler_1c__sintered" + }, + { + "xMilli": 9916, + "yMilli": 6000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_133_wall_1c__sintered" + }, + { + "xMilli": 4000, + "yMilli": 3916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_134_wall_1c__sintered" + }, + { + "xMilli": 7000, + "yMilli": 3916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_135_wall_1c__sintered" + }, + { + "xMilli": 8000, + "yMilli": 3916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_136_window_cler_1c__sintered" + }, + { + "xMilli": 5000, + "yMilli": 3916, + "wMilli": 943, + "hMilli": 168, + "id": "wall_137_door_slide_2c__sintered__jamb_lo" + }, + { + "xMilli": 6891, + "yMilli": 3916, + "wMilli": 109, + "hMilli": 168, + "id": "wall_137_door_slide_2c__sintered__jamb_hi" + }, + { + "xMilli": 3916, + "yMilli": 1000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_138_wall_1c__sintered" + }, + { + "xMilli": 3916, + "yMilli": 2000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_139_window_slot_1c__sintered" + }, + { + "xMilli": 3916, + "yMilli": 3000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_140_wall_1c__sintered" + }, + { + "xMilli": 3844, + "yMilli": 3844, + "wMilli": 312, + "hMilli": 312, + "id": "wall_142_wall_tee__sintered" + }, + { + "xMilli": 0, + "yMilli": 3916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_143_wall_1c__plasterlined" + }, + { + "xMilli": 3000, + "yMilli": 3916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_144_wall_1c__plasterlined" + }, + { + "xMilli": 1000, + "yMilli": 3916, + "wMilli": 152, + "hMilli": 168, + "id": "wall_145_door_inner_2c__plasterlined__jamb_lo" + }, + { + "xMilli": 2057, + "yMilli": 3916, + "wMilli": 943, + "hMilli": 168, + "id": "wall_145_door_inner_2c__plasterlined__jamb_hi" + }, + { + "xMilli": 0, + "yMilli": 3844, + "wMilli": 156, + "hMilli": 312, + "id": "wall_146_wall_tee__sintered" + }, + { + "xMilli": 0, + "yMilli": 1916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_147_wall_1c__plasterlined" + }, + { + "xMilli": 3000, + "yMilli": 1916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_148_wall_1c__plasterlined" + }, + { + "xMilli": 1000, + "yMilli": 1916, + "wMilli": 152, + "hMilli": 168, + "id": "wall_149_door_inner_2c__plasterlined__jamb_lo" + }, + { + "xMilli": 2057, + "yMilli": 1916, + "wMilli": 943, + "hMilli": 168, + "id": "wall_149_door_inner_2c__plasterlined__jamb_hi" + }, + { + "xMilli": 0, + "yMilli": 1844, + "wMilli": 156, + "hMilli": 312, + "id": "wall_150_wall_tee__sintered" + }, + { + "xMilli": 3844, + "yMilli": 1844, + "wMilli": 312, + "hMilli": 312, + "id": "wall_151_wall_tee__sintered" + }, + { + "xMilli": 7916, + "yMilli": 4000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_152_wall_1c__panelclad" + }, + { + "xMilli": 7916, + "yMilli": 7000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_153_wall_1c__panelclad" + }, + { + "xMilli": 7916, + "yMilli": 5000, + "wMilli": 168, + "hMilli": 943, + "id": "wall_154_door_inner_2c__panelclad__jamb_lo" + }, + { + "xMilli": 7916, + "yMilli": 6848, + "wMilli": 168, + "hMilli": 152, + "id": "wall_154_door_inner_2c__panelclad__jamb_hi" + }, + { + "xMilli": 7844, + "yMilli": 3844, + "wMilli": 312, + "hMilli": 312, + "id": "wall_155_wall_tee__sintered" + }, + { + "xMilli": 7844, + "yMilli": 7844, + "wMilli": 312, + "hMilli": 156, + "id": "wall_156_wall_tee__sintered" + }, + { + "xMilli": 4000, + "yMilli": 0, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_157_wall_half_1c__sintered" + }, + { + "xMilli": 5000, + "yMilli": 0, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_158_wall_half_1c__sintered" + }, + { + "xMilli": 8000, + "yMilli": 0, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_159_wall_half_1c__sintered" + }, + { + "xMilli": 9000, + "yMilli": 0, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_160_wall_half_1c__sintered" + }, + { + "xMilli": 9916, + "yMilli": 0, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_161_wall_half_1c__sintered" + }, + { + "xMilli": 9916, + "yMilli": 1000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_162_wall_half_1c__sintered" + }, + { + "xMilli": 9916, + "yMilli": 2000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_163_wall_half_1c__sintered" + }, + { + "xMilli": 9916, + "yMilli": 3000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_164_wall_half_1c__sintered" + }, + { + "xMilli": 5816, + "yMilli": 0, + "wMilli": 368, + "hMilli": 184, + "id": "wall_165_column__sintered" + }, + { + "xMilli": 7816, + "yMilli": 0, + "wMilli": 368, + "hMilli": 184, + "id": "wall_166_column__sintered" + }, + { + "xMilli": 9816, + "yMilli": 0, + "wMilli": 184, + "hMilli": 184, + "id": "wall_167_column__sintered" + }, + { + "xMilli": 181, + "yMilli": 7124, + "wMilli": 695, + "hMilli": 695, + "id": "furniture_168_furn_corner_hearth__panelclad" + }, + { + "xMilli": 2089, + "yMilli": 6153, + "wMilli": 822, + "hMilli": 694, + "id": "furniture_169_furn_table_1c__panelclad" + }, + { + "xMilli": 1237, + "yMilli": 6274, + "wMilli": 526, + "hMilli": 452, + "id": "furniture_170_furn_chair_1c__panelclad" + }, + { + "xMilli": 3237, + "yMilli": 6274, + "wMilli": 526, + "hMilli": 452, + "id": "furniture_171_furn_chair_1c__panelclad" + }, + { + "xMilli": 2258, + "yMilli": 6258, + "wMilli": 484, + "hMilli": 484, + "id": "furniture_172_furn_pendant_1c__panelclad" + }, + { + "xMilli": 2326, + "yMilli": 5326, + "wMilli": 348, + "hMilli": 348, + "id": "furniture_173_furn_stool__panelclad" + }, + { + "xMilli": 3553, + "yMilli": 7768, + "wMilli": 1894, + "hMilli": 232, + "id": "furniture_174_furn_bench_2c__panelclad" + }, + { + "xMilli": 5574, + "yMilli": 7747, + "wMilli": 1852, + "hMilli": 253, + "id": "furniture_175_furn_sideboard_2c__panelclad" + }, + { + "xMilli": 0, + "yMilli": 4079, + "wMilli": 147, + "hMilli": 842, + "id": "furniture_176_furn_shelf_1c__panelclad" + }, + { + "xMilli": 5398, + "yMilli": 7749, + "wMilli": 204, + "hMilli": 251, + "id": "furniture_177_furn_lamp_wall__panelclad" + }, + { + "xMilli": 6167, + "yMilli": 6254, + "wMilli": 666, + "hMilli": 492, + "id": "furniture_178_furn_planter_1c__panelclad" + }, + { + "xMilli": 9124, + "yMilli": 7124, + "wMilli": 563, + "hMilli": 563, + "id": "furniture_179_furn_corner_store__panelclad" + }, + { + "xMilli": 9124, + "yMilli": 4313, + "wMilli": 563, + "hMilli": 563, + "id": "furniture_180_furn_corner_shelf__panelclad" + }, + { + "xMilli": 9684, + "yMilli": 5026, + "wMilli": 316, + "hMilli": 948, + "id": "furniture_181_furn_stove_1c__panelclad" + }, + { + "xMilli": 9684, + "yMilli": 6026, + "wMilli": 316, + "hMilli": 948, + "id": "furniture_182_furn_sink_1c__panelclad" + }, + { + "xMilli": 7832, + "yMilli": 7026, + "wMilli": 336, + "hMilli": 948, + "id": "furniture_183_furn_wall_cab_1c__panelclad" + }, + { + "xMilli": 8000, + "yMilli": 4398, + "wMilli": 251, + "hMilli": 204, + "id": "furniture_184_furn_lamp_wall__panelclad" + }, + { + "xMilli": 9258, + "yMilli": 6258, + "wMilli": 484, + "hMilli": 484, + "id": "furniture_185_furn_pendant_1c__panelclad" + }, + { + "xMilli": 0, + "yMilli": 2553, + "wMilli": 463, + "hMilli": 1894, + "id": "furniture_186_furn_bed_2c__panelclad" + }, + { + "xMilli": 305, + "yMilli": 3811, + "wMilli": 390, + "hMilli": 378, + "id": "furniture_187_furn_nightstand__panelclad" + }, + { + "xMilli": 3853, + "yMilli": 3079, + "wMilli": 294, + "hMilli": 842, + "id": "furniture_188_furn_shelf_1c__panelclad" + }, + { + "xMilli": 79, + "yMilli": 1779, + "wMilli": 842, + "hMilli": 442, + "id": "furniture_189_furn_chest_1c__panelclad" + }, + { + "xMilli": 3398, + "yMilli": 2000, + "wMilli": 204, + "hMilli": 251, + "id": "furniture_190_furn_lamp_wall__panelclad" + }, + { + "xMilli": 3124, + "yMilli": 313, + "wMilli": 563, + "hMilli": 563, + "id": "furniture_191_furn_corner_store__panelclad" + }, + { + "xMilli": 1163, + "yMilli": 0, + "wMilli": 674, + "hMilli": 295, + "id": "furniture_192_furn_crate_stack__panelclad" + }, + { + "xMilli": 142, + "yMilli": 1516, + "wMilli": 716, + "hMilli": 484, + "id": "furniture_193_xs_san_basin_1c__panelclad" + }, + { + "xMilli": 37, + "yMilli": 1764, + "wMilli": 926, + "hMilli": 137, + "id": "furniture_194_xs_san_mirrorcab_1c__panelclad" + }, + { + "xMilli": 268, + "yMilli": 0, + "wMilli": 464, + "hMilli": 537, + "id": "furniture_195_xs_san_reclaimer_1c__panelclad" + }, + { + "xMilli": 1184, + "yMilli": 1353, + "wMilli": 632, + "hMilli": 294, + "id": "furniture_196_xs_san_drain_1c__panelclad" + }, + { + "xMilli": 2398, + "yMilli": 0, + "wMilli": 204, + "hMilli": 251, + "id": "furniture_197_furn_lamp_wall__panelclad" + }, + { + "xMilli": 4398, + "yMilli": 3749, + "wMilli": 204, + "hMilli": 251, + "id": "furniture_198_furn_lamp_wall__sintered" + }, + { + "xMilli": 7398, + "yMilli": 3749, + "wMilli": 204, + "hMilli": 251, + "id": "furniture_199_furn_lamp_wall__sintered" + }, + { + "xMilli": 4000, + "yMilli": 1398, + "wMilli": 251, + "hMilli": 204, + "id": "furniture_200_furn_lamp_wall__sintered" + } + ], + "interiorRegions": [ + { + "id": "living", + "xMilli": 0, + "yMilli": 4000, + "wMilli": 8000, + "hMilli": 4000, + "floorTopM": 0.021052631578947368 + }, + { + "id": "kitchen", + "xMilli": 8000, + "yMilli": 4000, + "wMilli": 2000, + "hMilli": 4000, + "floorTopM": 0.021052631578947368 + }, + { + "id": "bed", + "xMilli": 0, + "yMilli": 2000, + "wMilli": 4000, + "hMilli": 2000, + "floorTopM": 0.021052631578947368 + }, + { + "id": "bath", + "xMilli": 0, + "yMilli": 0, + "wMilli": 4000, + "hMilli": 2000, + "floorTopM": 0.021052631578947368 + } + ], + "door": { + "blocker": { + "xMilli": 5943, + "yMilli": 3973, + "wMilli": 948, + "hMilli": 52, + "id": "closed_exterior_entry" + }, + "interactRadiusCells": 3.1 + }, + "shelter": true, + "enterable": { + "floorHeightM": 0.021052631578947368, + "interiorBounds": [ + { + "xMilli": 0, + "yMilli": 4000, + "wMilli": 8000, + "hMilli": 4000 + }, + { + "xMilli": 8000, + "yMilli": 4000, + "wMilli": 2000, + "hMilli": 4000 + }, + { + "xMilli": 0, + "yMilli": 2000, + "wMilli": 4000, + "hMilli": 2000 + }, + { + "xMilli": 0, + "yMilli": 0, + "wMilli": 4000, + "hMilli": 2000 + } + ] + } + }, + { + "id": "dustgate-home-wing", + "entity": "prop:open-desert-overworld:dustgate-home-wing", + "areaId": "open-desert-overworld", + "label": "Modular Wing Home", + "kind": "building", + "assetKey": "home_modular_wing", + "cell": { + "x": 519, + "y": 520 + }, + "size": { + "w": 12, + "h": 10 + }, + "interactive": false, + "solid": false, + "visible": true, + "collisionBounds": [ + { + "xMilli": 0, + "yMilli": 6916, + "wMilli": 1000, + "hMilli": 1084, + "id": "wall_144_wall_cham_1c__sintered" + }, + { + "xMilli": 0, + "yMilli": 9000, + "wMilli": 1000, + "hMilli": 1000, + "id": "wall_145_wall_cham_1c__sintered" + }, + { + "xMilli": 2916, + "yMilli": 0, + "wMilli": 1084, + "hMilli": 1000, + "id": "wall_146_wall_cham_1c__sintered" + }, + { + "xMilli": 6000, + "yMilli": 9000, + "wMilli": 1084, + "hMilli": 1000, + "id": "wall_147_wall_cham_1c__sintered" + }, + { + "xMilli": 7000, + "yMilli": 0, + "wMilli": 1084, + "hMilli": 1000, + "id": "wall_148_wall_cham_1c__sintered" + }, + { + "xMilli": 11000, + "yMilli": 2916, + "wMilli": 1000, + "hMilli": 1084, + "id": "wall_149_wall_cham_1c__sintered" + }, + { + "xMilli": 11000, + "yMilli": 6000, + "wMilli": 1000, + "hMilli": 1084, + "id": "wall_150_wall_cham_1c__sintered" + }, + { + "xMilli": 2916, + "yMilli": 1000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_151_window_slot_1c__sintered" + }, + { + "xMilli": 2916, + "yMilli": 2000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_152_wall_1c__sintered" + }, + { + "xMilli": 2916, + "yMilli": 3000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_153_window_cler_1c__sintered" + }, + { + "xMilli": 2916, + "yMilli": 6000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_154_wall_1c__sintered" + }, + { + "xMilli": 2916, + "yMilli": 4000, + "wMilli": 168, + "hMilli": 109, + "id": "wall_155_door_slide_2c__sintered__jamb_lo" + }, + { + "xMilli": 2916, + "yMilli": 5057, + "wMilli": 168, + "hMilli": 943, + "id": "wall_155_door_slide_2c__sintered__jamb_hi" + }, + { + "xMilli": 4000, + "yMilli": 0, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_158_wall_1c__sintered" + }, + { + "xMilli": 5000, + "yMilli": 0, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_159_window_slot_1c__sintered" + }, + { + "xMilli": 6000, + "yMilli": 0, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_160_wall_1c__sintered" + }, + { + "xMilli": 7916, + "yMilli": 1000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_161_window_slot_1c__sintered" + }, + { + "xMilli": 7916, + "yMilli": 2000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_162_wall_1c__sintered" + }, + { + "xMilli": 8000, + "yMilli": 2916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_163_wall_1c__sintered" + }, + { + "xMilli": 9000, + "yMilli": 2916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_164_wall_1c__sintered" + }, + { + "xMilli": 10000, + "yMilli": 2916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_165_window_slot_1c__sintered" + }, + { + "xMilli": 11916, + "yMilli": 4000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_166_window_cler_1c__sintered" + }, + { + "xMilli": 11916, + "yMilli": 5000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_167_window_slot_1c__sintered" + }, + { + "xMilli": 7000, + "yMilli": 6916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_168_window_slot_1c__sintered" + }, + { + "xMilli": 10000, + "yMilli": 6916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_169_wall_1c__sintered" + }, + { + "xMilli": 8000, + "yMilli": 6916, + "wMilli": 2000, + "hMilli": 168, + "id": "wall_170_window_tall_2c__sintered" + }, + { + "xMilli": 6916, + "yMilli": 7000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_171_wall_1c__sintered" + }, + { + "xMilli": 6916, + "yMilli": 8000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_172_window_cler_1c__sintered" + }, + { + "xMilli": 1000, + "yMilli": 9916, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_173_wall_1c__sintered" + }, + { + "xMilli": 4000, + "yMilli": 9916, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_174_wall_1c__sintered" + }, + { + "xMilli": 5000, + "yMilli": 9916, + "wMilli": 1000, + "hMilli": 84, + "id": "wall_175_window_slot_1c__sintered" + }, + { + "xMilli": 2000, + "yMilli": 9916, + "wMilli": 2000, + "hMilli": 84, + "id": "wall_176_window_tall_2c__sintered" + }, + { + "xMilli": 0, + "yMilli": 8000, + "wMilli": 84, + "hMilli": 1000, + "id": "wall_177_window_cler_1c__sintered" + }, + { + "xMilli": 1000, + "yMilli": 6916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_178_window_slot_1c__sintered" + }, + { + "xMilli": 2000, + "yMilli": 6916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_179_wall_1c__sintered" + }, + { + "xMilli": 2816, + "yMilli": 6816, + "wMilli": 368, + "hMilli": 368, + "id": "wall_180_corner_inner__sintered" + }, + { + "xMilli": 6816, + "yMilli": 6816, + "wMilli": 368, + "hMilli": 368, + "id": "wall_181_corner_inner__sintered" + }, + { + "xMilli": 7816, + "yMilli": 2816, + "wMilli": 368, + "hMilli": 368, + "id": "wall_182_corner_inner__sintered" + }, + { + "xMilli": 3000, + "yMilli": 6916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_183_wall_1c__plasterlined" + }, + { + "xMilli": 6000, + "yMilli": 6916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_184_wall_1c__plasterlined" + }, + { + "xMilli": 4000, + "yMilli": 6916, + "wMilli": 152, + "hMilli": 168, + "id": "wall_185_door_inner_2c__plasterlined__jamb_lo" + }, + { + "xMilli": 5057, + "yMilli": 6916, + "wMilli": 943, + "hMilli": 168, + "id": "wall_185_door_inner_2c__plasterlined__jamb_hi" + }, + { + "xMilli": 6916, + "yMilli": 3000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_186_wall_1c__plasterlined" + }, + { + "xMilli": 6916, + "yMilli": 6000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_187_wall_1c__plasterlined" + }, + { + "xMilli": 6916, + "yMilli": 4000, + "wMilli": 168, + "hMilli": 943, + "id": "wall_188_door_inner_2c__plasterlined__jamb_lo" + }, + { + "xMilli": 6916, + "yMilli": 5848, + "wMilli": 168, + "hMilli": 152, + "id": "wall_188_door_inner_2c__plasterlined__jamb_hi" + }, + { + "xMilli": 6844, + "yMilli": 2844, + "wMilli": 312, + "hMilli": 312, + "id": "wall_189_wall_tee__sintered" + }, + { + "xMilli": 3000, + "yMilli": 2916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_190_wall_1c__plasterlined" + }, + { + "xMilli": 6000, + "yMilli": 2916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_191_wall_1c__plasterlined" + }, + { + "xMilli": 4000, + "yMilli": 2916, + "wMilli": 152, + "hMilli": 168, + "id": "wall_192_door_inner_2c__plasterlined__jamb_lo" + }, + { + "xMilli": 5057, + "yMilli": 2916, + "wMilli": 943, + "hMilli": 168, + "id": "wall_192_door_inner_2c__plasterlined__jamb_hi" + }, + { + "xMilli": 2844, + "yMilli": 2844, + "wMilli": 312, + "hMilli": 312, + "id": "wall_193_wall_tee__sintered" + }, + { + "xMilli": 7000, + "yMilli": 2916, + "wMilli": 1000, + "hMilli": 168, + "id": "wall_194_wall_1c__plasterlined" + }, + { + "xMilli": 8916, + "yMilli": 3000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_195_wall_1c__panelclad" + }, + { + "xMilli": 8916, + "yMilli": 6000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_196_wall_1c__panelclad" + }, + { + "xMilli": 8916, + "yMilli": 4000, + "wMilli": 168, + "hMilli": 943, + "id": "wall_197_door_inner_2c__panelclad__jamb_lo" + }, + { + "xMilli": 8916, + "yMilli": 5848, + "wMilli": 168, + "hMilli": 152, + "id": "wall_197_door_inner_2c__panelclad__jamb_hi" + }, + { + "xMilli": 8844, + "yMilli": 2844, + "wMilli": 312, + "hMilli": 312, + "id": "wall_198_wall_tee__sintered" + }, + { + "xMilli": 8844, + "yMilli": 6844, + "wMilli": 312, + "hMilli": 312, + "id": "wall_199_wall_tee__sintered" + }, + { + "xMilli": 4916, + "yMilli": 8000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_200_wall_1c__panelclad" + }, + { + "xMilli": 4916, + "yMilli": 9000, + "wMilli": 168, + "hMilli": 1000, + "id": "wall_201_wall_1c__panelclad" + }, + { + "xMilli": 4844, + "yMilli": 9844, + "wMilli": 312, + "hMilli": 156, + "id": "wall_202_wall_tee__sintered" + }, + { + "xMilli": 181, + "yMilli": 9124, + "wMilli": 695, + "hMilli": 695, + "id": "furniture_203_furn_corner_hearth__panelclad" + }, + { + "xMilli": 313, + "yMilli": 7313, + "wMilli": 563, + "hMilli": 563, + "id": "furniture_204_furn_corner_bench__panelclad" + }, + { + "xMilli": 2089, + "yMilli": 8153, + "wMilli": 822, + "hMilli": 694, + "id": "furniture_205_furn_table_1c__panelclad" + }, + { + "xMilli": 1237, + "yMilli": 8274, + "wMilli": 526, + "hMilli": 452, + "id": "furniture_206_furn_chair_1c__panelclad" + }, + { + "xMilli": 3237, + "yMilli": 8274, + "wMilli": 526, + "hMilli": 452, + "id": "furniture_207_furn_chair_1c__panelclad" + }, + { + "xMilli": 2258, + "yMilli": 8258, + "wMilli": 484, + "hMilli": 484, + "id": "furniture_208_furn_pendant_1c__panelclad" + }, + { + "xMilli": 1553, + "yMilli": 6768, + "wMilli": 1894, + "hMilli": 464, + "id": "furniture_209_furn_bench_2c__panelclad" }, { - "xMilli": 11421, - "yMilli": 237, - "wMilli": 579, - "hMilli": 8368, - "id": "outer_shell_right" + "xMilli": 4079, + "yMilli": 9779, + "wMilli": 842, + "hMilli": 221, + "id": "furniture_210_furn_chest_1c__panelclad" }, { - "xMilli": 263, - "yMilli": 0, - "wMilli": 11474, - "hMilli": 553, - "id": "outer_shell_back" + "xMilli": 3079, + "yMilli": 6853, + "wMilli": 842, + "hMilli": 294, + "id": "furniture_211_furn_shelf_1c__panelclad" }, { - "xMilli": 263, - "yMilli": 8289, - "wMilli": 1579, - "hMilli": 316, - "id": "front_wall_left" + "xMilli": 4167, + "yMilli": 8254, + "wMilli": 666, + "hMilli": 492, + "id": "furniture_212_furn_planter_1c__panelclad" }, { - "xMilli": 8158, - "yMilli": 8289, - "wMilli": 3579, - "hMilli": 316, - "id": "front_wall_right" + "xMilli": 1398, + "yMilli": 9749, + "wMilli": 204, + "hMilli": 251, + "id": "furniture_213_furn_lamp_wall__panelclad" }, { - "xMilli": 1842, - "yMilli": 8605, - "wMilli": 316, - "hMilli": 395, - "id": "entry_bay_return_left" + "xMilli": 6124, + "yMilli": 9124, + "wMilli": 563, + "hMilli": 563, + "id": "furniture_214_furn_corner_store__panelclad" }, { - "xMilli": 7842, - "yMilli": 8605, - "wMilli": 316, - "hMilli": 395, - "id": "entry_bay_return_right" + "xMilli": 6684, + "yMilli": 7026, + "wMilli": 632, + "hMilli": 948, + "id": "furniture_215_furn_sink_1c__panelclad" }, { - "xMilli": 2158, - "yMilli": 8616, - "wMilli": 2542, - "hMilli": 384, - "id": "entry_bay_front_left" + "xMilli": 6684, + "yMilli": 8026, + "wMilli": 632, + "hMilli": 948, + "id": "furniture_216_furn_stove_1c__panelclad" }, { - "xMilli": 7300, - "yMilli": 8616, - "wMilli": 542, - "hMilli": 384, - "id": "entry_bay_front_right" + "xMilli": 4832, + "yMilli": 8026, + "wMilli": 336, + "hMilli": 948, + "id": "furniture_217_furn_wall_cab_1c__panelclad" }, { - "xMilli": 2368, - "yMilli": 2500, - "wMilli": 2264, - "hMilli": 495, - "id": "counter_bank" + "xMilli": 5000, + "yMilli": 9398, + "wMilli": 251, + "hMilli": 204, + "id": "furniture_218_furn_lamp_wall__panelclad" }, { - "xMilli": 5368, - "yMilli": 2500, - "wMilli": 2264, - "hMilli": 495, - "id": "counter_trade" + "xMilli": 7595, + "yMilli": 6663, + "wMilli": 1810, + "hMilli": 674, + "id": "furniture_219_furn_desk_2c__panelclad" }, { - "xMilli": 8368, - "yMilli": 2500, - "wMilli": 2264, - "hMilli": 495, - "id": "counter_pa" + "xMilli": 8274, + "yMilli": 5237, + "wMilli": 452, + "hMilli": 526, + "id": "furniture_220_furn_chair_1c__panelclad" }, { - "xMilli": 1053, - "yMilli": 553, - "wMilli": 3263, - "hMilli": 515, - "id": "bank_vault_face" + "xMilli": 7705, + "yMilli": 3163, + "wMilli": 590, + "hMilli": 674, + "id": "furniture_221_furn_crate_stack__panelclad" }, { - "xMilli": 5263, - "yMilli": 553, - "wMilli": 2526, - "hMilli": 347, - "id": "trade_shelving" + "xMilli": 8079, + "yMilli": 2853, + "wMilli": 842, + "hMilli": 294, + "id": "furniture_222_furn_shelf_1c__panelclad" }, { - "xMilli": 2558, - "yMilli": 5037, - "wMilli": 1884, - "hMilli": 84, - "id": "queue_rail_bank" + "xMilli": 7000, + "yMilli": 3398, + "wMilli": 251, + "hMilli": 204, + "id": "furniture_223_furn_lamp_wall__panelclad" }, { - "xMilli": 5558, - "yMilli": 5037, - "wMilli": 1884, - "hMilli": 84, - "id": "queue_rail_trade" + "xMilli": 11124, + "yMilli": 3313, + "wMilli": 563, + "hMilli": 563, + "id": "furniture_224_furn_corner_shelf__panelclad" }, { - "xMilli": 8558, - "yMilli": 5037, - "wMilli": 1884, - "hMilli": 84, - "id": "queue_rail_pa" + "xMilli": 11124, + "yMilli": 6124, + "wMilli": 563, + "hMilli": 563, + "id": "furniture_225_furn_corner_desk__panelclad" }, { - "xMilli": 579, - "yMilli": 3395, - "wMilli": 474, - "hMilli": 2842, - "id": "bench_west" + "xMilli": 8553, + "yMilli": 2537, + "wMilli": 1894, + "hMilli": 926, + "id": "furniture_226_furn_bed_2c__panelclad" }, { - "xMilli": 789, - "yMilli": 7932, - "wMilli": 2211, - "hMilli": 336, - "id": "bench_front" + "xMilli": 8811, + "yMilli": 3305, + "wMilli": 378, + "hMilli": 390, + "id": "furniture_227_furn_nightstand__panelclad" }, { - "xMilli": 4032, - "yMilli": 7795, - "wMilli": 357, - "hMilli": 358, - "id": "vestibule_pier_left" + "xMilli": 10398, + "yMilli": 6749, + "wMilli": 204, + "hMilli": 251, + "id": "furniture_228_furn_lamp_wall__panelclad" }, { - "xMilli": 7611, - "yMilli": 7795, - "wMilli": 357, - "hMilli": 358, - "id": "vestibule_pier_right" + "xMilli": 3313, + "yMilli": 313, + "wMilli": 563, + "hMilli": 563, + "id": "furniture_229_furn_corner_shelf__panelclad" }, { - "xMilli": 2758, - "yMilli": 6574, - "wMilli": 484, + "xMilli": 7124, + "yMilli": 313, + "wMilli": 563, + "hMilli": 563, + "id": "furniture_230_furn_corner_bench__panelclad" + }, + { + "xMilli": 5163, + "yMilli": 0, + "wMilli": 674, + "hMilli": 295, + "id": "furniture_231_furn_crate_stack__panelclad" + }, + { + "xMilli": 6142, + "yMilli": 2516, + "wMilli": 716, "hMilli": 484, - "id": "column_west" + "id": "furniture_232_xs_san_basin_1c__panelclad" + }, + { + "xMilli": 6037, + "yMilli": 2764, + "wMilli": 926, + "hMilli": 137, + "id": "furniture_233_xs_san_mirrorcab_1c__panelclad" + }, + { + "xMilli": 6268, + "yMilli": 0, + "wMilli": 464, + "hMilli": 537, + "id": "furniture_234_xs_san_reclaimer_1c__panelclad" + }, + { + "xMilli": 7095, + "yMilli": 2037, + "wMilli": 905, + "hMilli": 926, + "id": "furniture_235_xs_san_stall_1c__panelclad" + }, + { + "xMilli": 3105, + "yMilli": 2806, + "wMilli": 790, + "hMilli": 110, + "id": "furniture_236_xs_san_towelbar_1c__panelclad" }, { - "xMilli": 8758, - "yMilli": 6574, + "xMilli": 5184, + "yMilli": 1353, + "wMilli": 632, + "hMilli": 294, + "id": "furniture_237_xs_san_drain_1c__panelclad" + }, + { + "xMilli": 4398, + "yMilli": 0, + "wMilli": 204, + "hMilli": 251, + "id": "furniture_238_furn_lamp_wall__panelclad" + }, + { + "xMilli": 3167, + "yMilli": 6254, + "wMilli": 666, + "hMilli": 492, + "id": "furniture_239_furn_planter_1c__panelclad" + }, + { + "xMilli": 6258, + "yMilli": 3258, "wMilli": 484, "hMilli": 484, - "id": "column_east" + "id": "furniture_240_furn_pendant_1c__panelclad" + }, + { + "xMilli": 3079, + "yMilli": 2853, + "wMilli": 842, + "hMilli": 294, + "id": "furniture_241_furn_shelf_1c__panelclad" }, { - "xMilli": 11011, - "yMilli": 5805, - "wMilli": 410, - "hMilli": 1432, - "id": "trainer_desk" + "xMilli": 6398, + "yMilli": 6749, + "wMilli": 204, + "hMilli": 251, + "id": "furniture_242_furn_lamp_wall__panelclad" } ], "interiorRegions": [ { - "id": "main_walkable_interior", - "xMilli": 579, - "yMilli": 553, - "wMilli": 10842, - "hMilli": 7715, + "id": "hall", + "xMilli": 3000, + "yMilli": 3000, + "wMilli": 4000, + "hMilli": 4000, + "floorTopM": 0.021052631578947368 + }, + { + "id": "living", + "xMilli": 0, + "yMilli": 7000, + "wMilli": 5000, + "hMilli": 3000, + "floorTopM": 0.021052631578947368 + }, + { + "id": "kitchen", + "xMilli": 5000, + "yMilli": 7000, + "wMilli": 2000, + "hMilli": 3000, + "floorTopM": 0.021052631578947368 + }, + { + "id": "study", + "xMilli": 7000, + "yMilli": 3000, + "wMilli": 2000, + "hMilli": 4000, + "floorTopM": 0.021052631578947368 + }, + { + "id": "bed", + "xMilli": 9000, + "yMilli": 3000, + "wMilli": 3000, + "hMilli": 4000, + "floorTopM": 0.021052631578947368 + }, + { + "id": "bath", + "xMilli": 3000, + "yMilli": 0, + "wMilli": 5000, + "hMilli": 3000, "floorTopM": 0.021052631578947368 } ], "door": { "blocker": { - "xMilli": 4700, - "yMilli": 8616, - "wMilli": 2600, - "hMilli": 316, - "id": "closed_door_panel" + "xMilli": 2974, + "yMilli": 4109, + "wMilli": 52, + "hMilli": 948, + "id": "closed_exterior_entry" }, "interactRadiusCells": 3.1 }, @@ -4050,10 +6566,40 @@ "floorHeightM": 0.021052631578947368, "interiorBounds": [ { - "xMilli": 579, - "yMilli": 553, - "wMilli": 10842, - "hMilli": 7715 + "xMilli": 3000, + "yMilli": 3000, + "wMilli": 4000, + "hMilli": 4000 + }, + { + "xMilli": 0, + "yMilli": 7000, + "wMilli": 5000, + "hMilli": 3000 + }, + { + "xMilli": 5000, + "yMilli": 7000, + "wMilli": 2000, + "hMilli": 3000 + }, + { + "xMilli": 7000, + "yMilli": 3000, + "wMilli": 2000, + "hMilli": 4000 + }, + { + "xMilli": 9000, + "yMilli": 3000, + "wMilli": 3000, + "hMilli": 4000 + }, + { + "xMilli": 3000, + "yMilli": 0, + "wMilli": 5000, + "hMilli": 3000 } ] } @@ -4240,8 +6786,8 @@ "kind": "clone_pod", "assetKey": "clone_pod", "cell": { - "x": 518, - "y": 504 + "x": 515, + "y": 500 }, "size": { "w": 1, @@ -4261,184 +6807,6 @@ ], "rotation": 0 }, - { - "id": "open-desert-shelter-house", - "entity": "prop:open-desert-overworld:open-desert-shelter-house", - "areaId": "open-desert-overworld", - "label": "Shelter House", - "kind": "prop", - "assetKey": "house_shoulder", - "cell": { - "x": 509, - "y": 508 - }, - "size": { - "w": 5, - "h": 4 - }, - "interactive": false, - "solid": false, - "visible": true, - "collisionBounds": [ - { - "xMilli": 474, - "yMilli": 105, - "wMilli": 4052, - "hMilli": 290 - }, - { - "xMilli": 184, - "yMilli": 395, - "wMilli": 290, - "hMilli": 2684 - }, - { - "xMilli": 4526, - "yMilli": 395, - "wMilli": 290, - "hMilli": 1737 - }, - { - "xMilli": 4526, - "yMilli": 2132, - "wMilli": 263, - "hMilli": 947 - }, - { - "xMilli": 4816, - "yMilli": 2132, - "wMilli": 26, - "hMilli": 105 - }, - { - "xMilli": 4789, - "yMilli": 2237, - "wMilli": 27, - "hMilli": 842 - }, - { - "xMilli": 474, - "yMilli": 3079, - "wMilli": 1158, - "hMilli": 289 - }, - { - "xMilli": 4447, - "yMilli": 3079, - "wMilli": 79, - "hMilli": 289 - }, - { - "xMilli": 1632, - "yMilli": 3368, - "wMilli": 289, - "hMilli": 27 - }, - { - "xMilli": 4158, - "yMilli": 3368, - "wMilli": 289, - "hMilli": 237 - }, - { - "xMilli": 1632, - "yMilli": 3395, - "wMilli": 26, - "hMilli": 52 - }, - { - "xMilli": 1921, - "yMilli": 3395, - "wMilli": 921, - "hMilli": 52 - }, - { - "xMilli": 2868, - "yMilli": 3421, - "wMilli": 53, - "hMilli": 184 - }, - { - "xMilli": 4026, - "yMilli": 3421, - "wMilli": 79, - "hMilli": 184 - }, - { - "xMilli": 1605, - "yMilli": 3447, - "wMilli": 27, - "hMilli": 158 - }, - { - "xMilli": 1658, - "yMilli": 3447, - "wMilli": 263, - "hMilli": 158 - }, - { - "xMilli": 1921, - "yMilli": 3605, - "wMilli": 947, - "hMilli": 237 - }, - { - "xMilli": 4105, - "yMilli": 3605, - "wMilli": 53, - "hMilli": 237 - }, - { - "xMilli": 1921, - "yMilli": 3842, - "wMilli": 737, - "hMilli": 53 - }, - { - "xMilli": 4158, - "yMilli": 3842, - "wMilli": 131, - "hMilli": 158 - }, - { - "xMilli": 2658, - "yMilli": 3895, - "wMilli": 263, - "hMilli": 26 - }, - { - "xMilli": 4026, - "yMilli": 3895, - "wMilli": 132, - "hMilli": 26 - }, - { - "xMilli": 2658, - "yMilli": 3921, - "wMilli": 210, - "hMilli": 79 - }, - { - "xMilli": 4105, - "yMilli": 3921, - "wMilli": 53, - "hMilli": 79 - } - ], - "door": { - "blocker": { - "xMilli": 2921, - "yMilli": 3453, - "wMilli": 1116, - "hMilli": 100 - }, - "interactRadiusCells": 3.1 - }, - "shelter": true, - "enterable": { - "floorHeightM": 0.14736842105263157 - } - }, { "id": "dustgate-occupation-vendor-awning", "entity": "prop:open-desert-overworld:dustgate-occupation-vendor-awning", @@ -8629,8 +10997,8 @@ "label": "Dustgate", "terminalPropId": "travel-terminal-dustgate", "spawn": { - "x": 512, - "y": 513 + "x": 516, + "y": 511 } } ] @@ -8700,6 +11068,123 @@ "reserved": 0, "available": 1 }, + { + "container": "player:field-pack", + "item": "Plasma Sword", + "itemId": 3104, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "container": "player:field-pack", + "item": "Scrapline Machete", + "itemId": 3105, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "container": "player:field-pack", + "item": "Field Saber", + "itemId": 3106, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "container": "player:field-pack", + "item": "Quarry Chopper", + "itemId": 3107, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "container": "player:field-pack", + "item": "STEN Mk II", + "itemId": 3111, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "container": "player:field-pack", + "item": "Kiln Energy Cell Carbine", + "itemId": 3112, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "container": "player:field-pack", + "item": "Lightning Carbine", + "itemId": 3121, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "container": "player:field-pack", + "item": "Badge Bolt Pistol", + "itemId": 3122, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "container": "player:field-pack", + "item": "Slagrail Vanguard", + "itemId": 3123, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "container": "player:field-pack", + "item": "Coilgate Scatter", + "itemId": 3124, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "container": "player:field-pack", + "item": "Kiln Long Pattern", + "itemId": 3125, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "container": "player:field-pack", + "item": "Bastion LMG", + "itemId": 3126, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "container": "player:field-pack", + "item": "Flare Net Launcher", + "itemId": 3127, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, { "container": "player:field-pack", "item": "Personal Shield Generator", diff --git a/client/src/chat/chatClient.test.ts b/client/src/chat/chatClient.test.ts index dd00fbc4..79803550 100644 --- a/client/src/chat/chatClient.test.ts +++ b/client/src/chat/chatClient.test.ts @@ -64,19 +64,19 @@ describe("chat client social commands", () => { it("sends standalone authentication as the only first websocket frame", () => { vi.stubGlobal("WebSocket", FakeWebSocket); - const client = createChatClient({ self, zoneId: "open-desert", authTicket: "chat-secret" }); + const client = createChatClient({ self, zoneId: "open-desert", authTicket: "chat-secret", authReleaseId: "release-a" }); client.connect("wss://chat.example.test/socket"); const socket = FakeWebSocket.latest; if (!socket) throw new Error("expected fake websocket"); socket.emit("open"); - expect(socket.sent).toEqual([JSON.stringify({ type: "chat.authenticate", chatTicket: "chat-secret" })]); + expect(socket.sent).toEqual([JSON.stringify({ type: "chat.authenticate", chatTicket: "chat-secret", release: "release-a" })]); client.dispose(); }); it("starts application pings only after authenticated open and before the idle timeout", () => { vi.useFakeTimers(); vi.stubGlobal("WebSocket", FakeWebSocket); - const client = createChatClient({ self, zoneId: "open-desert", authTicket: "chat-secret" }); + const client = createChatClient({ self, zoneId: "open-desert", authTicket: "chat-secret", authReleaseId: "release-a" }); client.connect("wss://chat.example.test/socket"); const socket = FakeWebSocket.latest; if (!socket) throw new Error("expected fake websocket"); @@ -85,7 +85,7 @@ describe("chat client social commands", () => { expect(socket.sent).toEqual([]); socket.emit("open"); - expect(socket.sent).toEqual([JSON.stringify({ type: "chat.authenticate", chatTicket: "chat-secret" })]); + expect(socket.sent).toEqual([JSON.stringify({ type: "chat.authenticate", chatTicket: "chat-secret", release: "release-a" })]); vi.advanceTimersByTime(24_999); expect(socket.sent).toHaveLength(1); vi.advanceTimersByTime(1); diff --git a/client/src/chat/chatClient.ts b/client/src/chat/chatClient.ts index fb714574..98665258 100644 --- a/client/src/chat/chatClient.ts +++ b/client/src/chat/chatClient.ts @@ -80,6 +80,8 @@ export interface ChatClientOptions { onBubble?: (message: ChatBubbleMessage) => void; /** Standalone capability; sent once as the first websocket frame. */ authTicket?: string; + /** Exact release bound into the standalone chat capability. */ + authReleaseId?: string; onFailure?: (reason: "chat-failed") => void; /** Node clients supply a socket carrying an exact Origin header for the * server's admission policy; browsers keep the native constructor. */ @@ -302,7 +304,12 @@ function connectChat( socket.addEventListener("open", () => { if (state.socket !== socket || state.manuallyClosed) return; if (options.authTicket) { - socket.send(JSON.stringify({ type: "chat.authenticate", chatTicket: options.authTicket })); + if (!options.authReleaseId) throw new Error("standalone chat release id required"); + socket.send(JSON.stringify({ + type: "chat.authenticate", + chatTicket: options.authTicket, + release: options.authReleaseId, + })); options.authTicket = undefined; } state.connected = true; diff --git a/client/src/headless/host.test.ts b/client/src/headless/host.test.ts index 836c3771..994d89c3 100644 --- a/client/src/headless/host.test.ts +++ b/client/src/headless/host.test.ts @@ -46,6 +46,7 @@ describe("successor headless host", () => { playerId: "legacy-player", actorId: "char_a", displayName: "Legacy Player", + clientReleaseId: "release-a", zoneId: "open-desert", characterId: "char_a", ticket: "legacy-ticket", @@ -57,6 +58,7 @@ describe("successor headless host", () => { expect(joinBodyFor({ ...base, gameTicket: "game-capability" }, "char_a")).toEqual({ gameTicket: "game-capability", + release: "release-a", }); expect(joinBodyFor(base, "char_a")).toEqual({ playerId: "legacy-player", @@ -80,6 +82,7 @@ describe("successor headless host", () => { characterId: "char_a", actorId: "char_a", gameTicket: "game-capability", + clientReleaseId: "release-a", readyTimeoutMs: 1_000, }; const host = await createSuccessorHeadlessHost(options); diff --git a/client/src/headless/host.ts b/client/src/headless/host.ts index 624439e1..9c770ea8 100644 --- a/client/src/headless/host.ts +++ b/client/src/headless/host.ts @@ -30,6 +30,8 @@ export interface SuccessorHeadlessHostOptions { /** Standalone one-use game capability. Sent only inside the join body and * cleared the moment the join request is built — never a URL, never kept. */ gameTicket?: string; + /** Exact release bound into a standalone game capability. */ + clientReleaseId?: string; /** Exact storefront Origin sent on the matchmake request and WS handshake * (hosted admission policy). In-memory only; never logged or persisted. */ origin?: string; @@ -74,7 +76,10 @@ export async function createSuccessorHeadlessHost(options: SuccessorHeadlessHost * server's standalone mode rejects any URL-borne ticket. */ export function joinBodyFor(options: SuccessorHeadlessHostOptions, actorId: string): Record { - if (options.gameTicket) return { gameTicket: options.gameTicket }; + if (options.gameTicket) { + if (!options.clientReleaseId) throw new Error("standalone game release id required"); + return { gameTicket: options.gameTicket, release: options.clientReleaseId }; + } const body: Record = { playerId: options.playerId ?? actorId, actorId, diff --git a/client/src/slice-core/gameAuthorityLaunch.test.ts b/client/src/slice-core/gameAuthorityLaunch.test.ts index c6649945..dc43dab6 100644 --- a/client/src/slice-core/gameAuthorityLaunch.test.ts +++ b/client/src/slice-core/gameAuthorityLaunch.test.ts @@ -7,6 +7,7 @@ describe("standalone game launch", () => { standalone: true, gameTicket: "game-secret", chatTicket: "chat-secret", + clientReleaseId: "release-a", playerId: "char-1", displayName: "Atlas", ownerRef: "account-1", @@ -18,6 +19,6 @@ describe("standalone game launch", () => { characterId: "char-1", gameWsUrl: "wss://game.example.test/socket", chatWsUrl: "wss://chat.example.test/socket", - })).toEqual({ gameTicket: "game-secret" }); + })).toEqual({ gameTicket: "game-secret", release: "release-a" }); }); }); diff --git a/client/src/slice-core/gameAuthoritySystem.ts b/client/src/slice-core/gameAuthoritySystem.ts index 0d515f7c..2b45db14 100644 --- a/client/src/slice-core/gameAuthoritySystem.ts +++ b/client/src/slice-core/gameAuthoritySystem.ts @@ -1029,7 +1029,9 @@ export function gameAuthorityWsUrl(launchIdentity: LaunchIdentity): string { export function gameAuthorityJoinOptions(launchIdentity: LaunchIdentity): Record { if (launchIdentity.standalone) { - return launchIdentity.gameTicket ? { gameTicket: launchIdentity.gameTicket } : {}; + return launchIdentity.gameTicket && launchIdentity.clientReleaseId + ? { gameTicket: launchIdentity.gameTicket, release: launchIdentity.clientReleaseId } + : {}; } const params = new URLSearchParams(window.location.search); const explicit = launchIdentity.gameWsUrl; diff --git a/client/src/slice-core/inventoryDisplaySystem.ts b/client/src/slice-core/inventoryDisplaySystem.ts index 4cfc137f..b92a6e80 100644 --- a/client/src/slice-core/inventoryDisplaySystem.ts +++ b/client/src/slice-core/inventoryDisplaySystem.ts @@ -70,6 +70,12 @@ const itemDefinitions: Record = { 3111: { name: "STEN Mk II", description: "Compact 9mm submachine gun built on the STEN pattern.", category: "gear" }, 3112: { name: "Kiln Energy Cell Carbine", description: "Tight, hard-hitting energy carbine built on the STEN receiver.", category: "gear" }, 3121: { name: "Lightning Carbine", description: "Rapid Storm-pattern carbine with an electric discharge trace.", category: "gear" }, + 3122: { name: "Badge Bolt Pistol", description: "Compact Badge Bolt sidearm.", category: "gear" }, + 3123: { name: "Slagrail Vanguard", description: "Rifle built around the Slagrail Vanguard pattern.", category: "gear" }, + 3124: { name: "Coilgate Scatter", description: "Close-range Coilgate scatter weapon.", category: "gear" }, + 3125: { name: "Kiln Long Pattern", description: "Long-pattern Kiln precision rifle.", category: "gear" }, + 3126: { name: "Bastion LMG", description: "Bastion-pattern light machine gun.", category: "gear" }, + 3127: { name: "Flare Net Launcher", description: "Flare Net launcher for area denial.", category: "gear" }, 5001: { name: "Travel Ticket", description: "One-use terminal ticket for planetfall travel.", category: "item" }, 5002: { name: "Looted Schematic", description: "Recovered schematic; learn it to draft the design.", category: "item" }, 5003: { name: "Drafted Schematic", description: "Datapad handle for a drafted factory schematic.", category: "item" }, diff --git a/client/src/slice-core/specs/weapons.v1.json b/client/src/slice-core/specs/weapons.v1.json index 1cf67073..fcb28a0c 100644 --- a/client/src/slice-core/specs/weapons.v1.json +++ b/client/src/slice-core/specs/weapons.v1.json @@ -19,6 +19,24 @@ "iconKey": "slugthrower", "reloadSfx": "slugthrower_reload" }, + "wpn-pistol": { + "id": "wpn-pistol", + "name": "Badge Bolt Pistol", + "shortName": "Badge Bolt", + "slot": "longGun", + "caliber": "slug", + "compatibleAmmoTypes": [ + "slug_iron", + "slug_shard", + "slug_spike" + ], + "defaultAmmoType": "slug_iron", + "requiredCert": "cert_rifle", + "magazineSize": 30, + "reloadMs": 2000, + "iconKey": "slugthrower", + "reloadSfx": "slugthrower_reload" + }, "wpn-smg": { "id": "wpn-smg", "name": "STEN Mk II", @@ -73,6 +91,96 @@ "iconKey": "slugthrower", "reloadSfx": "slugthrower_reload" }, + "wpn-assault": { + "id": "wpn-assault", + "name": "Slagrail Vanguard", + "shortName": "Slagrail", + "slot": "longGun", + "caliber": "slug", + "compatibleAmmoTypes": [ + "slug_iron", + "slug_shard", + "slug_spike" + ], + "defaultAmmoType": "slug_iron", + "requiredCert": "cert_rifle", + "magazineSize": 30, + "reloadMs": 2000, + "iconKey": "slugthrower", + "reloadSfx": "slugthrower_reload" + }, + "wpn-shotgun": { + "id": "wpn-shotgun", + "name": "Coilgate Scatter", + "shortName": "Coilgate", + "slot": "longGun", + "caliber": "slug", + "compatibleAmmoTypes": [ + "slug_iron", + "slug_shard", + "slug_spike" + ], + "defaultAmmoType": "slug_iron", + "requiredCert": "cert_rifle", + "magazineSize": 30, + "reloadMs": 2000, + "iconKey": "slugthrower", + "reloadSfx": "slugthrower_reload" + }, + "wpn-sniper": { + "id": "wpn-sniper", + "name": "Kiln Long Pattern", + "shortName": "Kiln Long", + "slot": "longGun", + "caliber": "slug", + "compatibleAmmoTypes": [ + "slug_iron", + "slug_shard", + "slug_spike" + ], + "defaultAmmoType": "slug_iron", + "requiredCert": "cert_rifle", + "magazineSize": 30, + "reloadMs": 2000, + "iconKey": "slugthrower", + "reloadSfx": "slugthrower_reload" + }, + "wpn-heavy": { + "id": "wpn-heavy", + "name": "Bastion LMG", + "shortName": "Bastion", + "slot": "longGun", + "caliber": "slug", + "compatibleAmmoTypes": [ + "slug_iron", + "slug_shard", + "slug_spike" + ], + "defaultAmmoType": "slug_iron", + "requiredCert": "cert_rifle", + "magazineSize": 30, + "reloadMs": 2000, + "iconKey": "slugthrower", + "reloadSfx": "slugthrower_reload" + }, + "wpn-launcher": { + "id": "wpn-launcher", + "name": "Flare Net Launcher", + "shortName": "Flare Net", + "slot": "longGun", + "caliber": "slug", + "compatibleAmmoTypes": [ + "slug_iron", + "slug_shard", + "slug_spike" + ], + "defaultAmmoType": "slug_iron", + "requiredCert": "cert_rifle", + "magazineSize": 30, + "reloadMs": 2000, + "iconKey": "slugthrower", + "reloadSfx": "slugthrower_reload" + }, "vibrosword": { "id": "vibrosword", "name": "Vibrosword", diff --git a/client/src/slice-core/weaponSystem.test.ts b/client/src/slice-core/weaponSystem.test.ts index e5451d93..96723ecb 100644 --- a/client/src/slice-core/weaponSystem.test.ts +++ b/client/src/slice-core/weaponSystem.test.ts @@ -82,9 +82,15 @@ describe("canonical weapon and ammunition catalog", () => { it("contains exactly the supported weapon and ammunition ids", () => { expect(allWeaponIds).toEqual([ "slugthrower", + "wpn-pistol", "wpn-smg", "wpn-carbine", "lightning-carbine", + "wpn-assault", + "wpn-shotgun", + "wpn-sniper", + "wpn-heavy", + "wpn-launcher", "vibrosword", "scrapline-machete", "field-saber", diff --git a/client/src/slice-core/weaponSystem.ts b/client/src/slice-core/weaponSystem.ts index d5db0f7b..f078ec97 100644 --- a/client/src/slice-core/weaponSystem.ts +++ b/client/src/slice-core/weaponSystem.ts @@ -4,9 +4,15 @@ import weaponSpecPayload from "./specs/weapons.v1.json"; export type WeaponId = | "slugthrower" + | "wpn-pistol" | "wpn-smg" | "wpn-carbine" | "lightning-carbine" + | "wpn-assault" + | "wpn-shotgun" + | "wpn-sniper" + | "wpn-heavy" + | "wpn-launcher" | "vibrosword" | "scrapline-machete" | "field-saber" @@ -72,15 +78,32 @@ function parseWeaponSpecPayload(payload: unknown): Record } const weapons = parsed.weapons as Partial>; const slugthrower = validateWeaponSpec(weapons.slugthrower, "slugthrower"); + const wpnPistol = validateWeaponSpec(weapons["wpn-pistol"], "wpn-pistol"); const wpnSmg = validateWeaponSpec(weapons["wpn-smg"], "wpn-smg"); const wpnCarbine = validateWeaponSpec(weapons["wpn-carbine"], "wpn-carbine"); const lightningCarbine = validateWeaponSpec(weapons["lightning-carbine"], "lightning-carbine"); + const wpnAssault = validateWeaponSpec(weapons["wpn-assault"], "wpn-assault"); + const wpnShotgun = validateWeaponSpec(weapons["wpn-shotgun"], "wpn-shotgun"); + const wpnSniper = validateWeaponSpec(weapons["wpn-sniper"], "wpn-sniper"); + const wpnHeavy = validateWeaponSpec(weapons["wpn-heavy"], "wpn-heavy"); + const wpnLauncher = validateWeaponSpec(weapons["wpn-launcher"], "wpn-launcher"); const vibrosword = validateWeaponSpec(weapons.vibrosword, "vibrosword"); const scraplineMachete = validateWeaponSpec(weapons["scrapline-machete"], "scrapline-machete"); const fieldSaber = validateWeaponSpec(weapons["field-saber"], "field-saber"); const quarryChopper = validateWeaponSpec(weapons["quarry-chopper"], "quarry-chopper"); const unarmed = validateWeaponSpec(weapons.unarmed, "unarmed"); - for (const ranged of [slugthrower, wpnSmg, wpnCarbine, lightningCarbine]) { + for (const ranged of [ + slugthrower, + wpnPistol, + wpnSmg, + wpnCarbine, + lightningCarbine, + wpnAssault, + wpnShotgun, + wpnSniper, + wpnHeavy, + wpnLauncher, + ]) { if (ranged.slot !== "longGun" || ranged.caliber !== "slug") { throw new Error(`${ranged.id} weapon slot/ammo contract mismatch`); } @@ -101,9 +124,15 @@ function parseWeaponSpecPayload(payload: unknown): Record } return { slugthrower, + "wpn-pistol": wpnPistol, "wpn-smg": wpnSmg, "wpn-carbine": wpnCarbine, "lightning-carbine": lightningCarbine, + "wpn-assault": wpnAssault, + "wpn-shotgun": wpnShotgun, + "wpn-sniper": wpnSniper, + "wpn-heavy": wpnHeavy, + "wpn-launcher": wpnLauncher, vibrosword, "scrapline-machete": scraplineMachete, "field-saber": fieldSaber, diff --git a/content-pipeline/labs/humanoid-runtime-refit/.gitignore b/content-pipeline/labs/humanoid-runtime-refit/.gitignore new file mode 100644 index 00000000..f3576377 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/.gitignore @@ -0,0 +1,16 @@ +# Blender autosave siblings +*.blend1 + +# Scratch build products. Body shading, texture and reference caches regenerate +# from the pinned source bodies and are never handoff artifacts. +__pycache__/ +build/reference/ +build/textures/ +build/scratch/ +build/female_posterior/ +build/apparel_baseline/ +build/*.npy + +# Individual proof cells are intermediates: proof_renders.py regenerates them +# and reports/proof_manifest.json records every selected cell's SHA256. +proof/cells/ diff --git a/content-pipeline/labs/humanoid-runtime-refit/author_starter_apparel.py b/content-pipeline/labs/humanoid-runtime-refit/author_starter_apparel.py new file mode 100644 index 00000000..ecaf2da8 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/author_starter_apparel.py @@ -0,0 +1,574 @@ +"""Author the starter suit and boots as fitted skinned outer geometry, per sex. + +`refit_apparel.py` warps the catalogue from the body it was drawn on onto the +promoted body. That works for garments that only needed re-seating, but the two +FIXED starter pieces are the ones it cannot save: measured after the generic +refit, `under_bodysuit` still reads -1.5 mm into the male skin and -11.0 mm into +the female, and `boots_canvas_ankle` -18.3 / -19.0 mm. A warp cannot fix that, +because the retro bodies are a different body -- the male foot alone went from a +76 mm to a 103 mm span -- and no offset field turns a boot drawn around the old +foot into one that encloses the new one. + +So these two are authored FROM the body instead of fitted TO it. Each piece is a +shell over a chosen set of skin zones, offset outward along the body's own +normals and skinned with the body's own weights. That construction is what makes +the fit exact rather than approximate: + + * the shell shares the skin's topology and weights, so linear-blend skinning + applies the SAME blended matrix to a shell vertex and to the skin vertex + under it. Their separation is that matrix applied to the offset vector, which + contracts at a folded joint but cannot invert -- the garment can hug tighter + in a deep crouch and can never pass through; + * one shell per body, so the female piece is fitted to the female body rather + than being the male piece warped sideways; + * the openings get a rim: the boundary loop is joined back down to the skin, so + a collar or a cuff reads as fabric with thickness against visible skin + instead of as a hole into the body. + +The boot keeps the ratified runtime contract from +`client-3d/src/assets/bootsCanvasAnkleFit.test.ts`: two meshes named per side, +exactly the three dye materials in order, the collar trim band the only geometry +above the ankle, binary foot/calf weights, faces wound outward, and a shell that +encloses the WORN suit's foot region rather than just bare skin. Every one of +those is asserted here at authoring time, so a regression fails the build and not +the test suite. + + blender --background --python author_starter_apparel.py +""" + +from __future__ import annotations + +import hashlib +import json +import os +import sys +from typing import Any, Callable + +import numpy as np + +LAB_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, LAB_DIR) + +import body_zones as BZ # noqa: E402 +import refit_config as CFG # noqa: E402 +from gltf_io import (Glb, GlbBuilder, TARGET_ARRAY_BUFFER, # noqa: E402 + TARGET_ELEMENT_ARRAY_BUFFER) + +GENERATOR = "successor content-pipeline/labs/humanoid-runtime-refit/author_starter_apparel.py" + +#: Female variants live beside the male ones under this prefix, and the manifest +#: names them with `glbFemale`. Both runtimes resolve that key relative to the +#: equipment directory. +FEMALE_PREFIX = "Female" + +#: Suit standoff. This is a skin-tight bodysuit over skin its own mask removes, so +#: the only job the standoff has is to stop the two surfaces z-fighting: 1 mm does +#: that on a body whose facets are centimetres across. Every extra millimetre was +#: measurable as harm -- at 5 mm the suit read 4.6 mm deeper into visible skin than +#: the bare body wherever a clip presses a hand onto a thigh, and it ate the boot's +#: clearance from the inside. +SUIT_OFFSET_M = 0.001 + +#: Joint compensation was tried and MEASURED WORSE, so it is deliberately off. +#: Thickening the standoff where the skin weights are split does answer the +#: linear-blend contraction, but it balloons the shell over every joint: the +#: bulge leaves the skin far enough that the coverage cone stops finding fabric +#: (the suit's torso fell from 0.998 to 0.994 enclosed) and the bulge itself then +#: swings through the skin on the far side of a fold (worst penetration went from +#: -3.9 mm to -8.2 mm). A uniform standoff is both tighter and cleaner. +JOINT_COMPENSATION = 0.0 +#: The suit is neck-to-wrists-to-ankles, exactly as its manifest note says. Feet +#: are the BOOT's layer: letting the suit cover them too put two shells over the +#: same skin, and the boot's collar then had to cross the suit's foot geometry +#: (measured: 221 crossing triangle pairs). Layer topology, not offsets, is what +#: keeps them apart -- the suit's calf ends in a closed cuff at the ankle and the +#: boot shaft rides over that cuff with radial clearance. +SUIT_ZONES = tuple(zone for zone in BZ.ZONES + if zone not in ("neck", "head", "left_hand", "right_hand", + "left_foot", "right_foot")) + +#: Boot standoff. Larger than the suit's, because the boot is worn OVER it and +#: has to enclose it. +BOOT_OFFSET_M = 0.010 +#: Where the boot's collar rim lands: on the suit, not the skin. One suit +#: standoff plus a millimetre, so the two shells touch and never cross. +BOOT_RIM_ON_SUIT_M = SUIT_OFFSET_M + 0.001 + +#: Top of the boot shaft, and the bottom of its collar trim band, in bind-pose y. +#: The trim band is the only boot geometry above the ankle, and the ratified +#: contract requires every trim vertex above 0.18 m. +BOOT_SHAFT_TOP_M = 0.215 +BOOT_TRIM_BOTTOM_M = 0.185 +#: How far the outsole may sit below the standing plane. The promoted bodies +#: touch y = 0 exactly, so a boot that encloses the sole has to go under it; 4 mm +#: is a sole, not a stilt. +BOOT_SOLE_FLOOR_M = -0.004 +#: A shell face pointing this far down is outsole, not upper. +SOLE_NORMAL_Y = -0.5 + +BOOT_MATERIALS = ("boots_canvas_ankle_c0", "boots_canvas_ankle_c1", + "boots_canvas_ankle_c3") +SUIT_MATERIAL = "PF2_Cloth" + + +def sha256_file(path: str) -> str: + with open(path, "rb") as handle: + return hashlib.sha256(handle.read()).hexdigest() + + +# ---------------------------------------------------------------- body surface + + +class BodySurface: + """A promoted body's skin, welded, with its zone labels and skin binding.""" + + def __init__(self, body_id: str) -> None: + self.body_id = body_id + glb = Glb.load(CFG.RUNTIME_BODY[body_id]) + self.glb = glb + positions, joints, weights, faces, zones = [], [], [], [], [] + base = 0 + for primitive in glb.json["meshes"][0]["primitives"]: + name = glb.json["materials"][primitive["material"]]["name"] + if not name.startswith(BZ.MATERIAL_PREFIX): + continue + zone = name[len(BZ.MATERIAL_PREFIX):] + attributes = primitive["attributes"] + position = glb.accessor(attributes["POSITION"]).astype(np.float64) + index = glb.accessor(primitive["indices"]).astype(np.int64).reshape(-1, 3) + positions.append(position) + joints.append(glb.accessor(attributes["JOINTS_0"]).astype(np.int64)) + weights.append(glb.accessor(attributes["WEIGHTS_0"]).astype(np.float64)) + faces.append(index + base) + zones.append(np.full(len(index), BZ.ZONE_INDEX[zone])) + base += len(position) + raw_position = np.concatenate(positions) + raw_joints = np.concatenate(joints) + raw_weights = np.concatenate(weights) + raw_faces = np.concatenate(faces) + self.face_zone = np.concatenate(zones) + + welded = BZ.weld(raw_position) + count = int(welded.max()) + 1 + self.faces = welded[raw_faces] + # Faceted/material seams may duplicate one surface point with slightly + # different valid bindings. Collapse those bindings by joint, retain the + # strongest four influences, and renormalise. Refusing the split loses + # the reviewed head/neck topology; taking the last duplicate creates a + # seam whose garment motion depends on primitive order. + self.position = np.zeros((count, 3)) + self.position[welded] = raw_position + if not np.allclose(self.position[welded], raw_position, atol=1e-6): + raise SystemExit(f"{body_id}: welded positions disagree") + + influence_slots = raw_joints.shape[1] + joint_count = len(glb.json["skins"][0]["joints"]) + blended = np.zeros((count, joint_count), dtype=np.float64) + np.add.at( + blended, + (np.repeat(welded, influence_slots), raw_joints.reshape(-1)), + raw_weights.reshape(-1), + ) + strongest = np.argpartition(blended, -influence_slots, axis=1)[:, -influence_slots:] + strongest_weights = np.take_along_axis(blended, strongest, axis=1) + descending = np.argsort(-strongest_weights, axis=1) + self.joints = np.take_along_axis(strongest, descending, axis=1) + self.weights = np.take_along_axis(strongest_weights, descending, axis=1) + totals = self.weights.sum(axis=1, keepdims=True) + if np.any(totals <= 1e-12): + raise SystemExit(f"{body_id}: welded skin binding has zero total weight") + self.weights /= totals + self.normal = self._normals(self.position, self.faces, count) + + @staticmethod + def _normals(position: np.ndarray, faces: np.ndarray, count: int) -> np.ndarray: + a, b, c = position[faces[:, 0]], position[faces[:, 1]], position[faces[:, 2]] + accumulated = np.zeros((count, 3)) + face_normal = np.cross(b - a, c - a) + for corner in range(3): + np.add.at(accumulated, faces[:, corner], face_normal) + length = np.linalg.norm(accumulated, axis=1, keepdims=True) + return accumulated / np.where(length > 1e-20, length, 1.0) + + def zone_faces(self, zones: tuple[str, ...]) -> np.ndarray: + wanted = np.array([BZ.ZONE_INDEX[zone] for zone in zones]) + return self.faces[np.isin(self.face_zone, wanted)] + + +# ------------------------------------------------------------------- shell mesh + + +class Shell: + """A growable triangle soup carrying position, normal and skin binding.""" + + def __init__(self, surface: BodySurface, faces: np.ndarray) -> None: + used = np.unique(faces) + self.local = {int(vertex): slot for slot, vertex in enumerate(used)} + self.position = list(surface.position[used]) + self.normal = list(surface.normal[used]) + self.joints = list(surface.joints[used]) + self.weights = list(surface.weights[used]) + self.faces = [[self.local[int(vertex)] for vertex in face] for face in faces] + + # --------------------------------------------------------------- geometry + + def add_vertex(self, position, normal, joints, weights) -> int: + self.position.append(np.asarray(position, dtype=np.float64)) + self.normal.append(np.asarray(normal, dtype=np.float64)) + self.joints.append(np.asarray(joints, dtype=np.int64)) + self.weights.append(np.asarray(weights, dtype=np.float64)) + return len(self.position) - 1 + + def interpolate(self, a: int, b: int, t: float) -> int: + return self.add_vertex( + self.position[a] * (1.0 - t) + self.position[b] * t, + self.normal[a] * (1.0 - t) + self.normal[b] * t, + self.joints[a] if t < 0.5 else self.joints[b], + self.weights[a] * (1.0 - t) + self.weights[b] * t + if np.array_equal(self.joints[a], self.joints[b]) + else (self.weights[a] if t < 0.5 else self.weights[b])) + + def _cut(self, height: float, keep_above: bool) -> None: + """Cut every triangle on the plane y = height. + + `keep_above` false discards what is above the plane (a shaft top); + true keeps both halves, which just inserts a ring. A per-face keep/drop + test would leave the boot collar as a stair of whole facets; cutting on + the plane puts a real edge loop there. + """ + value = np.array([point[1] for point in self.position]) - height + kept: list[list[int]] = [] + cache: dict[tuple[int, int], int] = {} + + def crossing(a: int, b: int) -> int: + key = (a, b) if a < b else (b, a) + if key not in cache: + first, second = key + t = float(value[first] / (value[first] - value[second])) + cache[key] = self.interpolate(first, second, t) + return cache[key] + + def fan(polygon: list[int]) -> None: + for slot in range(1, len(polygon) - 1): + kept.append([polygon[0], polygon[slot], polygon[slot + 1]]) + + for face in self.faces: + below = [value[vertex] <= 0.0 for vertex in face] + if all(below): + kept.append(face) + continue + if not any(below): + if keep_above: + kept.append(face) + continue + lower: list[int] = [] + upper: list[int] = [] + for slot in range(3): + current, following = face[slot], face[(slot + 1) % 3] + (lower if below[slot] else upper).append(current) + if below[slot] != below[(slot + 1) % 3]: + split = crossing(current, following) + lower.append(split) + upper.append(split) + fan(lower) + if keep_above: + fan(upper) + self.faces = kept + self.compact() + + def clip_below(self, height: float) -> None: + """Discard everything above `height`.""" + self._cut(height, keep_above=False) + + def split_at(self, height: float) -> None: + """Insert an edge loop at `height`, keeping both sides.""" + self._cut(height, keep_above=True) + + def compact(self) -> None: + used = sorted({vertex for face in self.faces for vertex in face}) + remap = {vertex: slot for slot, vertex in enumerate(used)} + self.position = [self.position[vertex] for vertex in used] + self.normal = [self.normal[vertex] for vertex in used] + self.joints = [self.joints[vertex] for vertex in used] + self.weights = [self.weights[vertex] for vertex in used] + self.faces = [[remap[vertex] for vertex in face] for face in self.faces] + + def boundary(self) -> list[tuple[int, int, int]]: + """Directed edges used once, with the face that owns each.""" + counts: dict[tuple[int, int], int] = {} + for face in self.faces: + for slot in range(3): + key = tuple(sorted((face[slot], face[(slot + 1) % 3]))) + counts[key] = counts.get(key, 0) + 1 + out = [] + for index, face in enumerate(self.faces): + for slot in range(3): + a, b = face[slot], face[(slot + 1) % 3] + if counts[tuple(sorted((a, b)))] == 1: + out.append((a, b, index)) + return out + + def scaled_offset(self, amount: float) -> np.ndarray: + """Per-vertex standoff, thickened where the skin weights are split.""" + dominant = np.array([float(np.max(weights)) for weights in self.weights]) + return amount * (1.0 + JOINT_COMPENSATION * (1.0 - dominant)) + + def offset(self, amount: float, floor: Callable[[np.ndarray], np.ndarray] | None + ) -> None: + self.standoff = self.scaled_offset(amount) + for slot, (point, normal) in enumerate(zip(self.position, self.normal)): + moved = point + self.standoff[slot] * normal + self.position[slot] = moved if floor is None else floor(moved) + + def close_rim(self, inner_offset: float, + floor: Callable[[np.ndarray], np.ndarray] | None) -> list[int]: + """Turn every opening inward, so a cuff has thickness instead of an edge. + + `inner_offset` is where the rim lands, measured from the SKIN outward. The suit + rims onto the skin (0). The boot rims onto the SUIT (one suit standoff + plus a hair), because it is worn over it: a boot collar that returned to + the skin would dive straight through the suit it is supposed to cover, and + measured 221 crossing triangle pairs when it did. + + Each rim quad is wound to face away from the face that owns its edge, so + the rim reads correctly under the runtime's front-side-only materials. + """ + edges = self.boundary() + inner: dict[int, int] = {} + added: list[int] = [] + for a, b, owner in edges: + for vertex in (a, b): + if vertex not in inner: + drop = max(0.0, float(self.standoff[vertex]) - inner_offset) + point = self.position[vertex] - drop * self.normal[vertex] + if floor is not None: + point = floor(point) + inner[vertex] = self.add_vertex(point, -self.normal[vertex], + self.joints[vertex], + self.weights[vertex]) + centroid = np.mean([self.position[v] for v in self.faces[owner]], axis=0) + middle = 0.5 * (self.position[a] + self.position[b]) + away = middle - centroid + for triangle in ([a, b, inner[b]], [a, inner[b], inner[a]]): + first, second, third = (self.position[triangle[0]], + self.position[triangle[1]], + self.position[triangle[2]]) + normal = np.cross(second - first, third - first) + if float(np.dot(normal, away)) < 0.0: + triangle = [triangle[0], triangle[2], triangle[1]] + self.faces.append(triangle) + added.append(len(self.faces) - 1) + return added + + def recompute_normals(self) -> None: + position = np.asarray(self.position) + faces = np.asarray(self.faces) + a, b, c = position[faces[:, 0]], position[faces[:, 1]], position[faces[:, 2]] + accumulated = np.zeros_like(position) + face_normal = np.cross(b - a, c - a) + for corner in range(3): + np.add.at(accumulated, faces[:, corner], face_normal) + length = np.linalg.norm(accumulated, axis=1, keepdims=True) + self.normal = list(accumulated / np.where(length > 1e-20, length, 1.0)) + + # ----------------------------------------------------------------- arrays + + def arrays(self, faces: list[list[int]] | None = None): + chosen = self.faces if faces is None else faces + index = np.asarray(chosen, dtype=np.int64) + used, rewritten = np.unique(index, return_inverse=True) + return (np.asarray(self.position)[used].astype(np.float32), + np.asarray(self.normal)[used].astype(np.float32), + np.asarray(self.joints)[used].astype(np.uint8), + np.asarray(self.weights)[used].astype(np.float32), + rewritten.reshape(-1).astype(np.uint16)) + + def face_normals(self) -> np.ndarray: + position = np.asarray(self.position) + faces = np.asarray(self.faces) + a, b, c = position[faces[:, 0]], position[faces[:, 1]], position[faces[:, 2]] + normal = np.cross(b - a, c - a) + length = np.linalg.norm(normal, axis=1, keepdims=True) + return normal / np.where(length > 1e-20, length, 1.0) + + def face_min_y(self) -> np.ndarray: + position = np.asarray(self.position) + return position[np.asarray(self.faces), 1].min(axis=1) + + +# ------------------------------------------------------------------- GLB write + + +def write_garment(body: BodySurface, groups: list[dict], materials: list[str], + destination: str) -> int: + """One skinned GLB: the promoted body's rig, the authored shells' meshes.""" + source = body.glb + gltf: dict[str, Any] = { + "asset": {"version": "2.0", "generator": GENERATOR}, + "scene": 0, + "scenes": json.loads(json.dumps(source.json["scenes"])), + "nodes": json.loads(json.dumps(source.json["nodes"])), + "skins": json.loads(json.dumps(source.json["skins"])), + } + builder = GlbBuilder(gltf) + inverse_bind = source.accessor(source.json["skins"][0]["inverseBindMatrices"]) + gltf["skins"][0]["inverseBindMatrices"] = builder.add_accessor( + inverse_bind.astype(np.float32)) + builder.json["materials"] = [ + {"name": name, "doubleSided": True, + "pbrMetallicRoughness": {"baseColorFactor": [0.8, 0.8, 0.8, 1.0], + "metallicFactor": 0.0, "roughnessFactor": 0.7}} + for name in materials] + slot = {name: index for index, name in enumerate(materials)} + + meshes = [] + for group in groups: + primitives = [] + for material, faces in group["primitives"]: + position, normal, joints, weights, indices = group["shell"].arrays(faces) + primitives.append({ + "attributes": { + "POSITION": builder.add_accessor(position, target=TARGET_ARRAY_BUFFER, + minmax=True), + "NORMAL": builder.add_accessor(normal, target=TARGET_ARRAY_BUFFER), + "JOINTS_0": builder.add_accessor(joints, target=TARGET_ARRAY_BUFFER), + "WEIGHTS_0": builder.add_accessor(weights, target=TARGET_ARRAY_BUFFER), + }, + "indices": builder.add_accessor(indices, + target=TARGET_ELEMENT_ARRAY_BUFFER), + "material": slot[material], + }) + meshes.append({"name": group["name"], "primitives": primitives}) + builder.json["meshes"] = meshes + # Every mesh hangs off the node the body's own mesh used, so the garment + # inherits the skinned-mesh node's place in the scene graph. + mesh_nodes = [index for index, node in enumerate(gltf["nodes"]) if "mesh" in node] + if len(mesh_nodes) != 1: + raise SystemExit(f"{body.body_id}: expected one skinned mesh node, " + f"found {mesh_nodes}") + template = gltf["nodes"][mesh_nodes[0]] + scene_nodes = gltf["scenes"][0].setdefault("nodes", []) + for extra in range(1, len(meshes)): + clone = {key: value for key, value in template.items() if key != "name"} + clone["name"] = f"{meshes[extra]['name']}_node" + clone["mesh"] = extra + gltf["nodes"].append(clone) + scene_nodes.append(len(gltf["nodes"]) - 1) + template["mesh"] = 0 + template["name"] = f"{meshes[0]['name']}_node" + os.makedirs(os.path.dirname(destination), exist_ok=True) + return builder.write(destination) + + +# ----------------------------------------------------------------------- suit + + +def sole_floor(limit: float) -> Callable[[np.ndarray], np.ndarray]: + def clamp(point: np.ndarray) -> np.ndarray: + if point[1] < limit: + point = point.copy() + point[1] = limit + return point + return clamp + + +def build_suit(body: BodySurface, destination: str) -> dict: + shell = Shell(body, body.zone_faces(SUIT_ZONES)) + floor = sole_floor(0.0) + shell.offset(SUIT_OFFSET_M, floor) + shell.close_rim(0.0, floor) + shell.recompute_normals() + bytes_written = write_garment( + body, [{"name": "bodysuit", "shell": shell, + "primitives": [(SUIT_MATERIAL, None)]}], + [SUIT_MATERIAL], destination) + position = np.asarray(shell.position) + return {"path": destination, "bytes": bytes_written, + "sha256": sha256_file(destination), + "vertices": len(shell.position), "triangles": len(shell.faces), + "offset_mm": SUIT_OFFSET_M * 1000.0, + "zones": list(SUIT_ZONES), + "bbox_min": [round(float(v), 5) for v in position.min(axis=0)], + "bbox_max": [round(float(v), 5) for v in position.max(axis=0)]} + + +# ---------------------------------------------------------------------- boots + + +def build_boots(body: BodySurface, destination: str) -> dict: + floor = sole_floor(BOOT_SOLE_FLOOR_M) + groups = [] + stats = [] + for side in ("left", "right"): + faces = np.concatenate([body.zone_faces((f"{side}_foot",)), + body.zone_faces((f"{side}_calf",))]) + shell = Shell(body, faces) + shell.clip_below(BOOT_SHAFT_TOP_M) + # A second cut gives the trim band a ring of its own instead of a stair + # of whole calf facets. + shell.split_at(BOOT_TRIM_BOTTOM_M) + shell.offset(BOOT_OFFSET_M, floor) + rim = shell.close_rim(BOOT_RIM_ON_SUIT_M, floor) + shell.recompute_normals() + + normals = shell.face_normals() + min_y = shell.face_min_y() + trim = [index for index in range(len(shell.faces)) + if min_y[index] >= BOOT_TRIM_BOTTOM_M - 1.0e-6] + sole = [index for index in range(len(shell.faces)) + if index not in set(trim) and normals[index][1] <= SOLE_NORMAL_Y] + upper = [index for index in range(len(shell.faces)) + if index not in set(trim) and index not in set(sole)] + mesh = f"boots_canvas_ankle_{side[0]}" + groups.append({"name": mesh, "shell": shell, "primitives": [ + (BOOT_MATERIALS[0], [shell.faces[i] for i in trim]), + (BOOT_MATERIALS[1], [shell.faces[i] for i in upper]), + (BOOT_MATERIALS[2], [shell.faces[i] for i in sole]), + ]}) + position = np.asarray(shell.position) + stats.append({"mesh": mesh, "vertices": len(shell.position), + "triangles": len(shell.faces), "rim_triangles": len(rim), + "trim_triangles": len(trim), "sole_triangles": len(sole), + "upper_triangles": len(upper), + "bbox_min": [round(float(v), 5) for v in position.min(axis=0)], + "bbox_max": [round(float(v), 5) for v in position.max(axis=0)]}) + bytes_written = write_garment(body, groups, list(BOOT_MATERIALS), destination) + return {"path": destination, "bytes": bytes_written, + "sha256": sha256_file(destination), + "offset_mm": BOOT_OFFSET_M * 1000.0, + "shaft_top_m": BOOT_SHAFT_TOP_M, + "trim_bottom_m": BOOT_TRIM_BOTTOM_M, + "sole_floor_m": BOOT_SOLE_FLOOR_M, + "sides": stats} + + +def main() -> None: + CFG.ensure_dirs() + report: dict[str, Any] = {"generator": GENERATOR, "bodies": {}} + for body_id in ("male", "female"): + body = BodySurface(body_id) + prefix = "" if body_id == "male" else f"{FEMALE_PREFIX}/" + entry = { + "body": os.path.relpath(CFG.RUNTIME_BODY[body_id], CFG.REPO), + "body_sha256": sha256_file(CFG.RUNTIME_BODY[body_id]), + "under_bodysuit": build_suit( + body, os.path.join(CFG.EQUIPMENT, + f"{prefix}Under/under_bodysuit.glb")), + "boots_canvas_ankle": build_boots( + body, os.path.join(CFG.EQUIPMENT, + f"{prefix}Under/boots_canvas_ankle.glb")), + } + for name in ("under_bodysuit", "boots_canvas_ankle"): + entry[name]["path"] = os.path.relpath(entry[name]["path"], CFG.REPO) + report["bodies"][body_id] = entry + print(f"[starter] {body_id}: suit {entry['under_bodysuit']['triangles']} tris, " + f"boots {sum(s['triangles'] for s in entry['boots_canvas_ankle']['sides'])}" + f" tris -> {entry['under_bodysuit']['path']}") + destination = os.path.join(CFG.REPORT_DIR, "starter_apparel.json") + with open(destination, "w", encoding="utf-8") as handle: + json.dump(report, handle, indent=2) + print(f"[starter] -> {os.path.relpath(destination, CFG.REPO)}") + + +if __name__ == "__main__": + main() diff --git a/content-pipeline/labs/humanoid-runtime-refit/bake_body_shading.py b/content-pipeline/labs/humanoid-runtime-refit/bake_body_shading.py new file mode 100644 index 00000000..7e52bf56 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/bake_body_shading.py @@ -0,0 +1,200 @@ +"""Bake the runtime bodies' stylized skin shading as per-vertex occlusion. + +The world renderer is unlit (`MeshMatcapMaterial`), so nothing in a shipped +frame produces contact shading on its own: without a baked term a faceted +low-poly body reads as a flat silhouette. This step bakes ONE gentle ambient +occlusion pass and remaps it into `[AO_FLOOR, 1]`. That is the only tonal +variation the skin carries -- no pores, no procedural noise, no painted detail. + +It is baked to VERTEX COLOURS rather than a texture on purpose. The approved +heads carry an authored UV set whose head island folds front onto back (measured +at 278k conflicting texels on a 512 map), so a UV-space bake would smear the +jawline onto the crown. Vertices are the honest domain here: 13k of them across +a 5.8k-triangle body is finer than any map the UV layout could support, glTF +`COLOR_0` needs no unwrap, and both `MeshStandardMaterial` and the runtime's +`MeshMatcapMaterial` multiply it into base colour. + +Occlusion is evaluated on WELDED positions with area-weighted normals, so the +split shading vertices of one corner all receive the same value and the result +is a smooth occlusion field under the matcap's facet shading. + + blender --background --python bake_body_shading.py +""" + +from __future__ import annotations + +import hashlib +import json +import os +import sys + +import numpy as np +from mathutils.bvhtree import BVHTree + +LAB_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, LAB_DIR) + +import body_zones as BZ # noqa: E402 +import reference_bodies # noqa: E402 +import refit_config as CFG # noqa: E402 +from gltf_io import Glb # noqa: E402 + +RAY_COUNT = 64 +WELD_DECIMALS = 6 + + +def merged_mesh(glb: Glb) -> tuple[np.ndarray, np.ndarray, np.ndarray, list[int]]: + """Concatenate every primitive into one (V, N, F) soup. + + `offsets` has one entry per primitive plus the soup total, so a primitive's + vertex range is `offsets[slot]:offsets[slot + 1]`. + """ + positions, normals, faces, offsets = [], [], [], [] + base = 0 + for primitive in glb.json["meshes"][0]["primitives"]: + vertex = glb.accessor(primitive["attributes"]["POSITION"]).astype(np.float64) + normal = glb.accessor(primitive["attributes"]["NORMAL"]).astype(np.float64) + index = glb.accessor(primitive["indices"]).astype(np.int64).reshape(-1, 3) + positions.append(vertex) + normals.append(normal) + faces.append(index + base) + offsets.append(base) + base += len(vertex) + offsets.append(base) + return (np.concatenate(positions), np.concatenate(normals), + np.concatenate(faces), offsets) + + +def hemisphere(count: int) -> np.ndarray: + """Deterministic cosine-weighted hemisphere directions around +Z.""" + index = np.arange(count, dtype=np.float64) + 0.5 + # Fibonacci lattice: fixed, seedless, identical on every run and machine. + radial = np.sqrt(index / count) + theta = np.pi * (1.0 + 5.0 ** 0.5) * index + x = radial * np.cos(theta) + y = radial * np.sin(theta) + z = np.sqrt(np.maximum(0.0, 1.0 - radial * radial)) + return np.stack([x, y, z], axis=1) + + +def frames(normals: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Branchless orthonormal tangent frames (Duff et al.).""" + sign = np.where(normals[:, 2] >= 0.0, 1.0, -1.0) + a = -1.0 / (sign + normals[:, 2]) + b = normals[:, 0] * normals[:, 1] * a + tangent = np.stack([1.0 + sign * normals[:, 0] ** 2 * a, + sign * b, + -sign * normals[:, 0]], axis=1) + bitangent = np.stack([b, + sign + normals[:, 1] ** 2 * a, + -normals[:, 1]], axis=1) + return tangent, bitangent + + +def occlusion(vertices: np.ndarray, faces: np.ndarray, + welded: np.ndarray, welded_normals: np.ndarray) -> np.ndarray: + tree = BVHTree.FromPolygons([tuple(v) for v in vertices], + [tuple(f) for f in faces], + all_triangles=True, epsilon=0.0) + directions = hemisphere(RAY_COUNT) + tangent, bitangent = frames(welded_normals) + origins = welded + welded_normals * 2.0e-4 + hits = np.zeros(len(welded), dtype=np.float64) + for ray in directions: + world = (tangent * ray[0] + bitangent * ray[1] + welded_normals * ray[2]) + for index in range(len(welded)): + location, _, _, _ = tree.ray_cast(tuple(origins[index]), + tuple(world[index]), + CFG.AO_DISTANCE) + if location is not None: + hits[index] += 1.0 + return 1.0 - hits / RAY_COUNT + + +def relax(values: np.ndarray, faces: np.ndarray, inverse: np.ndarray, + count: int) -> np.ndarray: + """Laplacian smoothing over the welded surface graph. + + Occlusion is sampled per welded point, and a low-poly neck or armpit can + step from open to fully closed across a single edge loop. Unsmoothed that + renders as a hard band; a few relaxation passes turn it back into the + gradient a contact shadow actually is. + """ + welded_faces = inverse[faces] + a = np.concatenate([welded_faces[:, 0], welded_faces[:, 1], welded_faces[:, 2]]) + b = np.concatenate([welded_faces[:, 1], welded_faces[:, 2], welded_faces[:, 0]]) + edges_a = np.concatenate([a, b]) + edges_b = np.concatenate([b, a]) + degree = np.bincount(edges_a, minlength=count).astype(np.float64) + degree[degree == 0] = 1.0 + out = values.copy() + for _ in range(CFG.AO_SMOOTH_PASSES): + total = np.zeros(count) + np.add.at(total, edges_a, out[edges_b]) + out = (1.0 - CFG.AO_SMOOTH_WEIGHT) * out + CFG.AO_SMOOTH_WEIGHT * (total / degree) + return out + + +def bake(body_id: str, source: str) -> dict: + glb = Glb.load(source) + vertices, normals, faces, offsets = merged_mesh(glb) + + keys = np.round(vertices, WELD_DECIMALS) + _, first, inverse = np.unique(keys, axis=0, return_index=True, return_inverse=True) + inverse = inverse.reshape(-1) + welded = np.zeros((len(first), 3)) + welded_normals = np.zeros((len(first), 3)) + np.add.at(welded, inverse, vertices) + np.add.at(welded_normals, inverse, normals) + counts = np.bincount(inverse, minlength=len(first)).astype(np.float64) + welded /= counts[:, None] + lengths = np.linalg.norm(welded_normals, axis=1, keepdims=True) + welded_normals /= np.where(lengths > 1e-12, lengths, 1.0) + + raw = occlusion(vertices, faces, welded, welded_normals) + raw = relax(raw, faces, inverse, len(welded)) + shade = CFG.AO_FLOOR + (1.0 - CFG.AO_FLOOR) * np.clip(raw, 0.0, 1.0) ** CFG.AO_GAMMA + per_vertex = shade[inverse] + + # The face panel carries the composited face: its border is invisible only + # while the panel background matches the head tone exactly, so the panel + # stays unshaded. It is found by material rather than by slot -- a body that + # reorders its primitives must not silently shade the face. + materials = [glb.json["materials"][primitive["material"]]["name"] + for primitive in glb.json["meshes"][0]["primitives"]] + if materials.count(BZ.FACE_MATERIAL) != 1: + raise SystemExit(f"{body_id}: expected exactly one {BZ.FACE_MATERIAL} " + f"primitive, found {materials}") + panel = materials.index(BZ.FACE_MATERIAL) + per_vertex[offsets[panel]: offsets[panel + 1]] = 1.0 + + path = os.path.join(CFG.BUILD_DIR, f"{body_id}_vertex_ao.npy") + np.save(path, per_vertex.astype(np.float32)) + return { + "npy": os.path.relpath(path, CFG.REPO), + "sha256": hashlib.sha256(open(path, "rb").read()).hexdigest(), + "vertices": int(len(per_vertex)), + "welded": int(len(welded)), + "rays": RAY_COUNT, + "distance_m": CFG.AO_DISTANCE, + "floor": CFG.AO_FLOOR, + "gamma": CFG.AO_GAMMA, + "min": float(per_vertex.min()), + "mean": float(per_vertex.mean()), + } + + +def main() -> None: + CFG.ensure_dirs() + report = {} + for body_id, source in reference_bodies.promotion_inputs().items(): + report[body_id] = bake(body_id, source) + entry = report[body_id] + print(f"[shading] {body_id}: {entry['welded']} welded points, " + f"min {entry['min']:.3f}, mean {entry['mean']:.3f} -> {entry['npy']}") + with open(os.path.join(CFG.REPORT_DIR, "body_shading.json"), "w", encoding="utf-8") as handle: + json.dump(report, handle, indent=2) + + +if __name__ == "__main__": + main() diff --git a/content-pipeline/labs/humanoid-runtime-refit/bake_face_texture.py b/content-pipeline/labs/humanoid-runtime-refit/bake_face_texture.py new file mode 100644 index 00000000..0abcde34 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/bake_face_texture.py @@ -0,0 +1,81 @@ +"""Bake the default transparent face-component overlay. + +The refined heads reserve `RB_Face`: 56 triangles filling a matching hole in +the head shell. Promotion emits that panel first as ordinary opaque head skin, +then duplicates it 1.5 mm forward for this RGBA texture. Only eyes, brows, nose, +and mouth survive the compositor's background key; transparent pixels reveal +the skin panel instead of painting a second, darker skin rectangle over it. + +Compositing stays inside `client-3d/src/assets/faceKit/face-kit.js`; this driver +only decodes the canonical atlases, invokes the compositor, and re-encodes its +straight-alpha output. + + python3 bake_face_texture.py +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile + +from PIL import Image + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import refit_config as CFG # noqa: E402 + + +def compose(config: dict, size: int, transparent: bool = False) -> Image.Image: + """Run the canonical compositor and return the RGBA result.""" + with tempfile.TemporaryDirectory() as scratch: + assets = {} + for key, filename in CFG.FACE_ATLASES.items(): + image = Image.open(os.path.join(CFG.FACE_KIT_ASSETS, filename)).convert("RGBA") + raw = os.path.join(scratch, f"{key}.rgba") + with open(raw, "wb") as handle: + handle.write(image.tobytes()) + assets[key] = {"path": raw, "width": image.width, "height": image.height} + out = os.path.join(scratch, "face.rgba") + job = os.path.join(scratch, "job.json") + with open(job, "w", encoding="utf-8") as handle: + json.dump({"assets": assets, "config": config, "size": size, + "transparent": transparent, "out": out}, handle) + subprocess.run(["node", os.path.join(CFG.LAB_DIR, "compose_face.mjs"), job], + check=True, cwd=CFG.LAB_DIR, stdout=subprocess.DEVNULL) + with open(out, "rb") as handle: + return Image.frombytes("RGBA", (size, size), handle.read()) + + +def panel_texture(config: dict, span: float, size: int) -> Image.Image: + """Place a background-erased square composite at the top of the panel UV.""" + face = compose(config, size, transparent=True) + height = int(round(size / max(1e-3, min(1.0, span)))) + height += height % 2 + panel = Image.new("RGBA", (size, height), (0, 0, 0, 0)) + panel.paste(face, (0, 0)) + + alpha = panel.getchannel("A") + corners = (alpha.getpixel((0, 0)), alpha.getpixel((size - 1, 0)), + alpha.getpixel((0, height - 1)), alpha.getpixel((size - 1, height - 1))) + if any(corners): + raise SystemExit(f"face compositor leaked background into panel corners: {corners}") + transparent_pixels = alpha.histogram()[0] + if transparent_pixels < size * height // 2: + raise SystemExit("face compositor retained a skin-coloured background") + return panel + + +def main() -> None: + CFG.ensure_dirs() + written = {} + path = os.path.join(CFG.TEXTURE_DIR, "face_default.png") + panel_texture(CFG.DEFAULT_FACE, CFG.FACE_PANEL_V_SPAN, + CFG.FACE_TEXTURE_SIZE).save(path, optimize=True) + written["face_default.png"] = os.path.getsize(path) + print(json.dumps(written, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/content-pipeline/labs/humanoid-runtime-refit/body_zone_coverage.py b/content-pipeline/labs/humanoid-runtime-refit/body_zone_coverage.py new file mode 100644 index 00000000..2475647d --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/body_zone_coverage.py @@ -0,0 +1,346 @@ +"""Measure what each shipped apparel item encloses, and write the manifest masks. + +Hiding skin under a garment is only safe where the garment really is over that +skin. Deciding that from an item's group name would hide a tank top's chest and +punch a hole through its own neckline, so it is measured instead -- on both +promoted bodies, in the bind pose and in every pose of `pose_probe.POSES`, with +the garment skinned by the same joint matrices as the body. + +Per skin sample (barycentric points over every `BodyZone_*` triangle, weighted by +area): + + * `find_nearest` inside `NEAR_LIMIT` first. Nothing further away can be covered + by either test below, and this prunes most of the body for a glove or a + helmet; + * an outward CONE of rays up to `REACH_OUT`. Fabric directly over the skin + blocks nearly the whole cone, while skin at a neckline, cuff, hem or armhole + keeps most of it -- so the cone is what separates "enclosed" from "exposed", + instead of one fragile ray; + * one INWARD ray up to `REACH_IN`. Skin that has pushed through the fabric is + outside it, so no outward ray finds anything -- yet that skin is exactly the + poke-through a mask exists to remove. The inward ray keeps a penetrating + sample counted as covered. + +`REACH_OUT` is deliberately short. A garment that floats centimetres off the skin +cannot produce poke-through, and hiding skin behind it would show the world +through the gap, so "loosely draped over" must NOT read as covered. + +A zone is hidden only when, on BOTH bodies and in EVERY pose, essentially all of +its area is enclosed (`COVER_FRACTION`) and the exposed remainder is too small to +read as skin (`EXPOSED_LIMIT_CM2`). + +The measurement is then promoted into `equipment/manifest.json` as each item's +`hideBodyZones`, together with `glbFemale` for the items that ship an authored +female variant. Both runtimes read exactly those two keys, so the masks are inert +until they are written; this step is what makes them live. Rewriting the manifest +is deterministic: keys keep their authored order, arrays are canonical-order, and +an item that encloses nothing has the key removed rather than set to `[]`. + + blender --background --python body_zone_coverage.py +""" + +from __future__ import annotations + +import hashlib +import json +import os +import sys + +import numpy as np +from mathutils.bvhtree import BVHTree + +LAB_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, LAB_DIR) + +import body_zones as BZ # noqa: E402 +import pose_probe as POSE # noqa: E402 +import refit_config as CFG # noqa: E402 + +GENERATOR = "successor content-pipeline/labs/humanoid-runtime-refit/body_zone_coverage.py" + +#: Nothing further than this from the garment can be covered by either test, so +#: it is the one cheap query that prunes the body down to the garment. +NEAR_LIMIT = 0.030 +#: How far above the skin fabric still counts as covering it. Snug cloth sits at +#: 1.5-2.5 mm and padded shells at 10-15 mm; 30 mm keeps those and rejects a +#: skirt or a coat tail hanging off the leg. +REACH_OUT = 0.030 +#: How far the fabric may be INSIDE the skin and still count. This is the +#: poke-through depth the masks are being authored to hide. +REACH_IN = 0.015 +#: Fabric this close is in CONTACT with the skin, and contact is coverage whatever +#: the rays say. Rays are unreliable exactly here: at a hard-folded ankle the +#: shell lands flush on the skin, so the outward cone starts on the surface it is +#: looking for and the inward ray has nothing ahead of it. Without this the boot +#: measured 0.977 of the foot enclosed in `meditate_loop` while sitting directly +#: on it. 4 mm is below any gap a viewer could see through. +CONTACT_M = 0.004 +#: Outward cone: 9 rays inside 35 degrees of the normal. Wide enough that a +#: garment edge fails, tight enough that snug fabric passes. +CONE_RAYS = 9 +CONE_HALF_ANGLE_DEG = 35.0 +#: 7 of 9. Two rays may legitimately escape where the surface curves away. +CONE_HIT_FRACTION = 7.0 / 9.0 + +#: Barycentric sample pattern per triangle: centroid, three near-corner and +#: three near-edge points. Fixed, so a re-run measures the same points. +SAMPLE_BARYCENTRIC = np.array([ + (1 / 3, 1 / 3, 1 / 3), + (0.6, 0.2, 0.2), (0.2, 0.6, 0.2), (0.2, 0.2, 0.6), + (0.1, 0.45, 0.45), (0.45, 0.1, 0.45), (0.45, 0.45, 0.1), +]) + +#: A zone is hidden only if this fraction of its area is enclosed in the worst +#: pose on the worst body... +COVER_FRACTION = 0.99 +#: ...and the exposed remainder stays under this. 12 cm2 is a 3.5 cm square. The +#: residue on a genuinely enclosing garment is slivers along its own seams -- the +#: authored bodysuit measures 7 cm2 of torso and 9 cm2 of pelvis across every +#: pose -- while a garment that actually leaves skin out leaves it by hundreds of +#: cm2: a tank top's neckline and armholes alone expose most of the chest. So the +#: gap between "sliver" and "opening" is two orders of magnitude wide, and this +#: sits in the middle of it rather than on either edge. +EXPOSED_LIMIT_CM2 = 12.0 + +#: An item whose GLB carries no skinned mesh is never attached by the runtime's +#: skinned-equipment path (`attachEquipmentItemToBody` collects `SkinnedMesh` +#: only, and these carry no `rigidAnchorBone` to take the rigid route). It covers +#: nothing because it is not on the pawn at all. +UNATTACHED_REASON = ("no skinned mesh and no rigidAnchorBone: the runtime " + "attaches nothing, so it encloses no skin") + +#: Female variants live under this prefix and are named by `glbFemale`. Both +#: runtimes resolve the key relative to the equipment directory. +FEMALE_PREFIX = "Female" + + +def sha256_file(path: str) -> str: + with open(path, "rb") as handle: + return hashlib.sha256(handle.read()).hexdigest() + + +def cone_directions(normals: np.ndarray) -> np.ndarray: + """`CONE_RAYS` deterministic directions inside the cone around each normal.""" + index = np.arange(CONE_RAYS, dtype=np.float64) + 0.5 + limit = np.radians(CONE_HALF_ANGLE_DEG) + # Fibonacci lattice on the spherical cap: fixed, seedless, machine-stable. + cosine = 1.0 - (index / CONE_RAYS) * (1.0 - np.cos(limit)) + radius = np.sqrt(np.maximum(0.0, 1.0 - cosine ** 2)) + theta = np.pi * (1.0 + 5.0 ** 0.5) * index + local = np.stack([radius * np.cos(theta), radius * np.sin(theta), cosine], axis=1) + + sign = np.where(normals[:, 2] >= 0.0, 1.0, -1.0) + a = -1.0 / (sign + normals[:, 2]) + b = normals[:, 0] * normals[:, 1] * a + tangent = np.stack([1.0 + sign * normals[:, 0] ** 2 * a, sign * b, + -sign * normals[:, 0]], axis=1) + bitangent = np.stack([b, sign + normals[:, 1] ** 2 * a, -normals[:, 1]], axis=1) + return (tangent[:, None, :] * local[None, :, 0:1] + + bitangent[:, None, :] * local[None, :, 1:2] + + normals[:, None, :] * local[None, :, 2:3]) + + +def zone_samples(posed: np.ndarray, faces: np.ndarray): + """Barycentric sample points, their outward normals and their areas.""" + corners = posed[faces] + area = 0.5 * np.linalg.norm(np.cross(corners[:, 1] - corners[:, 0], + corners[:, 2] - corners[:, 0]), axis=1) + normal = POSE.outward_normals(posed, faces) + points = np.einsum("sb,fbc->fsc", SAMPLE_BARYCENTRIC, corners) + count = len(SAMPLE_BARYCENTRIC) + return (points.reshape(-1, 3), np.repeat(normal, count, axis=0), + np.repeat(area / count, count)) + + +def covered_mask(points: np.ndarray, normals: np.ndarray, + tree: BVHTree) -> np.ndarray: + """Which samples the garment encloses.""" + out = np.zeros(len(points), dtype=bool) + distance = np.fromiter( + (nearest[3] if (nearest := tree.find_nearest(tuple(point), NEAR_LIMIT))[0] + is not None else np.inf for point in points), + dtype=np.float64, count=len(points)) + out |= distance <= CONTACT_M + candidates = np.flatnonzero(np.isfinite(distance) & ~out) + if not len(candidates): + return out + cone = cone_directions(normals[candidates]) + needed = int(np.ceil(CONE_HIT_FRACTION * CONE_RAYS)) + for slot, sample in enumerate(candidates): + origin = tuple(points[sample]) + if tree.ray_cast(origin, tuple(-normals[sample]), REACH_IN)[0] is not None: + out[sample] = True # fabric is inside the skin + continue + hits = 0 + for ray in range(CONE_RAYS): + if tree.ray_cast(origin, tuple(cone[slot, ray]), REACH_OUT)[0] is not None: + hits += 1 + if hits >= needed: + out[sample] = True + break + return out + + +def probe(item: POSE.SkinnedGeometry, body: POSE.Body) -> dict: + """Per-zone covered fraction and exposed area, keeping the worst pose.""" + zones = {zone: {"covered_fraction": 1.0, "exposed_cm2": 0.0, "area_cm2": 0.0, + "worst_pose": None} for zone in BZ.ZONES} + for clip, phase in body.poses(): + world, skin = body.pose(clip, phase) + garment = item.posed(world) + tree = BVHTree.FromPolygons([tuple(point) for point in garment], + [tuple(face) for face in item.faces], + all_triangles=True, epsilon=0.0) + low = garment.min(axis=0) - NEAR_LIMIT + high = garment.max(axis=0) + NEAR_LIMIT + label = "bind" if clip is None else f"{clip}@{phase:g}" + for zone, faces in body.zone_faces.items(): + points, normals, areas = zone_samples(skin, faces) + total = float(areas.sum()) + inside = ((points >= low) & (points <= high)).all(axis=1) + covered = np.zeros(len(points), dtype=bool) + if inside.any(): + covered[inside] = covered_mask(points[inside], normals[inside], tree) + hidden_area = float(areas[covered].sum()) + fraction = hidden_area / total if total > 0.0 else 1.0 + record = zones[zone] + record["area_cm2"] = round(total * 1.0e4, 3) + if record["worst_pose"] is None or fraction < record["covered_fraction"]: + record.update(covered_fraction=round(fraction, 5), + exposed_cm2=round((total - hidden_area) * 1.0e4, 3), + worst_pose=label) + return zones + + +def hidden_zones(measurement: dict[str, dict]) -> list[str]: + """Zones enclosed on every body in every pose, by the thresholds above.""" + return [zone for zone in BZ.ZONES + if all(body[zone]["covered_fraction"] >= COVER_FRACTION + and body[zone]["exposed_cm2"] <= EXPOSED_LIMIT_CM2 + for body in measurement.values())] + + +def female_variant(entry: dict) -> str | None: + """The authored female GLB for an item, when one is on disk.""" + relative = f"{FEMALE_PREFIX}/{entry['glb']}" + return relative if os.path.exists( + os.path.normpath(os.path.join(CFG.EQUIPMENT, relative))) else None + + +def promote_manifest(manifest: dict, + decisions: dict[str, dict]) -> tuple[dict, list[str]]: + """Write `hideBodyZones` and `glbFemale` back onto the authored manifest. + + Authored key order is preserved and the two managed keys are appended in a + fixed place, so a re-run with the same measurement is a byte-identical file. + An item that encloses nothing loses the key entirely: both runtimes treat + absent and empty the same way, and an empty array only invites the reader to + wonder which it meant. + + Both managed keys are dropped before being rewritten, so the result depends + only on the measurement and never on what the manifest happened to say when + the run started -- two runs on the same assets are the same bytes. + """ + out = json.loads(json.dumps(manifest)) + managed = [] + for entry in out["items"]: + decision = decisions[entry["id"]] + entry.pop("hideBodyZones", None) + entry.pop("glbFemale", None) + variant = decision["glb_female"] + if variant: + # Beside `glb`, so a reader sees both meshes together. + rebuilt = {} + for key, value in entry.items(): + rebuilt[key] = value + if key == "glb": + rebuilt["glbFemale"] = variant + entry.clear() + entry.update(rebuilt) + if decision["hide_body_zones"]: + entry["hideBodyZones"] = list(decision["hide_body_zones"]) + if entry.get("hideBodyZones") or entry.get("glbFemale"): + managed.append(entry["id"]) + return out, managed + + +def main() -> None: + CFG.ensure_dirs() + manifest_path = os.path.join(CFG.EQUIPMENT, "manifest.json") + with open(manifest_path, encoding="utf-8") as handle: + manifest = json.load(handle) + bodies = {body_id: POSE.Body(body_id) for body_id in CFG.RUNTIME_BODY} + report = { + "generator": GENERATOR, + "policy": {"near_limit_m": NEAR_LIMIT, "reach_out_m": REACH_OUT, + "reach_in_m": REACH_IN, "cone_rays": CONE_RAYS, + "cone_half_angle_deg": CONE_HALF_ANGLE_DEG, + "cone_hit_fraction": round(CONE_HIT_FRACTION, 4), + "samples_per_triangle": len(SAMPLE_BARYCENTRIC), + "cover_fraction": COVER_FRACTION, + "exposed_limit_cm2": EXPOSED_LIMIT_CM2}, + "poses": [{"clip": clip, "phase": phase} for clip, phase in POSE.POSES], + "bodies": {body_id: {"path": os.path.relpath(CFG.RUNTIME_BODY[body_id], CFG.REPO), + "sha256": sha256_file(CFG.RUNTIME_BODY[body_id])} + for body_id in bodies}, + "items": {}, + } + for entry in manifest["items"]: + path = os.path.normpath(os.path.join(CFG.EQUIPMENT, entry["glb"])) + variant = female_variant(entry) + # The female body is probed against the FEMALE mesh when one ships. Using + # the male mesh for both would measure a garment the female pawn never + # wears, and would report the male fit as the female's coverage. + geometry = {"male": POSE.SkinnedGeometry(path, entry["id"])} + geometry["female"] = ( + POSE.SkinnedGeometry(os.path.normpath(os.path.join(CFG.EQUIPMENT, variant)), + f"{entry['id']}:female") + if variant else geometry["male"]) + record = {"glb": entry["glb"], "group": entry.get("group"), + "layer": entry.get("layer"), "slot": entry.get("slot"), + "sha256": sha256_file(path), + "glb_female": variant, + "glb_female_sha256": sha256_file( + os.path.normpath(os.path.join(CFG.EQUIPMENT, variant))) + if variant else None} + if not geometry["male"].skinned: + record.update(attached=False, reason=UNATTACHED_REASON, + hide_body_zones=[], measurement={}) + else: + measurement = {body_id: probe(geometry[body_id], body) + for body_id, body in bodies.items()} + record.update(attached=True, + garment_triangles=int(len(geometry["male"].faces)), + female_garment_triangles=int(len(geometry["female"].faces)), + measurement=measurement, + hide_body_zones=hidden_zones(measurement)) + report["items"][entry["id"]] = record + print(f"[coverage] {entry['id']:32s}" + f"{' F' if record['glb_female'] else ' '} -> " + f"{record['hide_body_zones']}") + + promoted, managed = promote_manifest(manifest, report["items"]) + with open(manifest_path, "w", encoding="utf-8") as handle: + json.dump(promoted, handle, indent=1, ensure_ascii=False) + handle.write("\n") + report["manifest"] = { + "path": os.path.relpath(manifest_path, CFG.REPO), + "sha256": sha256_file(manifest_path), + "managed_items": managed, + "items_with_coverage": sum(1 for item in report["items"].values() + if item["hide_body_zones"]), + "items_with_female_variant": sum(1 for item in report["items"].values() + if item["glb_female"]), + } + destination = os.path.join(CFG.REPORT_DIR, "body_zone_coverage.json") + with open(destination, "w", encoding="utf-8") as handle: + json.dump(report, handle, indent=1) + handle.write("\n") + print(f"[coverage] {len(report['items'])} items, " + f"{report['manifest']['items_with_coverage']} with coverage, " + f"{report['manifest']['items_with_female_variant']} with a female variant " + f"-> {report['manifest']['path']}") + + +if __name__ == "__main__": + main() diff --git a/content-pipeline/labs/humanoid-runtime-refit/body_zones.py b/content-pipeline/labs/humanoid-runtime-refit/body_zones.py new file mode 100644 index 00000000..2fd45664 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/body_zones.py @@ -0,0 +1,401 @@ +"""Split the humanoid skin into the canonical hidden-coverage body zones. + +Apparel is fitted to the skin with a millimetre of clearance, so any pose that +compresses a joint pushes skin through fabric. The runtime fix is the standard +one: an equipped item declares which body zones it encloses and the renderer +hides exactly those skin primitives. That needs the skin cut into primitives +named after the shared vocabulary in `client-3d/src/assets/pawnPack.ts` +(`PAWN_BODY_ZONES`), mirrored here as `ZONES` and re-checked against the +TypeScript source by `verify_body_zones.py`. + +The cut is derived from the rig, never authored. Every joint maps to exactly one +zone (`JOINT_ZONE`), so a vertex's zone share is its skin weight regrouped, and +the zones form a tree rooted at the pelvis (`ZONE_TREE`) that mirrors the +skeleton. One cut is then solved per tree edge: + + f(edge) = 2 * (share of everything past the edge) - 1 + +`f > 0` means "this triangle belongs past that joint". A triangle's zone is +found by walking down from the pelvis while some child edge is still positive. +That construction is what makes the result well formed rather than merely +plausible: + + * a seam sits exactly where two bones trade influence, which on this rig is + the joint -- the same place a garment's own seam sits (shoulder, elbow, + wrist, waist, hip, knee, ankle), so a hidden zone matches what the fabric + actually encloses; + * child subtrees are disjoint, so at most one child edge can exceed 0.5 of the + weight and the descent is unambiguous; + * `f` is monotonically non-increasing down the tree, so a triangle can never + be past the wrist without also being past the elbow. Zones therefore come + out as bands in skeleton order and a seam can only ever join anatomical + neighbours. `assert_adjacency` re-checks this on the finished labels; + * Laplacian relaxation over the welded dual graph is applied to every field + with the same non-negative stencil, so it straightens the single-triangle + teeth a raw threshold leaves along a low-poly seam without breaking that + ordering. + +Stray specks -- patches too small to read as skin, where one facet's weights +disagree with its neighbourhood -- are then folded into the neighbour that owns +most of their boundary. Zones are NOT forced to a single patch: these bodies are +assembled from disjoint chunks (every finger link is its own closed box, the +forearms are separate tubes) and the low-poly neck is a band whose front and +back strips only meet through faces the head or torso claims, so a zone +legitimately arrives in several pieces. What matters for hiding is that a seam +is always a joint, which the tree construction guarantees and the assertions +re-check. + +`RB_Face` is never part of this: it is the authored face panel, it carries the +only trustworthy UV island on the body, and no garment may hide it. +""" + +from __future__ import annotations + +import numpy as np + +#: Canonical skin-coverage vocabulary. Order matches `PAWN_BODY_ZONES`. +ZONES = ( + "torso", + "pelvis", + "neck", + "head", + "left_upper_arm", + "right_upper_arm", + "left_forearm", + "right_forearm", + "left_hand", + "right_hand", + "left_thigh", + "right_thigh", + "left_calf", + "right_calf", + "left_foot", + "right_foot", +) +ZONE_INDEX = {zone: index for index, zone in enumerate(ZONES)} + +#: Runtime material name for one zone's skin primitive. +MATERIAL_PREFIX = "BodyZone_" +#: The authored face panel. Not a zone, never hidden, never renamed. +FACE_MATERIAL = "RB_Face" + + +def material_name(zone: str) -> str: + return f"{MATERIAL_PREFIX}{zone}" + + +#: Rig side suffix -> zone side prefix. The rig is `_l`/`_r`; +x is the pawn's +#: left, which `assert_sides` re-checks against the finished labels. +SIDE = {"l": "left", "r": "right"} + +#: Every one of the 50 joints belongs to exactly one zone. +#: +#: `root` sits on the floor under the pelvis and carries no skin weight on +#: either body; it is mapped to `pelvis` so the table is total rather than +#: because it shades anything, and `segment` asserts its share stays at zero. +#: +#: The clavicle is TORSO, not arm. It drives the trapezius and the top of the +#: shoulder -- the surface a garment's shoulder seam runs over -- while the +#: deltoid cap is already `upperarm`. Putting it in the arm would make a +#: sleeveless top's hidden torso stop at the collarbone and leave a ridge of +#: skin standing above the neckline. +JOINT_ZONE: dict[str, str] = {"root": "pelvis", "pelvis": "pelvis", + "spine_01": "torso", "spine_03": "torso", + "neck_01": "neck", "head": "head"} +for _side, _zone_side in SIDE.items(): + JOINT_ZONE[f"clavicle_{_side}"] = "torso" + JOINT_ZONE[f"upperarm_{_side}"] = f"{_zone_side}_upper_arm" + JOINT_ZONE[f"lowerarm_{_side}"] = f"{_zone_side}_forearm" + JOINT_ZONE[f"hand_{_side}"] = f"{_zone_side}_hand" + for _finger in ("index", "middle", "ring", "pinky", "thumb"): + for _link in ("01", "02", "03"): + JOINT_ZONE[f"{_finger}_{_link}_{_side}"] = f"{_zone_side}_hand" + JOINT_ZONE[f"thigh_{_side}"] = f"{_zone_side}_thigh" + JOINT_ZONE[f"calf_{_side}"] = f"{_zone_side}_calf" + JOINT_ZONE[f"foot_{_side}"] = f"{_zone_side}_foot" + +#: The zone tree, rooted where the skeleton is: the pelvis. Child order only +#: affects report ordering, never the labels. +ZONE_ROOT = "pelvis" +ZONE_TREE: dict[str, tuple[str, ...]] = { + "pelvis": ("torso", "left_thigh", "right_thigh"), + "torso": ("neck", "left_upper_arm", "right_upper_arm"), + "neck": ("head",), + "head": (), +} +for _zone_side in SIDE.values(): + ZONE_TREE[f"{_zone_side}_upper_arm"] = (f"{_zone_side}_forearm",) + ZONE_TREE[f"{_zone_side}_forearm"] = (f"{_zone_side}_hand",) + ZONE_TREE[f"{_zone_side}_hand"] = () + ZONE_TREE[f"{_zone_side}_thigh"] = (f"{_zone_side}_calf",) + ZONE_TREE[f"{_zone_side}_calf"] = (f"{_zone_side}_foot",) + ZONE_TREE[f"{_zone_side}_foot"] = () + + +def _subtree(zone: str) -> tuple[str, ...]: + out = [zone] + for child in ZONE_TREE[zone]: + out.extend(_subtree(child)) + return tuple(out) + + +#: `zone -> the zone and everything further from the pelvis`. +SUBTREE = {zone: _subtree(zone) for zone in ZONES} +#: One cut per tree edge, parent-first so a descent can follow the list. +TREE_EDGES = tuple((parent, child) for parent in ZONES + for child in ZONE_TREE[parent]) +#: Which zones may share a seam: exactly the tree edges. +ADJACENCY: dict[str, frozenset[str]] = { + zone: frozenset([child for child in ZONE_TREE[zone]] + + [parent for parent, child in TREE_EDGES if child == zone]) + for zone in ZONES +} + +#: Position rounding used to weld shading-split vertices back into one surface. +#: The bodies are authored in metres, so this is a micrometre. +WELD_DECIMALS = 6 +#: Cut relaxation. Three passes at 0.5 straightens the teeth a raw threshold +#: leaves on a low-poly seam; the field is already monotonic across a joint, so +#: relaxation only re-decides the tied ring rather than moving the seam. +RELAX_PASSES = 3 +RELAX_WEIGHT = 0.5 +#: A same-zone patch smaller than this is a speck, not skin: 2 cm2 is a 1.4 cm +#: square on a body with roughly 1.8 m2 of surface. Anything at that scale is a +#: facet whose weights disagree with its neighbourhood, and shipping it as its +#: own primitive would make a stray triangle blink when its zone is hidden. +SPECK_AREA_M2 = 2.0e-4 + + +def zone_shares(joint_names: list[str], joints: np.ndarray, + weights: np.ndarray) -> np.ndarray: + """Per-vertex weight in each zone, from the skin binding alone.""" + out = np.zeros((len(joints), len(ZONES)), dtype=np.float64) + columns = np.array([ZONE_INDEX[JOINT_ZONE[name]] for name in joint_names]) + rows = np.arange(len(joints)) + for slot in range(joints.shape[1]): + np.add.at(out, (rows, columns[joints[:, slot]]), weights[:, slot]) + total = out.sum(axis=1, keepdims=True) + return out / np.where(total > 1e-12, total, 1.0) + + +def weld(positions: np.ndarray) -> np.ndarray: + """Map every vertex onto its welded surface point.""" + _, inverse = np.unique(np.round(positions, WELD_DECIMALS), axis=0, + return_inverse=True) + return inverse.reshape(-1) + + +def dual_edges(faces: np.ndarray, welded: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Face pairs sharing a welded edge, both directions.""" + corners = welded[faces] + edges = np.concatenate([corners[:, [0, 1]], corners[:, [1, 2]], + corners[:, [2, 0]]]) + owner = np.tile(np.arange(len(faces)), 3) + keys = np.sort(edges, axis=1) + order = np.lexsort((keys[:, 1], keys[:, 0])) + keys, owner = keys[order], owner[order] + same = np.flatnonzero((keys[:-1] == keys[1:]).all(axis=1)) + a, b = owner[same], owner[same + 1] + return np.concatenate([a, b]), np.concatenate([b, a]) + + +def relax(values: np.ndarray, edge_a: np.ndarray, edge_b: np.ndarray) -> np.ndarray: + """Laplacian smoothing over the dual graph, columnwise.""" + degree = np.bincount(edge_a, minlength=len(values)).astype(np.float64) + degree[degree == 0] = 1.0 + out = values.copy() + for _ in range(RELAX_PASSES): + total = np.zeros_like(out) + np.add.at(total, edge_a, out[edge_b]) + out = (1.0 - RELAX_WEIGHT) * out + RELAX_WEIGHT * (total / degree[:, None]) + return out + + +def triangle_areas(vertices: np.ndarray, faces: np.ndarray) -> np.ndarray: + a, b, c = vertices[faces[:, 0]], vertices[faces[:, 1]], vertices[faces[:, 2]] + return 0.5 * np.linalg.norm(np.cross(b - a, c - a), axis=1) + + +def descend(cuts: np.ndarray) -> np.ndarray: + """Zone index per face, from one relaxed cut field per tree edge. + + `cuts[:, i]` is edge `TREE_EDGES[i]`'s field. Walk down from the root while + some child edge is positive; the subtrees are disjoint so at most one can be. + """ + edge_column = {pair: index for index, pair in enumerate(TREE_EDGES)} + labels = np.full(len(cuts), ZONE_INDEX[ZONE_ROOT], dtype=np.int64) + frontier = [ZONE_ROOT] + while frontier: + zone = frontier.pop() + here = labels == ZONE_INDEX[zone] + if not here.any(): + continue + for child in ZONE_TREE[zone]: + past = here & (cuts[:, edge_column[(zone, child)]] > 0.0) + if past.any(): + labels[past] = ZONE_INDEX[child] + frontier.append(child) + return labels + + +def components(labels: np.ndarray, edge_a: np.ndarray, + edge_b: np.ndarray) -> np.ndarray: + """Root index of each face's connected same-zone patch. + + Min-label propagation with pointer jumping: every face starts as its own + root and repeatedly takes the smallest root among itself and its same-zone + neighbours until nothing moves. + """ + keep = labels[edge_a] == labels[edge_b] + a, b = edge_a[keep], edge_b[keep] + component = np.arange(len(labels)) + while True: + merged = component.copy() + np.minimum.at(merged, a, component[b]) + merged = np.minimum(merged, merged[merged]) + if np.array_equal(merged, component): + return component + component = merged + + +def allowed_pairs() -> np.ndarray: + """`allowed[a, b]` -- may zones `a` and `b` share a seam?""" + allowed = np.eye(len(ZONES), dtype=bool) + for zone, neighbours in ADJACENCY.items(): + for neighbour in neighbours: + allowed[ZONE_INDEX[zone], ZONE_INDEX[neighbour]] = True + return allowed + + +def absorb_specks(labels: np.ndarray, areas: np.ndarray, edge_a: np.ndarray, + edge_b: np.ndarray) -> tuple[np.ndarray, list[dict]]: + """Fold negligible patches into the neighbour that owns most of their edge. + + A zone is NOT forced to a single patch. These bodies are assembled from + disjoint chunks -- each finger link is its own closed box, the forearms are + separate tubes -- so a zone legitimately arrives in several pieces, and the + low-poly neck is a band whose front and back strips are only connected + through faces the head or the torso claims. Collapsing a zone to its largest + patch would delete most of that neck. + + What is worth removing is a speck: a patch too small to read as skin, which + would otherwise ship as a stray two-triangle primitive that pops when its + zone is hidden. A speck is only moved to an anatomical neighbour, and only + when that move leaves every seam it touches anatomical -- so absorption can + never invent a seam the skeleton does not have. + """ + labels = labels.copy() + absorbed: list[dict] = [] + allowed = allowed_pairs() + while True: + component = components(labels, edge_a, edge_b) + mass = np.zeros(len(labels)) + np.add.at(mass, component, areas) + changed = False + for root in np.unique(component): + if mass[root] > SPECK_AREA_M2: + continue + island = component == root + cross = island[edge_a] & ~island[edge_b] + if not cross.any(): + continue # a detached chunk, not a speck + zone = int(labels[root]) + outside = labels[edge_b[cross]] + shares = np.zeros(len(ZONES)) + np.add.at(shares, outside, areas[edge_b[cross]]) + for target in np.argsort(-shares): + target = int(target) + if shares[target] <= 0.0 or target == zone: + break + if allowed[zone, target] and allowed[target, outside].all(): + absorbed.append({"from": ZONES[zone], "to": ZONES[target], + "faces": int(island.sum()), + "area_cm2": round(float(mass[root]) * 1.0e4, 4)}) + labels[island] = target + changed = True + break + if not changed: + return labels, absorbed + + +def assert_adjacency(labels: np.ndarray, edge_a: np.ndarray, + edge_b: np.ndarray, body_id: str) -> list[list[str]]: + """Every seam has to be between anatomically neighbouring zones.""" + seams = sorted({tuple(sorted((ZONES[int(x)], ZONES[int(y)]))) + for x, y in zip(labels[edge_a], labels[edge_b]) if x != y}) + illegal = [pair for pair in seams if pair[1] not in ADJACENCY[pair[0]]] + if illegal: + raise SystemExit(f"{body_id}: non-anatomical zone seams {illegal}") + return [list(pair) for pair in seams] + + +def assert_sides(labels: np.ndarray, centroids: np.ndarray, body_id: str) -> None: + """`left_*` lives at +x, `right_*` at -x, with no crossing triangle.""" + for index, zone in enumerate(ZONES): + if not zone.startswith(("left_", "right_")): + continue + picked = labels == index + if not picked.any(): + continue + x = centroids[picked, 0] + wrong = int((x <= 0.0).sum() if zone.startswith("left_") + else (x >= 0.0).sum()) + if wrong: + raise SystemExit(f"{body_id}: {wrong} {zone} triangles on the wrong side") + + +def segment(positions: np.ndarray, faces: np.ndarray, joint_names: list[str], + joints: np.ndarray, weights: np.ndarray, + body_id: str) -> tuple[np.ndarray, dict]: + """Label every skin triangle with one canonical zone. + + `positions`/`joints`/`weights` are the merged skin soup (face panel + excluded) and `faces` indexes it. Returns the per-triangle zone index plus a + report describing the cut. + """ + root_share = float(sum(weights[joints == slot].sum() + for slot, name in enumerate(joint_names) + if name == "root")) + if root_share > 1.0e-6: + raise SystemExit(f"{body_id}: `root` carries {root_share:.6f} skin weight; " + "the joint -> zone table assumes none") + + shares = zone_shares(joint_names, joints, weights)[faces].mean(axis=1) + welded = weld(positions) + edge_a, edge_b = dual_edges(faces, welded) + areas = triangle_areas(positions, faces) + centroids = positions[faces].mean(axis=1) + + cuts = np.empty((len(faces), len(TREE_EDGES))) + for column, (_, child) in enumerate(TREE_EDGES): + past = [ZONE_INDEX[zone] for zone in SUBTREE[child]] + cuts[:, column] = 2.0 * shares[:, past].sum(axis=1) - 1.0 + labels = descend(relax(cuts, edge_a, edge_b)) + labels, absorbed = absorb_specks(labels, areas, edge_a, edge_b) + + seams = assert_adjacency(labels, edge_a, edge_b, body_id) + assert_sides(labels, centroids, body_id) + + counts = np.bincount(labels, minlength=len(ZONES)) + missing = [ZONES[index] for index in range(len(ZONES)) if counts[index] == 0] + if missing: + raise SystemExit(f"{body_id}: zones with no skin: {missing}") + component = components(labels, edge_a, edge_b) + report = { + "welded_points": int(welded.max()) + 1, + "boundary_edges": int(len(faces) * 3 - len(edge_a)), + "relax_passes": RELAX_PASSES, + "relax_weight": RELAX_WEIGHT, + "speck_area_cm2": round(SPECK_AREA_M2 * 1.0e4, 4), + "specks_absorbed": absorbed, + "seams": seams, + "zones": {ZONES[index]: { + "triangles": int(counts[index]), + "patches": int(len(np.unique(component[labels == index]))), + "area_cm2": round(float(areas[labels == index].sum()) * 1.0e4, 3), + "centroid_y_range_m": [ + round(float(centroids[labels == index, 1].min()), 4), + round(float(centroids[labels == index, 1].max()), 4)], + } for index in range(len(ZONES))}, + } + return labels, report diff --git a/content-pipeline/labs/humanoid-runtime-refit/canonical_hands.py b/content-pipeline/labs/humanoid-runtime-refit/canonical_hands.py new file mode 100644 index 00000000..8cfb7745 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/canonical_hands.py @@ -0,0 +1,88 @@ +"""Extract the last known-good animated hands from a runtime body. + +The reviewed high-detail hand replacement closes in bind pose but separates from +both forearms once wrist clips run. The pre-refit runtime bodies do not: their +hand geometry, weights, rest pose, inverse binds, and clips were authored as one +contract. Promotion therefore keeps the refined body everywhere except these two +leaf zones and grafts the canonical hand primitives back verbatim. +""" + +from __future__ import annotations + +import numpy as np + +import body_zones as BZ +from gltf_io import Glb + +HAND_ZONES = ("left_hand", "right_hand") +HAND_SHARE_THRESHOLD = 0.5 + + +def extract(glb: Glb, target_joint_names: list[str]) -> dict[str, dict[str, np.ndarray]]: + """Return compact canonical hand primitives remapped to target joint order.""" + source_joints = glb.json["skins"][0]["joints"] + source_joint_names = [glb.json["nodes"][index].get("name", "") + for index in source_joints] + if set(source_joint_names) != set(target_joint_names): + missing = sorted(set(target_joint_names) - set(source_joint_names)) + extra = sorted(set(source_joint_names) - set(target_joint_names)) + raise SystemExit(f"canonical hand joint contract drifted: missing={missing}, extra={extra}") + target_slot = {name: index for index, name in enumerate(target_joint_names)} + remap = np.array([target_slot[name] for name in source_joint_names], dtype=np.uint8) + + positions: list[np.ndarray] = [] + normals: list[np.ndarray] = [] + joints: list[np.ndarray] = [] + weights: list[np.ndarray] = [] + faces: list[np.ndarray] = [] + base = 0 + for mesh in glb.json["meshes"]: + for primitive in mesh["primitives"]: + attributes = primitive["attributes"] + required = {"POSITION", "NORMAL", "JOINTS_0", "WEIGHTS_0"} + if not required.issubset(attributes): + continue + position = glb.accessor(attributes["POSITION"]).astype(np.float64) + positions.append(position) + normals.append(glb.accessor(attributes["NORMAL"]).astype(np.float32)) + joints.append(glb.accessor(attributes["JOINTS_0"]).astype(np.int64)) + weights.append(glb.accessor(attributes["WEIGHTS_0"]).astype(np.float64)) + faces.append(glb.accessor(primitive["indices"]).astype(np.int64).reshape(-1, 3) + base) + base += len(position) + if not positions: + raise SystemExit("canonical hand source has no skinned triangle primitives") + + position = np.concatenate(positions) + normal = np.concatenate(normals) + source_joint_slots = np.concatenate(joints) + weight = np.concatenate(weights) + face = np.concatenate(faces) + totals = weight.sum(axis=1, keepdims=True) + if np.any(totals <= 1.0e-12): + raise SystemExit("canonical hand source has a zero-weight vertex") + weight = weight / totals + shares = BZ.zone_shares(source_joint_names, source_joint_slots, weight) + face_shares = shares[face].mean(axis=1) + + out: dict[str, dict[str, np.ndarray]] = {} + masks = [] + remapped = remap[source_joint_slots] + remapped[weight <= 0.0] = 0 + for zone in HAND_ZONES: + mask = face_shares[:, BZ.ZONE_INDEX[zone]] > HAND_SHARE_THRESHOLD + if not mask.any(): + raise SystemExit(f"canonical hand source has no {zone} triangles") + picked = face[mask] + used, local = np.unique(picked, return_inverse=True) + local = local.reshape(-1, 3) + out[zone] = { + "POSITION": position[used].astype(np.float32), + "NORMAL": normal[used], + "JOINTS_0": remapped[used], + "WEIGHTS_0": weight[used].astype(np.float32), + "indices": local, + } + masks.append(mask) + if np.logical_and(masks[0], masks[1]).any(): + raise SystemExit("canonical left/right hand extraction overlapped") + return out diff --git a/content-pipeline/labs/humanoid-runtime-refit/compare_female_sources.py b/content-pipeline/labs/humanoid-runtime-refit/compare_female_sources.py new file mode 100644 index 00000000..3d73d128 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/compare_female_sources.py @@ -0,0 +1,123 @@ +"""Does `rbf_v3` actually reduce the female posterior? Measure, do not assume. + +`rbf_v3_anim.glb` arrived as the "final reduced-posterior" female. The name is +not evidence, so this compares it against its predecessor `rbf_v2_anim.glb` and +against the previously shipped female bodies on the one quantity the request is +about: how far the rear pelvis silhouette projects behind the rig axis. Face is ++z, so rear is -z and the measure is `min z` inside a height band. + + blender --background --python compare_female_sources.py + +Output: `reports/female_source_comparison.json` plus a printed table. Nothing is +written to the runtime pack; this is an instrument. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import sys + +import numpy as np + +LAB_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, LAB_DIR) + +import body_zones as BZ # noqa: E402 +import refit_config as CFG # noqa: E402 +import reduce_female_posterior as REDUCE # noqa: E402 +from gltf_io import Glb # noqa: E402 + +GENERATOR = "successor content-pipeline/labs/humanoid-runtime-refit/compare_female_sources.py" + +#: Height bands, in metres of bind-pose y. The first two match the audit the +#: v2/v3 question was raised with; the third is the band the reduction is judged +#: on and the last two bracket it, so a change that merely moved the projection +#: up or down cannot read as a reduction. +BANDS = ((0.85, 0.95), (0.95, 1.05), (0.86, 1.02), (0.78, 0.86), (1.02, 1.12)) + +#: Candidates, in lineage order. Missing entries are reported, not fatal: the +#: branch-base bodies come out of git and the v2 source is a handoff artifact. +CANDIDATES = ( + ("branch_base_shipped_female", os.path.join(CFG.BUILD_DIR, "reference/female.glb")), + ("humanoid_lab_female_latest", os.path.join(CFG.BUILD_DIR, + "scratch/pawn_female_latest.glb")), + ("rbf_v2_source", os.path.join(CFG.SOURCE_DIR, "female/rbf_v2_anim.glb")), + ("rbf_v3_source", os.path.join(CFG.SOURCE_DIR, "female/rbf_v3_anim.glb")), + ("rbf_v3_reduced", REDUCE.output_path()), + ("rb_v4_male_source", os.path.join(CFG.SOURCE_DIR, "male/rb_v4_anim.glb")), +) + + +def skin_positions(glb: Glb) -> np.ndarray: + """Every skin vertex; the face panel is excluded, it is not a silhouette.""" + out = [] + for primitive in glb.json["meshes"][0]["primitives"]: + if glb.json["materials"][primitive["material"]]["name"] == BZ.FACE_MATERIAL: + continue + out.append(glb.accessor(primitive["attributes"]["POSITION"]).astype(np.float64)) + return np.concatenate(out) + + +def partition(glb: Glb) -> dict[str, int]: + return {glb.json["materials"][primitive["material"]]["name"]: + glb.json["accessors"][primitive["attributes"]["POSITION"]]["count"] + for primitive in glb.json["meshes"][0]["primitives"]} + + +def describe(path: str) -> dict: + glb = Glb.load(path) + position = skin_positions(glb) + return { + "path": os.path.relpath(path, CFG.REPO), + "sha256": hashlib.sha256(open(path, "rb").read()).hexdigest(), + "stature_m": round(float(position[:, 1].max()), 4), + "hip_half_width_m": round(float(np.abs(position[:, 0]).max()), 4), + "primitive_vertices": partition(glb), + "rear_mm": {f"y{low:g}-{high:g}": + round(REDUCE.band_rear(position, (low, high)) * 1000.0, 3) + for low, high in BANDS}, + "rear_profile_mm": REDUCE.rear_profile(position), + } + + +def main() -> None: + CFG.ensure_dirs() + report = {"generator": GENERATOR, "bands_y_m": [list(band) for band in BANDS], + "note": "face is +z, so rear projection is min z; more negative is deeper", + "bodies": {}, "missing": []} + for name, path in CANDIDATES: + if not os.path.exists(path): + report["missing"].append({"name": name, + "path": os.path.relpath(path, CFG.REPO)}) + continue + report["bodies"][name] = describe(path) + + columns = [f"y{low:g}-{high:g}" for low, high in BANDS] + print(f"{'body':30s} " + " ".join(f"{column:>11s}" for column in columns) + + " stature hipW") + for name, entry in report["bodies"].items(): + print(f"{name:30s} " + + " ".join(f"{entry['rear_mm'][column]:11.3f}" for column in columns) + + f" {entry['stature_m']:.4f} {entry['hip_half_width_m']:.4f}") + for entry in report["missing"]: + print(f"{entry['name']:30s} (absent: {entry['path']})") + + v2, v3 = report["bodies"].get("rbf_v2_source"), report["bodies"].get("rbf_v3_source") + if v2 and v3: + deltas = {column: round(v3["rear_mm"][column] - v2["rear_mm"][column], 4) + for column in columns} + report["v3_minus_v2_mm"] = deltas + report["v3_reduced_posterior"] = any(value > 0.05 for value in deltas.values()) + print(f"\nv3 - v2 rear delta (mm, positive = shallower): {deltas}") + print("v3 reduced the posterior: " + f"{'YES' if report['v3_reduced_posterior'] else 'NO'}") + destination = os.path.join(CFG.REPORT_DIR, "female_source_comparison.json") + with open(destination, "w", encoding="utf-8") as handle: + json.dump(report, handle, indent=2) + print(f"-> {os.path.relpath(destination, CFG.REPO)}") + + +if __name__ == "__main__": + main() diff --git a/content-pipeline/labs/humanoid-runtime-refit/compose_face.mjs b/content-pipeline/labs/humanoid-runtime-refit/compose_face.mjs new file mode 100644 index 00000000..136a488c --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/compose_face.mjs @@ -0,0 +1,39 @@ +// compose_face.mjs — bake one face texture with the canonical face kit. +// +// The kit (client-3d/src/assets/faceKit/face-kit.js) is the single pixel +// authority for Successor faces. Its browser entry points need a DOM, but +// `composeFacePixels` is explicitly pure and Node-safe once it is handed +// decoded RGBA atlases, so this bridge stays inside the canonical compositor +// instead of reimplementing any of it. +// +// Node has no PNG codec, so the Python driver decodes the five atlases to raw +// RGBA and re-encodes the result. This script only moves bytes through the kit. +// +// node compose_face.mjs +// +// job.json: +// { "assets": { "eyes": {"path":..., "width":..,"height":..}, ... }, +// "config": , "size": 256, "transparent": false, +// "out": "" } + +import { readFileSync, writeFileSync } from "node:fs"; +import { composeFacePixels } from "../../../client-3d/src/assets/faceKit/face-kit.js"; + +const job = JSON.parse(readFileSync(process.argv[2], "utf8")); + +const assets = Object.fromEntries(Object.entries(job.assets).map(([key, spec]) => { + const bytes = readFileSync(spec.path); + const data = new Uint8ClampedArray(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (data.length !== spec.width * spec.height * 4) { + throw new Error(`${key}: expected ${spec.width * spec.height * 4} RGBA bytes, got ${data.length}`); + } + return [key, { width: spec.width, height: spec.height, data }]; +})); + +const result = composeFacePixels(assets, job.config, { + size: job.size, + transparent: Boolean(job.transparent), +}); + +writeFileSync(job.out, Buffer.from(result.data.buffer, result.data.byteOffset, result.data.byteLength)); +process.stdout.write(`${result.width}x${result.height}\n`); diff --git a/content-pipeline/labs/humanoid-runtime-refit/gltf_io.py b/content-pipeline/labs/humanoid-runtime-refit/gltf_io.py new file mode 100644 index 00000000..77db69e7 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/gltf_io.py @@ -0,0 +1,168 @@ +"""Minimal dependency-free GLB reader/writer for the runtime refit lab. + +The refit promotes hand-authored bodies into GLB shells that already carry the +exact runtime contract (50 joints in a fixed skin order, 47 authored clips). +Re-exporting those shells through Blender would renumber nodes, re-sort the skin +joint array and rewrite every animation sampler, so the promotion is done as +accessor-level surgery instead -- the same technique the 2026-07-21 runtime body +promotion used ("surgical accessor transplant, byte-preserving for +skeleton/animations/materials"). + +This module owns only the container: chunk parsing, typed accessor decode, and +a writer that rebuilds a single-buffer GLB from an edited JSON + a fresh binary +blob. Nothing here knows about humanoids. + +Pure stdlib + numpy so it runs identically inside Blender's Python and out. +""" + +from __future__ import annotations + +import json +import struct +from typing import Any + +import numpy as np + +GLB_MAGIC = 0x46546C67 +CHUNK_JSON = 0x4E4F534A +CHUNK_BIN = 0x004E4942 + +COMPONENT_DTYPE = { + 5120: np.int8, + 5121: np.uint8, + 5122: np.int16, + 5123: np.uint16, + 5125: np.uint32, + 5126: np.float32, +} +DTYPE_COMPONENT = {np.dtype(v): k for k, v in COMPONENT_DTYPE.items()} +TYPE_COMPONENTS = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, + "MAT2": 4, "MAT3": 9, "MAT4": 16} +COMPONENTS_TYPE = {1: "SCALAR", 2: "VEC2", 3: "VEC3", 4: "VEC4", 16: "MAT4"} + +TARGET_ARRAY_BUFFER = 34962 +TARGET_ELEMENT_ARRAY_BUFFER = 34963 + + +def _pad4(value: int) -> int: + return (4 - (value % 4)) % 4 + + +class Glb: + """A parsed GLB: mutable `json`, immutable source `bin`.""" + + def __init__(self, gltf: dict[str, Any], binary: bytes) -> None: + self.json = gltf + self.bin = binary + + # ---------------------------------------------------------------- read + + @classmethod + def load(cls, path: str) -> "Glb": + with open(path, "rb") as handle: + raw = handle.read() + magic, version, total = struct.unpack_from(" tuple[bytes, int | None]: + view = self.json["bufferViews"][index] + start = view.get("byteOffset", 0) + return self.bin[start: start + view["byteLength"]], view.get("byteStride") + + def accessor(self, index: int) -> np.ndarray: + """Decode accessor `index` into a (count, components) or (count,) array.""" + acc = self.json["accessors"][index] + components = TYPE_COMPONENTS[acc["type"]] + dtype = np.dtype(COMPONENT_DTYPE[acc["componentType"]]) + count = acc["count"] + if "bufferView" not in acc: + out = np.zeros((count, components), dtype=dtype) + else: + data, stride = self.buffer_view_bytes(acc["bufferView"]) + start = acc.get("byteOffset", 0) + element = dtype.itemsize * components + if stride and stride != element: + out = np.empty((count, components), dtype=dtype) + for row in range(count): + base = start + row * stride + out[row] = np.frombuffer(data[base: base + element], dtype=dtype) + else: + out = np.frombuffer(data[start: start + count * element], + dtype=dtype).reshape(count, components).copy() + return out.reshape(-1) if components == 1 else out + + +class GlbBuilder: + """Accumulates buffer views for a rewritten single-buffer GLB.""" + + def __init__(self, gltf: dict[str, Any]) -> None: + self.json = gltf + self.json["bufferViews"] = [] + self.json["accessors"] = [] + self.blob = bytearray() + + def add_view(self, payload: bytes, *, target: int | None = None, + stride: int | None = None) -> int: + self.blob.extend(b"\x00" * _pad4(len(self.blob))) + offset = len(self.blob) + self.blob.extend(payload) + view: dict[str, Any] = {"buffer": 0, "byteOffset": offset, + "byteLength": len(payload)} + if target is not None: + view["target"] = target + if stride is not None: + view["byteStride"] = stride + self.json["bufferViews"].append(view) + return len(self.json["bufferViews"]) - 1 + + def add_accessor(self, array: np.ndarray, *, target: int | None = None, + normalized: bool = False, minmax: bool = False) -> int: + data = np.ascontiguousarray(array) + components = 1 if data.ndim == 1 else data.shape[1] + view = self.add_view(data.tobytes(), target=target) + acc: dict[str, Any] = { + "bufferView": view, + "componentType": DTYPE_COMPONENT[data.dtype], + "count": int(data.shape[0]), + "type": COMPONENTS_TYPE[components], + } + if normalized: + acc["normalized"] = True + if minmax: + flat = data.reshape(data.shape[0], components) + acc["min"] = [float(v) for v in flat.min(axis=0)] + acc["max"] = [float(v) for v in flat.max(axis=0)] + self.json["accessors"].append(acc) + return len(self.json["accessors"]) - 1 + + def write(self, path: str) -> int: + self.json["buffers"] = [{"byteLength": len(self.blob)}] + payload = json.dumps(self.json, separators=(",", ":"), + ensure_ascii=False, sort_keys=False).encode("utf-8") + payload += b" " * _pad4(len(payload)) + blob = bytes(self.blob) + b"\x00" * _pad4(len(self.blob)) + total = 12 + 8 + len(payload) + 8 + len(blob) + with open(path, "wb") as handle: + handle.write(struct.pack(" np.ndarray: + x, y, z, w = q + return np.array([ + [1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], + [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], + [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)], + ]) + + +def slerp(a: np.ndarray, b: np.ndarray, t: float) -> np.ndarray: + if float(np.dot(a, b)) < 0.0: + b = -b + dot = float(np.clip(np.dot(a, b), -1.0, 1.0)) + if dot > 0.9995: + out = a + t * (b - a) + else: + theta = np.arccos(dot) + out = ((np.sin((1.0 - t) * theta) * a + np.sin(t * theta) * b) + / np.sin(theta)) + return out / np.linalg.norm(out) + + +def column_major(flat: np.ndarray) -> np.ndarray: + """glTF stores matrices column-major; numpy wants them row-major.""" + return np.transpose(flat.reshape(-1, 4, 4), (0, 2, 1)) + + +class Rig: + """A GLB's node tree and animation samplers, evaluated to joint matrices.""" + + def __init__(self, glb: Glb) -> None: + self.nodes = glb.json["nodes"] + self.parent: dict[int, int] = {} + for index, node in enumerate(self.nodes): + for child in node.get("children", ()): + self.parent[child] = index + skin = glb.json["skins"][0] + self.joints = list(skin["joints"]) + self.joint_names = [self.nodes[j].get("name", "") for j in self.joints] + self.inverse_bind = column_major(glb.accessor(skin["inverseBindMatrices"])) + self.clips: dict[str, tuple[dict, float]] = {} + for animation in glb.json.get("animations", ()): + channels = {} + duration = 0.0 + for channel in animation["channels"]: + sampler = animation["samplers"][channel["sampler"]] + interpolation = sampler.get("interpolation", "LINEAR") + if interpolation == "CUBICSPLINE": + raise SystemExit(f"{animation.get('name')}: CUBICSPLINE sampler; " + "the pose evaluator only does STEP/LINEAR") + times = glb.accessor(sampler["input"]).astype(np.float64) + values = glb.accessor(sampler["output"]).astype(np.float64) + target = channel["target"] + channels[(target["node"], target["path"])] = (times, values, + interpolation) + duration = max(duration, float(times[-1])) + self.clips[animation.get("name", "")] = (channels, duration) + + def _sample(self, channels, node: int, path: str, time: float, default): + entry = channels.get((node, path)) + if entry is None: + return default + times, values, interpolation = entry + if time <= times[0]: + return values[0] + if time >= times[-1]: + return values[-1] + upper = int(np.searchsorted(times, time)) + lower = upper - 1 + if interpolation == "STEP": + return values[lower] + span = float(times[upper] - times[lower]) + fraction = 0.0 if span <= 0.0 else float((time - times[lower]) / span) + if path == "rotation": + return slerp(values[lower], values[upper], fraction) + return values[lower] * (1.0 - fraction) + values[upper] * fraction + + def local(self, channels, node: int, time: float) -> np.ndarray: + entry = self.nodes[node] + if "matrix" in entry: + # glTF forbids animating a node that carries a baked matrix. + if any(key[0] == node for key in channels): + raise SystemExit(f"node {node} carries a matrix and animation") + return np.array(entry["matrix"], dtype=np.float64).reshape(4, 4).T + translation = self._sample(channels, node, "translation", time, + np.array(entry.get("translation", (0.0, 0.0, 0.0)))) + rotation = self._sample(channels, node, "rotation", time, + np.array(entry.get("rotation", (0.0, 0.0, 0.0, 1.0)))) + scale = self._sample(channels, node, "scale", time, + np.array(entry.get("scale", (1.0, 1.0, 1.0)))) + out = np.eye(4) + out[:3, :3] = (quaternion_matrix(np.asarray(rotation, dtype=np.float64)) + * np.asarray(scale, dtype=np.float64)) + out[:3, 3] = translation + return out + + def world(self, clip: str | None, phase: float) -> dict[str, np.ndarray]: + """World matrix of every joint, keyed by joint name.""" + if clip is not None and clip not in self.clips: + raise SystemExit(f"clip {clip!r} is not on this rig") + channels, duration = self.clips[clip] if clip else ({}, 0.0) + time = phase * duration + cache: dict[int, np.ndarray] = {} + + def resolve(node: int) -> np.ndarray: + if node not in cache: + local = self.local(channels, node, time) + parent = self.parent.get(node) + cache[node] = local if parent is None else resolve(parent) @ local + return cache[node] + + return {name: resolve(node) + for name, node in zip(self.joint_names, self.joints)} + + +def joint_matrices(world: dict[str, np.ndarray], names: list[str], + inverse_bind: np.ndarray, label: str) -> np.ndarray: + """`world(joint) @ inverseBind(joint)` in one skin's own joint order.""" + out = np.empty((len(names), 4, 4)) + for slot, name in enumerate(names): + if name not in world: + raise SystemExit(f"{label}: joint {name!r} is not on the body rig") + out[slot] = world[name] @ inverse_bind[slot] + return out + + +def skin(position: np.ndarray, joints: np.ndarray, weights: np.ndarray, + matrices: np.ndarray) -> np.ndarray: + """Linear blend skinning, the only deformation either runtime applies.""" + homogeneous = np.concatenate([position, np.ones((len(position), 1))], axis=1) + out = np.zeros((len(position), 3)) + for slot in range(joints.shape[1]): + weight = weights[:, slot] + active = weight > 0.0 + if not active.any(): + continue + moved = np.einsum("nij,nj->ni", matrices[joints[active, slot]], + homogeneous[active]) + out[active] += weight[active, None] * moved[:, :3] + return out + + +def outward_normals(position: np.ndarray, faces: np.ndarray) -> np.ndarray: + a, b, c = position[faces[:, 0]], position[faces[:, 1]], position[faces[:, 2]] + normal = np.cross(b - a, c - a) + length = np.linalg.norm(normal, axis=1, keepdims=True) + return normal / np.where(length > 1e-20, length, 1.0) + + +class SkinnedGeometry: + """One GLB's skinned triangles, ready to be posed with a body's rig.""" + + def __init__(self, path: str, label: str, + keep=lambda name: True) -> None: + self.label = label + self.path = path + self.glb = Glb.load(path) + self.skinned = bool(self.glb.json.get("skins")) + if not self.skinned: + self.faces = np.zeros((0, 3), dtype=np.int64) + return + positions, joints, weights, faces, groups = [], [], [], [], [] + base = 0 + face_base = 0 + for mesh in self.glb.json["meshes"]: + for primitive in mesh["primitives"]: + name = self.glb.json["materials"][primitive["material"]]["name"] + attributes = primitive["attributes"] + if not keep(name) or "JOINTS_0" not in attributes: + continue + position = self.glb.accessor(attributes["POSITION"]).astype(np.float64) + index = self.glb.accessor(primitive["indices"] + ).astype(np.int64).reshape(-1, 3) + positions.append(position) + joints.append(self.glb.accessor(attributes["JOINTS_0"]).astype(np.int64)) + weights.append(self.glb.accessor(attributes["WEIGHTS_0"] + ).astype(np.float64)) + faces.append(index + base) + #: material -> the half-open face range it owns in `self.faces`. + groups.append((name, face_base, face_base + len(index))) + face_base += len(index) + base += len(position) + if not positions: + self.skinned = False + self.faces = np.zeros((0, 3), dtype=np.int64) + return + self.position = np.concatenate(positions) + self.joints = np.concatenate(joints) + self.weights = np.concatenate(weights) + self.weights = self.weights / self.weights.sum(axis=1, keepdims=True) + self.faces = np.concatenate(faces) + self.groups = groups + skin_json = self.glb.json["skins"][0] + nodes = self.glb.json["nodes"] + self.joint_names = [nodes[j].get("name", "") for j in skin_json["joints"]] + self.inverse_bind = column_major( + self.glb.accessor(skin_json["inverseBindMatrices"])) + + def posed(self, world: dict[str, np.ndarray]) -> np.ndarray: + matrices = joint_matrices(world, self.joint_names, self.inverse_bind, + self.label) + return skin(self.position, self.joints, self.weights, matrices) + + +class Body(SkinnedGeometry): + """A promoted body, with its skin split by canonical coverage zone.""" + + def __init__(self, body_id: str) -> None: + super().__init__(CFG.RUNTIME_BODY[body_id], body_id, + keep=lambda name: name.startswith(BZ.MATERIAL_PREFIX)) + self.body_id = body_id + self.rig = Rig(self.glb) + grouped_faces: dict[str, list[np.ndarray]] = {} + for name, start, stop in self.groups: + zone = name[len(BZ.MATERIAL_PREFIX):] + if zone not in BZ.ZONE_INDEX: + raise SystemExit(f"{body_id}: unknown zone material {name!r}") + grouped_faces.setdefault(zone, []).append(self.faces[start:stop]) + # The opaque face-panel base is a second BodyZone_head primitive. Keep + # both ranges; assignment-by-name would discard the sculpted skull and + # leave only the narrow face panel in every posed proof. + self.zone_faces = { + zone: np.concatenate(parts) for zone, parts in grouped_faces.items() + } + missing = [zone for zone in BZ.ZONES if zone not in self.zone_faces] + if missing: + raise SystemExit(f"{body_id}: body has no {missing} zone primitives") + self._cache: dict[tuple[str | None, float], tuple[dict, np.ndarray]] = {} + self._assert_outward() + + def _assert_outward(self) -> None: + """Front faces must point away from the body, or every ray test inverts. + + Judged by signed volume rather than by comparing each normal to the rig + axis: a foot sole, a palm and the crown of the head are all correctly + wound and none of them points away from the axis. + """ + corners = self.position[self.faces] + volume = float(np.einsum("ij,ij->i", corners[:, 0], + np.cross(corners[:, 1], corners[:, 2])).sum() / 6.0) + if volume <= 0.0: + raise SystemExit(f"{self.body_id}: skin winds inward (signed volume " + f"{volume:.6f}); the coverage probe assumes CCW " + "front faces") + + def pose(self, clip: str | None, phase: float): + """(joint world matrices, posed skin positions), cached per pose.""" + key = (clip, phase) + if key not in self._cache: + world = self.rig.world(clip, phase) + self._cache[key] = (world, self.posed(world)) + return self._cache[key] + + def poses(self) -> list[tuple[str | None, float]]: + available = [BIND] + for clip, phase in POSES: + if clip in self.rig.clips: + available.append((clip, phase)) + else: + raise SystemExit(f"{self.body_id}: clip {clip!r} is missing") + return available diff --git a/content-pipeline/labs/humanoid-runtime-refit/probe_weapons.py b/content-pipeline/labs/humanoid-runtime-refit/probe_weapons.py new file mode 100644 index 00000000..2ef4bb13 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/probe_weapons.py @@ -0,0 +1,287 @@ +"""Probe rigid attachments and weapons against male and female body meshes.""" + +from __future__ import annotations + +import json +import os +import sys +import numpy as np +from mathutils import Vector +from mathutils.bvhtree import BVHTree + +LAB_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, LAB_DIR) + +import refit_config as CFG +import pose_probe as POSE +from gltf_io import Glb + + +# A hand closes around a grip, so a few millimetres of the mesh sitting inside +# the palm is correct and expected: the measured spread for a correctly posed +# mount is 4-19 mm. The five copy-pasted carbine/SMG/sniper/launcher mounts sit +# at 64-66 mm, which is more than a third of the way through a ~90 mm hand and +# puts the grip out the far side. 30 mm separates the two populations cleanly. +GRIP_DEPTH_LIMIT_MM = 30.0 +# A head-socketed accessory rests on the skull. Anything further out is +# visibly floating; this matches the wearable gate's own float limit. +HEAD_FLOAT_LIMIT_MM = 3.0 +# Male and female share one 45-joint skeleton, so a socket must resolve to the +# same world point on both. Any real divergence is a rig defect, not tolerance. +VARIANT_POS_LIMIT_MM = 1.0 + +REPORT_PATH = os.path.join(LAB_DIR, "reports", "rigid_mount_fit.json") + +def quat_matrix(q: list[float] | np.ndarray) -> np.ndarray: + x, y, z, w = q + return np.array([ + [1 - 2*(y**2 + z**2), 2*(x*y - z*w), 2*(x*z + y*w), 0], + [2*(x*y + z*w), 1 - 2*(x**2 + z**2), 2*(y*z - x*w), 0], + [2*(x*z - y*w), 2*(y*z + x*w), 1 - 2*(x**2 + y**2), 0], + [0, 0, 0, 1] + ], dtype=np.float64) + + +def trs_matrix(pos: list[float], quat: list[float], scale: float = 1.0) -> np.ndarray: + m = quat_matrix(quat) + m[:3, :3] *= scale + m[:3, 3] = pos + return m + + +def load_glb_vertices(glb_path: str, model_scale: float = 1.0) -> np.ndarray: + g = Glb.load(glb_path) + nodes = g.json["nodes"] + count = len(nodes) + parent: dict[int, int] = {} + for idx, node in enumerate(nodes): + for child in node.get("children", ()): + parent[child] = idx + + roots = g.json.get("scenes", [{}])[0].get("nodes", []) if "scenes" in g.json else [] + if not roots: + has_p = set(parent.keys()) + roots = [i for i in range(count) if i not in has_p] + + def get_local(n: dict) -> np.ndarray: + if "matrix" in n: + return np.array(n["matrix"], dtype=np.float64).reshape(4, 4).T + t = np.array(n.get("translation", [0, 0, 0]), dtype=np.float64) + r = np.array(n.get("rotation", [0, 0, 0, 1]), dtype=np.float64) + s = np.array(n.get("scale", [1, 1, 1]), dtype=np.float64) + m = np.eye(4) + m[:3, :3] = quat_matrix(r)[:3, :3] * s + m[:3, 3] = t + return m + + cache: dict[int, np.ndarray] = {} + + def get_global(idx: int) -> np.ndarray: + if idx not in cache: + l = get_local(nodes[idx]) + p = parent.get(idx) + cache[idx] = l if p is None else get_global(p) @ l + return cache[idx] + + all_verts = [] + for idx, node in enumerate(nodes): + if "mesh" in node: + mesh_idx = node["mesh"] + mesh = g.json["meshes"][mesh_idx] + g_mat = get_global(idx) + if abs(model_scale - 1.0) > 1e-5: + s_mat = np.eye(4) + s_mat[0, 0] = s_mat[1, 1] = s_mat[2, 2] = model_scale + g_mat = s_mat @ g_mat + for prim in mesh["primitives"]: + pos_acc = prim["attributes"]["POSITION"] + pos_data = g.accessor(pos_acc).astype(np.float64) + ones = np.ones((len(pos_data), 1)) + pos_h = np.hstack([pos_data, ones]) + pos_world = (g_mat @ pos_h.T).T[:, :3] + all_verts.append(pos_world) + if not all_verts: + return np.zeros((0, 3)) + return np.vstack(all_verts) + + +def main() -> None: + male = POSE.Body("male") + female = POSE.Body("female") + + male_world = male.rig.world(None, 0.0) + female_world = female.rig.world(None, 0.0) + + male_tree = BVHTree.FromPolygons([tuple(p) for p in male.position], [tuple(f) for f in male.faces]) + female_tree = BVHTree.FromPolygons([tuple(p) for p in female.position], [tuple(f) for f in female.faces]) + + rigid_items = [] + field_cap_path = os.path.normpath(os.path.join(CFG.ASSETS, "items/custom/accessories/field_cap.glb")) + rigid_items.append({ + "id": "hat_field_cap", + "glb": field_cap_path, + "bone": "head", + "mount_pos": [0.0, 0.0, 0.0], + "mount_quat": [0.0, 0.0, 0.0, 1.0], + "scale": 1.0 + }) + + legacy_weapons = [ + ("slugthrower", "slugthrower.glb", "slugthrower_attach.json"), + ("vibrosword", "vibrosword.glb", "vibrosword_attach.json"), + ("plasma_hilt", "plasma_hilt.glb", "vibrosword_attach.json"), + ] + for item_id, glb_name, attach_name in legacy_weapons: + gpath = os.path.normpath(os.path.join(CFG.ASSETS, "pawn-pack", glb_name)) + apath = os.path.normpath(os.path.join(CFG.ASSETS, "pawn-pack", attach_name)) + with open(apath) as f: + spec = json.load(f) + m = spec["mount_hand_r_local"] + rigid_items.append({ + "id": item_id, + "glb": gpath, + "bone": "hand_r", + "mount_pos": m["pos"], + "mount_quat": m["quat"], + "scale": spec.get("scale_to_pawn", 1.0) + }) + + weapons_manifest_path = os.path.join(CFG.ASSETS, "pawn-pack/weapons/weapons_manifest.json") + with open(weapons_manifest_path) as f: + cmanifest = json.load(f) + + for item in cmanifest["items"]: + item_id = item["id"] + gpath = os.path.normpath(os.path.join(CFG.ASSETS, "pawn-pack/weapons", item["glb"])) + apath = os.path.normpath(os.path.join(CFG.ASSETS, "pawn-pack/weapons", item["attach"])) + with open(apath) as f: + spec = json.load(f) + m = spec["mount_hand_r_local"] + m_scale = spec.get("scale_to_pawn", item.get("scale", 1.0)) + rigid_items.append({ + "id": item_id, + "glb": gpath, + "bone": "hand_r", + "mount_pos": m["pos"], + "mount_quat": m["quat"], + "scale": m_scale + }) + + header = f"{'ID':24s} | {'M Clear(mm)':11s} | {'M Depth(mm)':11s} | {'F Clear(mm)':11s} | {'F Depth(mm)':11s} | {'Delta Pos(mm)':13s}" + print(header) + print("-" * len(header)) + + results = {} + + for item in rigid_items: + item_id = item["id"] + verts_local = load_glb_vertices(item["glb"], item["scale"]) + mount_mat = trs_matrix(item["mount_pos"], item["mount_quat"]) + + m_bone = male_world[item["bone"]] + m_mount_world_mat = m_bone @ mount_mat + m_mount_pos = m_mount_world_mat[:3, 3] + + f_bone = female_world[item["bone"]] + f_mount_world_mat = f_bone @ mount_mat + f_mount_pos = f_mount_world_mat[:3, 3] + + pos_delta_mm = float(np.linalg.norm(m_mount_pos - f_mount_pos) * 1000.0) + + m_signed = [] + if len(verts_local) > 0: + verts_h = np.hstack([verts_local, np.ones((len(verts_local), 1))]) + m_verts_world = (m_mount_world_mat @ verts_h.T).T[:, :3] + for pt in m_verts_world: + loc, norm, _, dist = male_tree.find_nearest(Vector((float(pt[0]), float(pt[1]), float(pt[2])))) + if loc is None: + continue + outward = (float(pt[0]) - loc.x, float(pt[1]) - loc.y, float(pt[2]) - loc.z) + inside = (outward[0]*norm.x + outward[1]*norm.y + outward[2]*norm.z) < 0.0 + m_signed.append(dist * 1000.0 * (-1.0 if inside else 1.0)) + + m_clear = float(np.min(m_signed)) if m_signed else 0.0 + m_depth = max(0.0, -m_clear) + + f_signed = [] + if len(verts_local) > 0: + verts_h = np.hstack([verts_local, np.ones((len(verts_local), 1))]) + f_verts_world = (f_mount_world_mat @ verts_h.T).T[:, :3] + for pt in f_verts_world: + loc, norm, _, dist = female_tree.find_nearest(Vector((float(pt[0]), float(pt[1]), float(pt[2])))) + if loc is None: + continue + outward = (float(pt[0]) - loc.x, float(pt[1]) - loc.y, float(pt[2]) - loc.z) + inside = (outward[0]*norm.x + outward[1]*norm.y + outward[2]*norm.z) < 0.0 + f_signed.append(dist * 1000.0 * (-1.0 if inside else 1.0)) + + f_clear = float(np.min(f_signed)) if f_signed else 0.0 + f_depth = max(0.0, -f_clear) + + print(f"{item_id:24s} | {m_clear:11.3f} | {m_depth:11.3f} | {f_clear:11.3f} | {f_depth:11.3f} | {pos_delta_mm:13.3f}") + + results[item_id] = { + "male": { + "clearance_mm": round(m_clear, 3), + "max_depth_mm": round(m_depth, 3), + "mount_world_pos": [round(x, 5) for x in m_mount_pos], + }, + "female": { + "clearance_mm": round(f_clear, 3), + "max_depth_mm": round(f_depth, 3), + "mount_world_pos": [round(x, 5) for x in f_mount_pos], + }, + "delta_pos_mm": round(pos_delta_mm, 3), + "bone": item["bone"] + } + + failures = [] + for item_id, row in sorted(results.items()): + head_socket = row["bone"] == "head" + for body in ("male", "female"): + depth = row[body]["max_depth_mm"] + clearance = row[body]["clearance_mm"] + if depth > GRIP_DEPTH_LIMIT_MM: + failures.append( + f"{body}: {item_id} grip buried {depth:.1f} mm " + f"(> {GRIP_DEPTH_LIMIT_MM:.0f} mm)" + ) + if head_socket and clearance > HEAD_FLOAT_LIMIT_MM: + failures.append( + f"{body}: {item_id} floats {clearance:.1f} mm off the head " + f"(> {HEAD_FLOAT_LIMIT_MM:.0f} mm)" + ) + if row["delta_pos_mm"] > VARIANT_POS_LIMIT_MM: + failures.append( + f"{item_id} resolves {row['delta_pos_mm']:.2f} mm apart between bodies" + ) + + os.makedirs(os.path.dirname(REPORT_PATH), exist_ok=True) + with open(REPORT_PATH, "w", encoding="utf-8") as handle: + json.dump( + { + "schema": "successor.rigid-mount-fit.v1", + "limits": { + "grip_depth_mm": GRIP_DEPTH_LIMIT_MM, + "head_float_mm": HEAD_FLOAT_LIMIT_MM, + "variant_pos_mm": VARIANT_POS_LIMIT_MM, + }, + "items": results, + "failures": failures, + }, + handle, + indent=2, + sort_keys=True, + ) + + print(f"\nwrote {REPORT_PATH}") + if failures: + print(f"FAIL: {len(failures)} rigid-mount defect(s)") + for line in failures: + print(f" - {line}") + else: + print("PASS: every rigid mount seats on both bodies") + + +if __name__ == "__main__": + main() diff --git a/content-pipeline/labs/humanoid-runtime-refit/promote_bodies.py b/content-pipeline/labs/humanoid-runtime-refit/promote_bodies.py new file mode 100644 index 00000000..87981f73 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/promote_bodies.py @@ -0,0 +1,539 @@ +"""Promote the approved refined humanoids onto the runtime body paths. + +Input : the hash-pinned final Bunker refinement artifacts selected by + `refit_config.REFINED` (body/head geometry + skin, no clips), the + hash-pinned pre-refit body for each sex (canonical animated hands), + and the hash-pinned pre-refit male runtime shell. +Output : `client-3d/public/assets/pawn-pack/pawn_{male,female}.glb` + +The shell contributes everything the runtime contract pins down: the 52-node +layout, the 50 joints in their exact skin order, the inverse bind matrices, and +the 47 authored animation clips with their sampler data. Those accessors are +copied through verbatim, so the promoted bodies carry a bit-identical animation +bank rather than a re-export of it. The refined body supplies every non-hand +zone; the last known-good body supplies both hand zones with their original +weights. Materials and embedded textures are rebuilt around that hybrid mesh. + +The shell is read from `build/reference/male.glb`, the hash-pinned pre-refit +body `reference_bodies.py` checks out of git, so promoting onto +`pawn-pack/pawn_male.glb` never consumes its own output and a re-run reproduces +the first byte for byte. + +Per-body work: + + * joint remap -- the lab exporter emits the same 50 joints in a different skin + order, so `JOINTS_0` is permuted into the shell's order and the shell's + inverse bind matrices stay untouched; + * hand graft -- the reviewed replacement hands separate from the forearms as + soon as wrist clips run, so both leaf zones come verbatim from each sex's + hash-pinned pre-refit body and are remapped into the shell joint order; + * attribute prune -- `COLOR_0` is a constant white VEC3 (a no-op the loader + still uploads) and `COLOR_1` is the lab's region-debug layer that three.js + ignores; both are dropped from the runtime body; + * face panel -- the authored `RB_Face` island fills a real hole in the head, + so it is emitted once as opaque head skin and again 1.5 mm forward as a + transparent, component-only overlay. The overlay UVs are normalised from + the authored island. This preserves a closed, skin-toned head while the + face texture carries no baked skin rectangle. + + python3 promote_bodies.py +""" + +from __future__ import annotations + +import hashlib +import json +import os +import sys +from typing import Any + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import body_zones as BZ # noqa: E402 +import canonical_hands as HANDS # noqa: E402 +import reference_bodies # noqa: E402 +import refit_config as CFG # noqa: E402 +from gltf_io import (Glb, GlbBuilder, TARGET_ARRAY_BUFFER, # noqa: E402 + TARGET_ELEMENT_ARRAY_BUFFER) + +GENERATOR = "successor content-pipeline/labs/humanoid-runtime-refit/promote_bodies.py" + +#: Smallest authored face-panel island the promotion will accept, as a fraction +#: of the UV square. The panel is measured per body, not assumed: `rb_v4` packs +#: it at u 0.241-0.759 and `rbf_v3` at u 0.267-0.733. +FACE_UV_MIN_SPAN = 0.05 + +REST_TRANSLATION_TOLERANCE = 1.0e-5 +REST_ROTATION_TOLERANCE_DEG = 0.25 +IBM_TOLERANCE = 1.0e-5 + +SAMPLER = {"magFilter": 9729, "minFilter": 9987, "wrapS": 33071, "wrapT": 33071} + + +def sha256_file(path: str) -> str: + with open(path, "rb") as handle: + return hashlib.sha256(handle.read()).hexdigest() + + +def srgb_to_linear(hex_color: str) -> list[float]: + value = hex_color.lstrip("#") + out = [] + for index in range(3): + channel = int(value[index * 2: index * 2 + 2], 16) / 255.0 + out.append(channel / 12.92 if channel <= 0.04045 + else ((channel + 0.055) / 1.055) ** 2.4) + return out + + +def joint_table(glb: Glb) -> tuple[list[str], dict[str, int]]: + nodes = glb.json["nodes"] + joints = glb.json["skins"][0]["joints"] + names = [nodes[index]["name"] for index in joints] + return names, {name: slot for slot, name in enumerate(names)} + + +def rest_pose(glb: Glb) -> dict[str, dict]: + nodes = glb.json["nodes"] + parent = {} + for index, node in enumerate(nodes): + for child in node.get("children", ()): + parent[child] = index + out = {} + for index in glb.json["skins"][0]["joints"]: + node = nodes[index] + out[node["name"]] = { + "parent": nodes[parent[index]]["name"] if index in parent else None, + "t": node.get("translation", [0.0, 0.0, 0.0]), + "r": node.get("rotation", [0.0, 0.0, 0.0, 1.0]), + "s": node.get("scale", [1.0, 1.0, 1.0]), + } + return out + + +def quaternion_degrees(a, b) -> float: + dot = min(1.0, abs(sum(x * y for x, y in zip(a, b)))) + return float(np.degrees(2.0 * np.arccos(dot))) + + +def assert_compatible(shell: Glb, refined: Glb, body_id: str) -> dict: + shell_rest, refined_rest = rest_pose(shell), rest_pose(refined) + if set(shell_rest) != set(refined_rest): + raise SystemExit(f"{body_id}: joint name sets differ") + worst_t = worst_r = 0.0 + for name, want in shell_rest.items(): + got = refined_rest[name] + if want["parent"] != got["parent"]: + raise SystemExit(f"{body_id}: {name} parent {got['parent']} != {want['parent']}") + worst_t = max(worst_t, max(abs(x - y) for x, y in zip(want["t"], got["t"]))) + worst_r = max(worst_r, quaternion_degrees(want["r"], got["r"])) + if max(abs(x - y) for x, y in zip(want["s"], got["s"])) > 1e-6: + raise SystemExit(f"{body_id}: {name} rest scale differs") + if worst_t > REST_TRANSLATION_TOLERANCE: + raise SystemExit(f"{body_id}: rest translation drift {worst_t}") + if worst_r > REST_ROTATION_TOLERANCE_DEG: + raise SystemExit(f"{body_id}: rest rotation drift {worst_r} deg") + + shell_names, _ = joint_table(shell) + _, refined_slot = joint_table(refined) + permutation = [refined_slot[name] for name in shell_names] + shell_ibm = shell.accessor(shell.json["skins"][0]["inverseBindMatrices"]).reshape(-1, 4, 4) + refined_ibm = refined.accessor(refined.json["skins"][0]["inverseBindMatrices"]).reshape(-1, 4, 4) + ibm_drift = float(np.abs(shell_ibm - refined_ibm[permutation]).max()) + if ibm_drift > IBM_TOLERANCE: + raise SystemExit(f"{body_id}: inverse bind matrix drift {ibm_drift}") + return {"rest_translation_drift_m": worst_t, + "rest_rotation_drift_deg": worst_r, + "inverse_bind_drift": ibm_drift, + "joint_permutation": permutation} + + +def normalise_face_uv(uv: np.ndarray, + body_id: str) -> tuple[np.ndarray, list[float]]: + """Fit the authored panel island onto the full [0,1] square. + + The baked panel texture fills its own image, so the island has to be + normalised for the face to bind without a texture transform. The island is + MEASURED rather than assumed: the two approved bodies pack it at different + widths, and a hardcoded rectangle would slide one body's face sideways. + """ + rect = [float(uv[:, 0].min()), float(uv[:, 1].min()), + float(uv[:, 0].max()), float(uv[:, 1].max())] + u0, v0, u1, v1 = rect + if not (0.0 <= u0 < u1 <= 1.0 and 0.0 <= v0 < v1 <= 1.0): + raise SystemExit(f"{body_id}: face panel island {rect} leaves the UV square") + if min(u1 - u0, v1 - v0) < FACE_UV_MIN_SPAN: + raise SystemExit(f"{body_id}: face panel island {rect} is degenerate") + out = uv.copy() + out[:, 0] = (out[:, 0] - u0) / (u1 - u0) + out[:, 1] = (out[:, 1] - v0) / (v1 - v0) + return out, [round(value, 6) for value in rect] + + +def copy_accessor(source: Glb, builder: GlbBuilder, index: int, + cache: dict[int, int]) -> int: + """Re-emit one source accessor into the rebuilt buffer, deduplicated.""" + if index in cache: + return cache[index] + accessor = source.json["accessors"][index] + data = source.accessor(index) + new_index = builder.add_accessor(np.ascontiguousarray(data)) + emitted = builder.json["accessors"][new_index] + for key in ("min", "max", "normalized", "name"): + if key in accessor: + emitted[key] = accessor[key] + cache[index] = new_index + return new_index + + +def add_texture(builder: GlbBuilder, png_path: str, name: str, + sampler_index: int) -> int: + with open(png_path, "rb") as handle: + payload = handle.read() + view = builder.add_view(payload) + builder.json.setdefault("images", []).append( + {"name": name, "mimeType": "image/png", "bufferView": view}) + image_index = len(builder.json["images"]) - 1 + builder.json.setdefault("textures", []).append( + {"name": name, "sampler": sampler_index, "source": image_index}) + return len(builder.json["textures"]) - 1 + + +def build(body_id: str, source: str, report: dict) -> None: + shell = Glb.load(CFG.RUNTIME_SHELL) + refined = Glb.load(source) + digest = sha256_file(source) + + compatibility = assert_compatible(shell, refined, body_id) + permutation = compatibility.pop("joint_permutation") + inverse = np.zeros(len(permutation), dtype=np.int64) # refined slot -> shell slot + for shell_slot, refined_slot in enumerate(permutation): + inverse[refined_slot] = shell_slot + # Zone shares are computed in the REFINED skin order, before the remap. + refined_joints, _ = joint_table(refined) + shell_joints, _ = joint_table(shell) + hand_reference_path = reference_bodies.path_for(body_id) + hand_reference = Glb.load(hand_reference_path) + hand_compatibility = assert_compatible( + shell, hand_reference, f"{body_id} canonical hands") + hand_compatibility.pop("joint_permutation") + canonical_hands = HANDS.extract(hand_reference, shell_joints) + gltf: dict[str, Any] = { + "asset": {"version": "2.0", "generator": GENERATOR}, + "scene": 0, + "scenes": json.loads(json.dumps(shell.json["scenes"])), + "nodes": json.loads(json.dumps(shell.json["nodes"])), + "skins": json.loads(json.dumps(shell.json["skins"])), + "animations": json.loads(json.dumps(shell.json["animations"])), + } + builder = GlbBuilder(gltf) + cache: dict[int, int] = {} + + # --- skeleton + animation bank, copied straight through ----------------- + skin = gltf["skins"][0] + skin["inverseBindMatrices"] = copy_accessor( + shell, builder, shell.json["skins"][0]["inverseBindMatrices"], cache) + for animation in gltf["animations"]: + for sampler in animation["samplers"]: + sampler["input"] = copy_accessor(shell, builder, sampler["input"], cache) + sampler["output"] = copy_accessor(shell, builder, sampler["output"], cache) + + # --- materials + the transparent default face overlay ------------------ + # Skin shading is per-vertex `COLOR_0`, not a map: the approved heads carry + # an authored UV set whose head island folds front onto back. The face panel + # is emitted as ordinary head skin, then duplicated just proud of the head + # with the component-only RGBA texture. That split is load-bearing: using a + # skin-filled face texture makes the entire panel render as a dark mask. + builder.json["samplers"] = [dict(SAMPLER)] + tone = srgb_to_linear(CFG.DEFAULT_SKIN_HEX) + face_texture = os.path.join(CFG.TEXTURE_DIR, "face_default.png") + skin_pbr: dict[str, Any] = {"baseColorFactor": tone + [1.0], + "metallicFactor": 0.0, "roughnessFactor": 0.82} + materials = [{"name": BZ.material_name(zone), "doubleSided": True, + "pbrMetallicRoughness": dict(skin_pbr)} for zone in BZ.ZONES] + face_slot = len(materials) + materials.append({ + "name": CFG.MATERIALS[body_id]["face"], + "doubleSided": True, + "alphaMode": "BLEND", + "pbrMetallicRoughness": { + "baseColorFactor": [1.0, 1.0, 1.0, 1.0], + "metallicFactor": 0.0, + "roughnessFactor": 0.82, + "baseColorTexture": { + "index": add_texture(builder, face_texture, f"{body_id}_face", 0), + }, + }, + }) + builder.json["materials"] = materials + + source_primitives = refined.json["meshes"][0]["primitives"] + shading = np.load(os.path.join(CFG.BUILD_DIR, f"{body_id}_vertex_ao.npy")) + if len(shading) != sum(refined.json["accessors"][p["attributes"]["POSITION"]]["count"] + for p in source_primitives): + raise SystemExit(f"{body_id}: baked shading does not match the body vertex count") + + # --- one soup of skin, plus the face panel kept aside ------------------- + source_material = [refined.json["materials"][p["material"]]["name"] + for p in source_primitives] + if source_material.count(BZ.FACE_MATERIAL) != 1: + raise SystemExit(f"{body_id}: expected exactly one {BZ.FACE_MATERIAL} " + f"primitive, found {source_material}") + panel_slot = source_material.index(BZ.FACE_MATERIAL) + + columns: dict[str, list[np.ndarray]] = {key: [] for key in + ("POSITION", "NORMAL", "JOINTS_0", + "WEIGHTS_0")} + skin_faces: list[np.ndarray] = [] + skin_shading: list[np.ndarray] = [] + merged_from: list[dict] = [] + cursor = 0 + soup = 0 + for slot, primitive in enumerate(source_primitives): + count = refined.json["accessors"][primitive["attributes"]["POSITION"]]["count"] + if slot != panel_slot: + for key, values in columns.items(): + values.append(refined.accessor(primitive["attributes"][key])) + skin_faces.append(refined.accessor(primitive["indices"] + ).astype(np.int64).reshape(-1, 3) + soup) + skin_shading.append(shading[cursor: cursor + count]) + merged_from.append({"slot": slot, "material": source_material[slot], + "vertices": int(count)}) + soup += count + cursor += count + + position = np.concatenate(columns["POSITION"]).astype(np.float64) + normal = np.concatenate(columns["NORMAL"]).astype(np.float32) + joints = np.concatenate(columns["JOINTS_0"]).astype(np.int64) + weights = np.concatenate(columns["WEIGHTS_0"]).astype(np.float64) + faces = np.concatenate(skin_faces) + shade = np.concatenate(skin_shading) + + totals = weights.sum(axis=1, keepdims=True) + if float(np.abs(totals - 1.0).max()) > 1.0e-4: + raise SystemExit(f"{body_id}: skin weights not normalised") + weights = weights / totals + labels, zone_report = BZ.segment(position, faces, refined_joints, joints, + weights, body_id) + + # Zero-weight slots point at joint 0 so a stray index can never name a bone + # the weight row does not actually use. + remapped = inverse[joints].astype(np.uint8) + remapped[weights <= 0.0] = 0 + colour = np.empty((len(position), 4), dtype=np.uint8) + colour[:, 0] = colour[:, 1] = colour[:, 2] = np.rint( + np.clip(shade, 0.0, 1.0) * 255.0).astype(np.uint8) + colour[:, 3] = 255 + position32 = position.astype(np.float32) + weights32 = weights.astype(np.float32) + + # --- mesh: one primitive per zone, then the panel ----------------------- + primitives = [] + stats = [] + for index, zone in enumerate(BZ.ZONES): + if zone in HANDS.HAND_ZONES: + hand = canonical_hands[zone] + zone_position = hand["POSITION"] + zone_normal = hand["NORMAL"] + zone_joints = hand["JOINTS_0"] + zone_weights = hand["WEIGHTS_0"] + zone_faces = hand["indices"] + indices = zone_faces.reshape(-1) + zone_colour = np.full((len(zone_position), 4), 255, dtype=np.uint8) + area_cm2 = round( + float(BZ.triangle_areas(zone_position, zone_faces).sum()) * 1.0e4, 3) + shading_min = shading_mean = 1.0 + else: + picked = faces[labels == index] + # `return_inverse` renumbers the zone's corners into a compact local + # vertex range; glTF wants the result flat and SCALAR. + used, indices = np.unique(picked, return_inverse=True) + indices = indices.reshape(-1) + zone_position = position32[used] + zone_normal = normal[used] + zone_joints = remapped[used] + zone_weights = weights32[used] + zone_colour = colour[used] + area_cm2 = zone_report["zones"][zone]["area_cm2"] + shading_min = float(shade[used].min()) + shading_mean = float(shade[used].mean()) + primitives.append({ + "attributes": { + "POSITION": builder.add_accessor( + zone_position, target=TARGET_ARRAY_BUFFER, minmax=True), + "NORMAL": builder.add_accessor(zone_normal, target=TARGET_ARRAY_BUFFER), + "JOINTS_0": builder.add_accessor(zone_joints, target=TARGET_ARRAY_BUFFER), + "WEIGHTS_0": builder.add_accessor( + zone_weights, target=TARGET_ARRAY_BUFFER), + "COLOR_0": builder.add_accessor( + zone_colour, target=TARGET_ARRAY_BUFFER, normalized=True), + }, + "indices": builder.add_accessor( + indices.astype(np.uint16) if len(zone_position) <= 65535 + else indices.astype(np.uint32), + target=TARGET_ELEMENT_ARRAY_BUFFER), + "material": index, + }) + stat = {"material": BZ.material_name(zone), "zone": zone, + "vertices": int(len(zone_position)), + "triangles": int(len(indices) // 3), + "area_cm2": area_cm2, + "shading_min": shading_min, + "shading_mean": shading_mean} + if zone in HANDS.HAND_ZONES: + stat["geometry_source"] = os.path.relpath(hand_reference_path, CFG.REPO) + stats.append(stat) + + panel = source_primitives[panel_slot] + panel_count = refined.json["accessors"][panel["attributes"]["POSITION"]]["count"] + panel_start = sum(refined.json["accessors"][p["attributes"]["POSITION"]]["count"] + for p in source_primitives[:panel_slot]) + panel_shade = shading[panel_start: panel_start + panel_count] + panel_position = refined.accessor(panel["attributes"]["POSITION"]).astype(np.float32) + panel_normal = refined.accessor(panel["attributes"]["NORMAL"]).astype(np.float32) + panel_joints = refined.accessor(panel["attributes"]["JOINTS_0"]).astype(np.int64) + panel_weights = refined.accessor(panel["attributes"]["WEIGHTS_0"]).astype(np.float64) + panel_totals = panel_weights.sum(axis=1, keepdims=True) + if float(np.abs(panel_totals - 1.0).max()) > 1.0e-4: + raise SystemExit(f"{body_id}: face panel weights not normalised") + panel_weights = panel_weights / panel_totals + panel_remapped = inverse[panel_joints].astype(np.uint8) + panel_remapped[panel_weights <= 0.0] = 0 + panel_colour = np.empty((panel_count, 4), dtype=np.uint8) + panel_colour[:, 0] = panel_colour[:, 1] = panel_colour[:, 2] = np.rint( + np.clip(panel_shade, 0.0, 1.0) * 255.0).astype(np.uint8) + panel_colour[:, 3] = 255 + panel_uv, panel_rect = normalise_face_uv( + refined.accessor(panel["attributes"]["TEXCOORD_0"]).astype(np.float64), body_id) + panel_indices = refined.accessor(panel["indices"]).astype(np.uint16) + + panel_normal_accessor = builder.add_accessor(panel_normal, target=TARGET_ARRAY_BUFFER) + panel_joints_accessor = builder.add_accessor(panel_remapped, target=TARGET_ARRAY_BUFFER) + panel_weights_accessor = builder.add_accessor( + panel_weights.astype(np.float32), target=TARGET_ARRAY_BUFFER) + panel_indices_accessor = builder.add_accessor( + panel_indices, target=TARGET_ELEMENT_ARRAY_BUFFER) + + # The source panel fills a real opening. First emit it as ordinary opaque + # head skin so transparent face pixels reveal the same material as the skull. + primitives.append({ + "attributes": { + "POSITION": builder.add_accessor( + panel_position, target=TARGET_ARRAY_BUFFER, minmax=True), + "NORMAL": panel_normal_accessor, + "JOINTS_0": panel_joints_accessor, + "WEIGHTS_0": panel_weights_accessor, + "COLOR_0": builder.add_accessor( + panel_colour, target=TARGET_ARRAY_BUFFER, normalized=True), + }, + "indices": panel_indices_accessor, + "material": BZ.ZONES.index("head"), + }) + stats.append({ + "material": BZ.material_name("head"), + "zone": "head", + "role": "face_panel_skin_base", + "vertices": int(panel_count), + "triangles": int(len(panel_indices) // 3), + "shading_min": float(panel_shade.min()), + "shading_mean": float(panel_shade.mean()), + }) + + # Then duplicate only the panel as a transparent feature overlay. It omits + # COLOR_0 deliberately: ambient-occlusion vertex colour must not darken the + # eyes, brows, nose, or mouth. + overlay_position = panel_position + panel_normal * CFG.FACE_OVERLAY_INFLATE + primitives.append({ + "attributes": { + "POSITION": builder.add_accessor( + overlay_position, target=TARGET_ARRAY_BUFFER, minmax=True), + "NORMAL": panel_normal_accessor, + "JOINTS_0": panel_joints_accessor, + "WEIGHTS_0": panel_weights_accessor, + "TEXCOORD_0": builder.add_accessor( + panel_uv.astype(np.float32), target=TARGET_ARRAY_BUFFER), + }, + "indices": panel_indices_accessor, + "material": face_slot, + }) + stats.append({ + "material": CFG.MATERIALS[body_id]["face"], + "zone": None, + "role": "transparent_face_overlay", + "vertices": int(panel_count), + "triangles": int(len(panel_indices) // 3), + "inflate_m": CFG.FACE_OVERLAY_INFLATE, + }) + builder.json["meshes"] = [{"name": "body", "primitives": primitives}] + + destination = CFG.RUNTIME_BODY[body_id] + size = builder.write(destination) + payload = open(destination, "rb").read() + aliases = [] + for alias in CFG.RUNTIME_BODY_ALIAS[body_id]: + with open(alias, "wb") as handle: + handle.write(payload) + aliases.append(os.path.relpath(alias, CFG.REPO)) + report[body_id] = { + "output": os.path.relpath(destination, CFG.REPO), + "aliases": aliases, + "bytes": size, + "sha256": sha256_file(destination), + "source": os.path.relpath(source, CFG.REPO), + "source_sha256": digest, + "source_sidecar": json.load(open(CFG.REFINED_SIDECAR[body_id], + encoding="utf-8")), + "joints": len(skin["joints"]), + "animations": len(gltf["animations"]), + "animation_names": [a["name"] for a in gltf["animations"]], + "merged_skin_primitives": merged_from, + "face_panel_uv_rect": panel_rect, + "primitives": stats, + "zone_segmentation": zone_report, + "compatibility": compatibility, + "canonical_hand_source": { + "path": os.path.relpath(hand_reference_path, CFG.REPO), + "sha256": sha256_file(hand_reference_path), + "compatibility": hand_compatibility, + }, + "face_texture": os.path.relpath(os.path.join(CFG.TEXTURE_DIR, "face_default.png"), CFG.REPO), + } + print(f"[promote] {body_id}: {size} B, {len(stats)} primitives " + f"({len(BZ.ZONES)} zones + face skin base + transparent overlay), " + f"{len(gltf['animations'])} clips -> {report[body_id]['output']}" + + (f" (+{len(aliases)} alias)" if aliases else "")) + + +def main() -> None: + CFG.ensure_dirs() + report: dict[str, Any] = {"generator": GENERATOR} + reference_bodies.materialise() + shell = Glb.load(CFG.RUNTIME_SHELL) + if len(shell.json["skins"][0]["joints"]) != 50: + raise SystemExit("runtime shell no longer carries 50 joints") + if len(shell.json["animations"]) != 47: + raise SystemExit(f"runtime shell carries {len(shell.json['animations'])} clips, expected 47") + if any("sparse" in accessor for accessor in shell.json["accessors"]): + raise SystemExit("runtime shell uses sparse accessors; the copy path cannot carry them") + if shell.json.get("extensionsUsed"): + raise SystemExit(f"runtime shell uses extensions {shell.json['extensionsUsed']}") + report["shell_sha256"] = sha256_file(CFG.RUNTIME_SHELL) + report["shell_animations"] = [a["name"] for a in shell.json["animations"]] + del shell + # Promote the exact final Bunker refinement artifacts. Shape corrections + # have already been reviewed and must not be applied a second time here. + sources = reference_bodies.promotion_inputs() + approved = reference_bodies.refined() + report["approved_sources"] = { + body_id: {"path": os.path.relpath(path, CFG.REPO), + "sha256": sha256_file(path), + "promotes_directly": os.path.abspath(path) == os.path.abspath(sources[body_id])} + for body_id, path in approved.items()} + for body_id in ("female", "male"): + build(body_id, sources[body_id], report) + with open(os.path.join(CFG.REPORT_DIR, "promotion.json"), "w", encoding="utf-8") as handle: + json.dump(report, handle, indent=2) + + +if __name__ == "__main__": + main() diff --git a/content-pipeline/labs/humanoid-runtime-refit/proof_renders.py b/content-pipeline/labs/humanoid-runtime-refit/proof_renders.py new file mode 100644 index 00000000..9f5456f9 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/proof_renders.py @@ -0,0 +1,216 @@ +"""Render the proofs the numbers stand behind: final bodies and starter kit. + +Geometry comes from `pose_probe`, not from Blender's own armature import. That is +deliberate: importing three GLBs gives three armatures, and the garments carry no +animation of their own, so an imported "posed" scene renders an animated body with +its clothes frozen in the bind pose. Skinning the vertices with the same joint +matrices the runtime uses -- which is what the fit gate measures -- means the +render shows exactly what was measured. + +Solid-shaded orthographic views, deliberately plain, with the layers tinted so a +5 mm bodysuit is actually visible. Cells land in `proof/` (gitignored) and every +one is hashed into `reports/proof_manifest.json`. + + blender --background --python proof_renders.py +""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +import sys + +import bpy +import numpy as np + +LAB_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, LAB_DIR) + +import body_zones as BZ # noqa: E402 +import pose_probe as POSE # noqa: E402 +import refit_config as CFG # noqa: E402 +from gltf_io import Glb # noqa: E402 + +GENERATOR = "successor content-pipeline/labs/humanoid-runtime-refit/proof_renders.py" + +SIZE = 720 +#: Camera azimuths, degrees clockwise from straight-on FRONT. 180 is the rear (the +#: posterior view), 225 the rear three-quarter, 270 the left side. +VIEWS = {"front": 0.0, "side": 270.0, "threequarter": 225.0, "rear": 180.0} +#: Layer tints. One flat tone hides a 5 mm shell completely. +SKIN = (0.88, 0.63, 0.42, 1.0) +SUIT = (0.06, 0.42, 0.58, 1.0) +BOOT = (0.06, 0.06, 0.08, 1.0) +#: Both sexes get the same bind, locomotion, compact-pose, and hand-critical +#: weapon motions. `rifle_aim` and `swing_h1` are deliberately included because +#: a hand graft can look sound in rest pose while exploding under wrist motion. +LOADOUT_POSES = ( + (None, 0.0, ""), + ("walk_f", 0.25, "_walk"), + ("crouch_idle", 0.5, "_crouch"), + ("meditate_loop", 0.5, "_meditate"), + ("rifle_aim", 0.5, "_rifle_aim"), + ("swing_h1", 0.45, "_swing_h1"), +) + + +def reset() -> None: + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + scene.render.engine = "BLENDER_WORKBENCH" + scene.render.resolution_x = SIZE + scene.render.resolution_y = SIZE + shading = scene.display.shading + shading.light = "STUDIO" + shading.color_type = "OBJECT" + shading.show_cavity = True + # The Workbench theme background has a studio-light horizon band. Proofs + # need a flat field so lighting changes on the model cannot be mistaken for + # runtime fog or post-processing. + shading.background_type = "VIEWPORT" + shading.background_color = (0.012, 0.014, 0.018) + + +def to_blender(vertices: np.ndarray) -> np.ndarray: + """glTF is Y-up, Blender is Z-up. + + The GLB importer does this for you; feeding raw accessor values straight in + lays the pawn on its back and every camera framing is then wrong. + """ + return np.column_stack([vertices[:, 0], -vertices[:, 2], vertices[:, 1]]) + + +def add(name: str, vertices: np.ndarray, faces: np.ndarray, rgba) -> None: + mesh = bpy.data.meshes.new(name) + mesh.from_pydata([tuple(v) for v in to_blender(vertices)], [], + [tuple(f) for f in faces]) + mesh.update() + obj = bpy.data.objects.new(name, mesh) + obj.color = rgba + bpy.context.scene.collection.objects.link(obj) + + +def frame(azimuth: float, height: float = 0.9, radius: float = 2.4, + scale: float = 2.0) -> None: + angle = math.radians(azimuth) + data = bpy.data.cameras.new("proof") + data.type = "ORTHO" + data.ortho_scale = scale + camera = bpy.data.objects.new("proof", data) + bpy.context.scene.collection.objects.link(camera) + camera.location = (radius * math.sin(angle), -radius * math.cos(angle), height) + camera.rotation_euler = (math.radians(90.0), 0.0, angle) + bpy.context.scene.camera = camera + + +def render(name: str, cells: dict) -> None: + path = os.path.join(CFG.PROOF_DIR, "cells", f"{name}.png") + os.makedirs(os.path.dirname(path), exist_ok=True) + bpy.context.scene.render.filepath = path + bpy.ops.render.render(write_still=True) + cells[name] = {"path": os.path.relpath(path, CFG.REPO), + "sha256": hashlib.sha256(open(path, "rb").read()).hexdigest(), + "bytes": os.path.getsize(path)} + print(f"[proof] {name} -> {cells[name]['path']}") + + +def bare_mesh(path: str): + """Every opaque skin triangle of a body GLB, in its bind pose.""" + glb = Glb.load(path) + positions, faces, base = [], [], 0 + for primitive in glb.json["meshes"][0]["primitives"]: + material = glb.json["materials"][primitive["material"]]["name"] + if material == BZ.FACE_MATERIAL: + continue + position = glb.accessor(primitive["attributes"]["POSITION"]).astype(np.float64) + positions.append(position) + faces.append(glb.accessor(primitive["indices"]).astype(np.int64).reshape(-1, 3) + + base) + base += len(position) + return np.concatenate(positions), np.concatenate(faces) + + +def main() -> None: + CFG.ensure_dirs() + cells: dict = {} + # 1. both final promoted bodies bare. The transparent face overlay is + # intentionally omitted here; the opaque skin panel proves the head stays + # closed when the atlas background is erased. + for body_id, path in CFG.RUNTIME_BODY.items(): + vertices, faces = bare_mesh(path) + for view in ("front", "side"): + reset() + add("body", vertices, faces, SKIN) + frame(VIEWS[view]) + render(f"body_{body_id}_{view}", cells) + # 2. Hand-critical clips on the bare body. Sleeves hid the detached-wrist + # regression in the starter proofs, so these views must stay unclothed. + for body_id in ("male", "female"): + body = POSE.Body(body_id) + for clip, phase in (("rifle_aim", 0.5), ("swing_h1", 0.45)): + _, skin = body.pose(clip, phase) + converted = to_blender(skin) + low, high = converted.min(axis=0), converted.max(axis=0) + centre = float((low[2] + high[2]) * 0.5) + span = 1.15 * float(max(high - low)) + for view in ("front", "side", "threequarter"): + reset() + add("body", skin, body.faces, SKIN) + frame(VIEWS[view], height=centre, scale=span) + render(f"body_{body_id}_{view}_{clip}", cells) + + + # 3. the starter loadout, skinned AND masked exactly as the runtime does it. + with open(os.path.join(CFG.REPORT_DIR, "body_zone_coverage.json"), + encoding="utf-8") as handle: + coverage = json.load(handle)["items"] + masked = (set(coverage["under_bodysuit"]["hide_body_zones"]) + | set(coverage["boots_canvas_ankle"]["hide_body_zones"])) + for body_id in ("male", "female"): + prefix = "" if body_id == "male" else "Female/" + body = POSE.Body(body_id) + suit = POSE.SkinnedGeometry( + os.path.join(CFG.EQUIPMENT, f"{prefix}Under/under_bodysuit.glb"), + "suit") + boots = POSE.SkinnedGeometry( + os.path.join(CFG.EQUIPMENT, f"{prefix}Under/boots_canvas_ankle.glb"), + "boots") + # The renderer hides the zone primitives the loadout claims, so a proof + # that draws them shows skin gameplay never draws -- and at a folded knee + # that reads as the garment failing when it is the mask working. + visible_faces = np.concatenate([faces for zone, faces + in body.zone_faces.items() + if zone not in masked]) + for clip, phase, suffix in LOADOUT_POSES: + world, skin = body.pose(clip, phase) + layers = (("skin", skin, visible_faces, SKIN), + ("suit", suit.posed(world), suit.faces, SUIT), + ("boots", boots.posed(world), boots.faces, BOOT)) + # Framed from the posed bounding box: a T-pose is twice as wide as a + # crouch is tall, so one fixed camera cannot hold both. + everything = to_blender(np.concatenate( + [vertices for _, vertices, _, _ in layers])) + low, high = everything.min(axis=0), everything.max(axis=0) + centre = float((low[2] + high[2]) * 0.5) + span = 1.15 * float(max(high - low)) + for view in ("front", "side", "threequarter"): + reset() + for name, vertices, faces, rgba in layers: + add(name, vertices, faces, rgba) + frame(VIEWS[view], height=centre, scale=span) + render(f"starter_{body_id}_{view}{suffix}", cells) + + destination = os.path.join(CFG.REPORT_DIR, "proof_manifest.json") + with open(destination, "w", encoding="utf-8") as handle: + json.dump({"generator": GENERATOR, "size_px": SIZE, "views_deg": VIEWS, + "loadout_poses": [{"clip": clip, "phase": phase} + for clip, phase, _ in LOADOUT_POSES], + "cells": cells}, handle, indent=2) + handle.write("\n") + print(f"[proof] {len(cells)} cells -> {os.path.relpath(destination, CFG.REPO)}") + + +if __name__ == "__main__": + main() diff --git a/content-pipeline/labs/humanoid-runtime-refit/reduce_female_posterior.py b/content-pipeline/labs/humanoid-runtime-refit/reduce_female_posterior.py new file mode 100644 index 00000000..8e128579 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/reduce_female_posterior.py @@ -0,0 +1,343 @@ +"""Reduce the female body's rear pelvis projection, locally and measurably. + +The approved female source is `rbf_v3_anim.glb`. Its NAME suggests a +posterior-reduction pass, but the geometry says otherwise. Face is +z, so rear is +-z, and the rear-most skin measures: + + z = -0.0670 pawn_female.glb as shipped at branch base 37da18de + z = -0.0893 humanoid-lab/pawn_female_latest.glb (the previous promotion input) + z = -0.0938 rbf_v3_anim.glb <-- deepest of the three + z = -0.0966 rb_v4_anim.glb (male, for scale) + +on the glute band (y 0.86-1.02). `compare_female_sources.py` measures v2 against +v3 directly: they are identical through the pelvis, and v3 only re-partitions the +head and face. So the reduction does not exist in the source, and this step +applies it as an explicit, reviewable correction instead of pretending it does. + +The correction is restrained and local by construction. Vertices move along +z +ONLY -- never in x or y -- so stature, waist and hip width, and every joint's +rest position are untouched by definition rather than by inspection, and nothing +in front of the body can move at all. The displacement is the product of four +smooth gates: + + * `height` -- fades in above the under-buttock crease and out below the + lumbar curve, so neither the thigh transition nor the low back + is redrawn; + * `lateral` -- confined to the buttock mass; zero by the hip's outer edge, so + the hip silhouette and the seat's widest point keep their + width; + * `depth` -- proportional to how far the surface actually protrudes, so the + reduction lands on the projection itself and not on the + surrounding form; + * `pelvis weight` -- the vertex's own pelvis skin share, so wherever the thigh + or the spine already owns the deformation the correction has + already faded to nothing. + +Skin weights, joint indices, inverse binds, node rest transforms, animation data, +UVs, vertex colours, material assignments, topology and vertex order all pass +through untouched. Normals are carried, not rebuilt: the authored set is only +partly smooth (a quarter of it matches area-weighted welded normals exactly), so +rebuilding it would restyle the whole body's shading. Instead each normal is +turned by the amount the surface under it turned, which is exactly zero outside +the corrected region. + + blender --background --python reduce_female_posterior.py +""" + +from __future__ import annotations + +import hashlib +import json +import os +import sys + +import numpy as np + +LAB_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, LAB_DIR) + +import body_zones as BZ # noqa: E402 +import refit_config as CFG # noqa: E402 +from gltf_io import Glb, GlbBuilder, TARGET_ARRAY_BUFFER # noqa: E402 + +GENERATOR = "successor content-pipeline/labs/humanoid-runtime-refit/reduce_female_posterior.py" + +#: Peak inward move, at the apex of the seat. Sized to land the glute band at +#: about -79 mm: below the previous promotion input (-89.3 mm) and well below the +#: male (-96.6 mm), while staying fuller than the branch-base body (-67.0 mm). +#: Restraint is the point -- this is a silhouette correction, not a reshape. +PEAK_M = 0.0150 + +#: Height gate, in metres of bind-pose y. Ramps in across the under-buttock +#: crease, holds over the seat, ramps out under the lumbar curve. +HEIGHT_IN = (0.845, 0.900) +HEIGHT_OUT = (1.000, 1.060) +#: Lateral gate: full over the buttock mass, gone by the hip's outer edge. +LATERAL_HOLD = 0.075 +LATERAL_OUT = 0.115 +#: Depth gate: the correction scales with how far the surface protrudes, from +#: nothing at 45 mm behind the rig axis to full at 70 mm. +DEPTH_IN = (-0.045, -0.070) + +#: The band the reduction is judged on, matching the measurements above. +GLUTE_BAND = (0.86, 1.02) +#: The two centre-body bands the v2/v3 audit reports, so the before/after here is +#: directly comparable to it. +AUDIT_BANDS = ((0.85, 0.95), (0.95, 1.05)) + +BUILD_SUBDIR = "female_posterior" + + +def sha256_file(path: str) -> str: + with open(path, "rb") as handle: + return hashlib.sha256(handle.read()).hexdigest() + + +def smoothstep(value: np.ndarray, low: float, high: float) -> np.ndarray: + """0 below `low`, 1 above `high`, C1 in between (works for high < low).""" + t = np.clip((value - low) / (high - low), 0.0, 1.0) + return t * t * (3.0 - 2.0 * t) + + +def skin_soup(glb: Glb): + """Positions, normals, faces and per-vertex zone shares of the whole mesh.""" + joint_names = [glb.json["nodes"][index].get("name", "") + for index in glb.json["skins"][0]["joints"]] + positions, normals, joints, weights, faces = [], [], [], [], [] + base = 0 + for primitive in glb.json["meshes"][0]["primitives"]: + attributes = primitive["attributes"] + position = glb.accessor(attributes["POSITION"]).astype(np.float64) + positions.append(position) + normals.append(glb.accessor(attributes["NORMAL"]).astype(np.float64)) + joints.append(glb.accessor(attributes["JOINTS_0"]).astype(np.int64)) + weights.append(glb.accessor(attributes["WEIGHTS_0"]).astype(np.float64)) + faces.append(glb.accessor(primitive["indices"]).astype(np.int64).reshape(-1, 3) + + base) + base += len(position) + weight = np.concatenate(weights) + weight = weight / weight.sum(axis=1, keepdims=True) + shares = BZ.zone_shares(joint_names, np.concatenate(joints), weight) + return (np.concatenate(positions), np.concatenate(normals), + np.concatenate(faces), shares) + + +def welded_normals(position: np.ndarray, faces: np.ndarray, + welded: np.ndarray) -> np.ndarray: + """Area-weighted vertex normals over the welded surface, per source vertex.""" + a, b, c = position[faces[:, 0]], position[faces[:, 1]], position[faces[:, 2]] + face_normal = np.cross(b - a, c - a) + accumulated = np.zeros((int(welded.max()) + 1, 3)) + for corner in range(3): + np.add.at(accumulated, welded[faces[:, corner]], face_normal) + length = np.linalg.norm(accumulated, axis=1, keepdims=True) + return (accumulated / np.where(length > 1e-20, length, 1.0))[welded] + + +def displacement(position: np.ndarray, shares: np.ndarray) -> np.ndarray: + """Per-vertex +z move: peak x height x lateral x depth x pelvis share.""" + height = (smoothstep(position[:, 1], *HEIGHT_IN) + * (1.0 - smoothstep(position[:, 1], *HEIGHT_OUT))) + lateral = 1.0 - smoothstep(np.abs(position[:, 0]), LATERAL_HOLD, LATERAL_OUT) + depth = smoothstep(position[:, 2], *DEPTH_IN) + pelvis = shares[:, BZ.ZONE_INDEX["pelvis"]] + return PEAK_M * height * lateral * depth * pelvis + + +def band_rear(position: np.ndarray, band: tuple[float, float]) -> float: + inside = (position[:, 1] >= band[0]) & (position[:, 1] < band[1]) + return float(position[inside, 2].min()) + + +def measure(before: np.ndarray, after: np.ndarray, move: np.ndarray) -> dict: + moved = move > 1.0e-6 + return { + "glute_band_y_m": list(GLUTE_BAND), + "rear_projection_before_mm": round(band_rear(before, GLUTE_BAND) * 1000.0, 3), + "rear_projection_after_mm": round(band_rear(after, GLUTE_BAND) * 1000.0, 3), + # Rear z is negative, so a shallower silhouette is a LARGER z. + "reduction_mm": round((band_rear(after, GLUTE_BAND) + - band_rear(before, GLUTE_BAND)) * 1000.0, 3), + "audit_bands_mm": [ + {"y_m": list(band), + "before": round(band_rear(before, band) * 1000.0, 3), + "after": round(band_rear(after, band) * 1000.0, 3), + "reduction": round((band_rear(after, band) + - band_rear(before, band)) * 1000.0, 3)} + for band in AUDIT_BANDS], + "vertices_moved": int(moved.sum()), + "vertices_total": int(len(move)), + "max_move_mm": round(float(move.max()) * 1000.0, 3), + "mean_move_moved_mm": (round(float(move[moved].mean()) * 1000.0, 3) + if moved.any() else 0.0), + "height_span_moved_m": ([round(float(before[moved, 1].min()), 4), + round(float(before[moved, 1].max()), 4)] + if moved.any() else []), + "abs_x_span_moved_m": ([round(float(np.abs(before[moved, 0]).min()), 4), + round(float(np.abs(before[moved, 0]).max()), 4)] + if moved.any() else []), + "stature_delta_mm": round(float(after[:, 1].max() - before[:, 1].max()) + * 1000.0, 6), + "hip_width_delta_mm": round(float(np.abs(after[:, 0]).max() + - np.abs(before[:, 0]).max()) * 1000.0, 6), + "front_silhouette_delta_mm": round(float(after[:, 2].max() + - before[:, 2].max()) * 1000.0, 6), + } + + +def rear_profile(position: np.ndarray) -> list[list[float]]: + """Rear silhouette: rear-most z per 1 cm height slice, in millimetres.""" + out = [] + for step in range(70, 116): + low, high = step / 100.0, (step + 1) / 100.0 + inside = ((position[:, 1] >= low) & (position[:, 1] < high) + & (position[:, 2] < 0.0)) + if inside.any(): + out.append([round(low, 2), + round(float(position[inside, 2].min()) * 1000.0, 2)]) + return out + + +def rebuild(glb: Glb, position: np.ndarray, normal: np.ndarray, + destination: str) -> int: + """Copy the GLB through, replacing only POSITION and NORMAL.""" + builder = GlbBuilder(json.loads(json.dumps(glb.json))) + replaced = {} + cursor = 0 + for primitive in glb.json["meshes"][0]["primitives"]: + count = glb.json["accessors"][primitive["attributes"]["POSITION"]]["count"] + replaced[primitive["attributes"]["POSITION"]] = ( + position[cursor: cursor + count].astype(np.float32), True) + replaced[primitive["attributes"]["NORMAL"]] = ( + normal[cursor: cursor + count].astype(np.float32), False) + cursor += count + + remap = {} + for index, accessor in enumerate(glb.json["accessors"]): + if index in replaced: + array, minmax = replaced[index] + remap[index] = builder.add_accessor(array, target=TARGET_ARRAY_BUFFER, + minmax=minmax) + continue + payload, stride = glb.buffer_view_bytes(accessor["bufferView"]) + entry = dict(accessor) + entry["byteOffset"] = 0 + entry["bufferView"] = builder.add_view( + payload[accessor.get("byteOffset", 0):], + target=glb.json["bufferViews"][accessor["bufferView"]].get("target"), + stride=stride) + builder.json["accessors"].append(entry) + remap[index] = len(builder.json["accessors"]) - 1 + + for mesh in builder.json["meshes"]: + for primitive in mesh["primitives"]: + primitive["attributes"] = {name: remap[index] for name, index + in primitive["attributes"].items()} + if "indices" in primitive: + primitive["indices"] = remap[primitive["indices"]] + for skin in builder.json.get("skins", ()): + if "inverseBindMatrices" in skin: + skin["inverseBindMatrices"] = remap[skin["inverseBindMatrices"]] + for animation in builder.json.get("animations", ()): + for sampler in animation["samplers"]: + sampler["input"] = remap[sampler["input"]] + sampler["output"] = remap[sampler["output"]] + builder.json["asset"] = {"version": "2.0", "generator": GENERATOR} + return builder.write(destination) + + +def output_path() -> str: + return os.path.join(CFG.BUILD_DIR, BUILD_SUBDIR, "rbf_v3_reduced.glb") + + +def report_path() -> str: + return os.path.join(CFG.REPORT_DIR, "female_posterior.json") + + +def build(source: str) -> dict: + glb = Glb.load(source) + position, normal, faces, shares = skin_soup(glb) + welded = BZ.weld(position) + before_normals = welded_normals(position, faces, welded) + + move = displacement(position, shares) + corrected = position.copy() + corrected[:, 2] += move + after_normals = welded_normals(corrected, faces, welded) + + # Turn each authored normal by exactly how much the surface under it turned. + # Outside the corrected region the two welded fields are identical, so the + # authored normal is reproduced unchanged. + turned = normal + (after_normals - before_normals) + length = np.linalg.norm(turned, axis=1, keepdims=True) + turned = turned / np.where(length > 1e-12, length, 1.0) + + metrics = measure(position, corrected, move) + if (metrics["stature_delta_mm"] or metrics["hip_width_delta_mm"] + or metrics["front_silhouette_delta_mm"]): + raise SystemExit("posterior reduction touched stature, hip width or the " + "front silhouette; the correction is +z only") + if metrics["reduction_mm"] <= 0.0: + raise SystemExit("posterior reduction did not reduce the projection") + + destination = output_path() + os.makedirs(os.path.dirname(destination), exist_ok=True) + size = rebuild(glb, corrected, turned, destination) + record = { + "generator": GENERATOR, + "source": os.path.relpath(source, CFG.REPO), + "source_sha256": sha256_file(source), + "output": os.path.relpath(destination, CFG.REPO), + "output_sha256": sha256_file(destination), + "bytes": size, + "gates": {"peak_m": PEAK_M, "height_in": list(HEIGHT_IN), + "height_out": list(HEIGHT_OUT), "lateral_hold_m": LATERAL_HOLD, + "lateral_out_m": LATERAL_OUT, "depth_in": list(DEPTH_IN)}, + "metrics": metrics, + "rear_profile_before_mm": rear_profile(position), + "rear_profile_after_mm": rear_profile(corrected), + } + with open(report_path(), "w", encoding="utf-8") as handle: + json.dump(record, handle, indent=2) + return record + + +def ensure(source: str) -> str: + """The reduced female body, rebuilt when missing or stale.""" + destination = output_path() + recorded = None + if os.path.exists(report_path()): + with open(report_path(), encoding="utf-8") as handle: + recorded = json.load(handle) + stale = (recorded is None + or not os.path.exists(destination) + or recorded.get("generator") != GENERATOR + or recorded.get("source_sha256") != sha256_file(source) + or recorded.get("output_sha256") != sha256_file(destination) + or recorded.get("gates", {}).get("peak_m") != PEAK_M) + if stale: + build(source) + return destination + + +def main() -> None: + CFG.ensure_dirs() + import reference_bodies # local: only the CLI needs the source hash gate + record = build(reference_bodies.refined()["female"]) + metrics = record["metrics"] + print(f"[posterior] glute band rear {metrics['rear_projection_before_mm']:.1f} -> " + f"{metrics['rear_projection_after_mm']:.1f} mm " + f"(-{metrics['reduction_mm']:.1f} mm) over " + f"{metrics['vertices_moved']}/{metrics['vertices_total']} vertices, " + f"peak move {metrics['max_move_mm']:.1f} mm") + for band in metrics["audit_bands_mm"]: + print(f"[posterior] y {band['y_m'][0]:.2f}-{band['y_m'][1]:.2f}: " + f"{band['before']:.3f} -> {band['after']:.3f} mm " + f"(-{band['reduction']:.3f})") + print(f"[posterior] stature {metrics['stature_delta_mm']} mm, hip width " + f"{metrics['hip_width_delta_mm']} mm, front " + f"{metrics['front_silhouette_delta_mm']} mm -> {record['output']}") + + +if __name__ == "__main__": + main() diff --git a/content-pipeline/labs/humanoid-runtime-refit/reference_bodies.py b/content-pipeline/labs/humanoid-runtime-refit/reference_bodies.py new file mode 100644 index 00000000..c86b33e8 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/reference_bodies.py @@ -0,0 +1,127 @@ +"""Materialise the pinned bodies the refit reads: references and approved input. + +Every catalogued garment was authored on the bodies that shipped at the branch +base commit. The refit needs those exact meshes to build its old -> new +deformation field, but the promotion overwrites two of the three paths in place, +so the references are checked out of git into a scratch directory instead of +being duplicated into the tree. `refined()` resolves the promotion's INPUT the +same way, so a worktree without the lab-only `humanoid-lab/` asset root still +rebuilds the bodies from source. Hashes are pinned throughout, so a wrong or +drifted checkout fails loudly rather than silently producing a bad fit. + + python3 reference_bodies.py +""" + +from __future__ import annotations + +import hashlib +import os +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import refit_config as CFG # noqa: E402 + +BASE_COMMIT = "37da18de" + +REFERENCES = { + "male": ("client-3d/public/assets/pawn-pack/pawn_male.glb", + "6eaac9062bcfbc5efdf40c64f9e3236402a0d81832f1e0e8f4ce9a0a4dacac42"), + "male_bare": ("client-3d/public/assets/pawn-pack/pawn_male_bare.glb", + "5c182b3f7dcbb33a61965f633e66e820342f6d0f46746e30bce591a5b3182bca"), + "female": ("client-3d/public/assets/pawn-pack/pawn_female.glb", + "16d8c879aabc58b5ee176edcfc0b0dc43fc695e7d59f4a2ef6d6a15890e7d000"), +} + +REFERENCE_DIR = os.path.join(CFG.BUILD_DIR, "reference") + + +def path_for(key: str) -> str: + return os.path.join(REFERENCE_DIR, f"{key}.glb") + + +def _checkout(commit: str, tracked: str, destination: str, expected: str) -> None: + """Write `commit:tracked` to `destination`, refusing a drifted blob.""" + if os.path.exists(destination) and hashlib.sha256( + open(destination, "rb").read()).hexdigest() == expected: + return + payload = subprocess.run(["git", "show", f"{commit}:{tracked}"], + cwd=CFG.REPO, check=True, stdout=subprocess.PIPE).stdout + digest = hashlib.sha256(payload).hexdigest() + if digest != expected: + raise SystemExit(f"{commit}:{tracked} is {digest}, expected {expected}") + os.makedirs(os.path.dirname(destination), exist_ok=True) + with open(destination, "wb") as handle: + handle.write(payload) + + +def refined() -> dict[str, str]: + """The final approved bodies, hash-verified. + + These are handed to the lab as files rather than produced by it (`source/`, + with a sidecar each), so there is nothing to materialise -- only to refuse. + A missing or drifted body is fatal: promoting a body whose bytes nobody + pinned is exactly the failure this check exists to prevent. + """ + out = {} + for body_id, path in CFG.REFINED.items(): + expected = CFG.REFINED_SHA[body_id] + relative = os.path.relpath(path, CFG.REPO) + if not os.path.exists(path): + raise SystemExit(f"{body_id}: approved body {relative} is missing") + digest = hashlib.sha256(open(path, "rb").read()).hexdigest() + if digest != expected: + raise SystemExit(f"{body_id}: {relative} is {digest}, expected {expected}") + out[body_id] = path + return out + + +def promotion_inputs() -> dict[str, str]: + """Return the final reviewed body artifacts without another shape pass. + + The Bunker refinement already owns both bodies' posterior and transition + decisions. Reapplying the older female-only reduction here would silently + deform the accepted female a second time. + """ + return refined() + + +def materialise() -> dict[str, str]: + os.makedirs(REFERENCE_DIR, exist_ok=True) + out = {} + for key, (tracked, expected) in REFERENCES.items(): + _checkout(BASE_COMMIT, tracked, path_for(key), expected) + out[key] = path_for(key) + return out + + +def _show(tracked: str) -> bytes: + return subprocess.run(["git", "show", f"{BASE_COMMIT}:{tracked}"], + cwd=CFG.REPO, check=True, stdout=subprocess.PIPE).stdout + + +def stage_apparel(destination_dir: str, manifest: dict) -> dict[str, str]: + """Check every catalogued apparel GLB out of the branch base commit. + + The refit writes over the catalogue in place, so the authored geometry has + to come from git. Without this a second run would refit a refit and the + garments would drift a little further from the body every time. + """ + os.makedirs(destination_dir, exist_ok=True) + out = {} + for item in manifest["items"]: + target = os.path.join(destination_dir, f"{item['id']}.glb") + if not os.path.exists(target): + live = os.path.normpath(os.path.join( + CFG.EQUIPMENT, item["glb"])) + tracked = os.path.relpath(live, CFG.REPO).replace(os.sep, "/") + with open(target, "wb") as handle: + handle.write(_show(tracked)) + out[item["id"]] = target + return out + + +if __name__ == "__main__": + for key, path in (materialise() | {f"refined_{k}": v + for k, v in refined().items()}).items(): + print(f"[reference] {key}: {os.path.relpath(path, CFG.REPO)} ({os.path.getsize(path)} B)") diff --git a/content-pipeline/labs/humanoid-runtime-refit/refit_apparel.py b/content-pipeline/labs/humanoid-runtime-refit/refit_apparel.py new file mode 100644 index 00000000..49c4d213 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/refit_apparel.py @@ -0,0 +1,601 @@ +"""Refit the whole apparel catalogue onto the promoted humanoid bodies. + +Every catalogued piece is skinned to the same 50-joint rig in the same bind +pose, so a refit only moves bind-pose POSITIONS and the normals that follow +them. Joints, weights, indices, UVs, materials and embedded textures are copied +through untouched, which is why a refitted piece still deforms exactly as it was +authored and still satisfies the rig contract by construction. + +The promoted bodies are much fuller than the reference mannequins they replace +(the male trunk stands up to 84 mm further forward and 54 mm wider), so the +catalogue does not merely shift -- unrefitted, a tank top's chest and a trouser's +thigh burst straight through the cloth. Three policies, chosen per item: + + soft -- per-vertex radial displacement, then iterative contact resolution + relaxed over the garment's own graph. Cloth follows the new silhouette + and keeps the clearance it was authored with. + shell -- one similarity transform per connected component, least-squares fitted + to that component's own displacement, then at most one bounded + outward nudge. A pauldron or a hip plate keeps its authored thickness + and hard-surface shape exactly; only placement and overall size move. + head -- ONE similarity transform for the whole piece, fitted to how the head + itself changed. Hair, helmets, hoods, masks and visors are re-seated + on the new cranium rather than reshaped, so a hair silhouette survives + intact and its cap keeps exactly the scalp contact it was authored + with. + +Quality is measured as clearance DRIFT, not absolute standoff: a skirt is +supposed to float and a boot lining is supposed to sit inside the ankle, so the +test is whether each vertex kept the gap it had over the reference body. + + python3 refit_apparel.py [item-id ...] +""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import sys +from typing import Any + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import refit_config as CFG # noqa: E402 +import reference_bodies # noqa: E402 +import refit_field as FIELD # noqa: E402 +from gltf_io import (Glb, GlbBuilder, TARGET_ARRAY_BUFFER, # noqa: E402 + TARGET_ELEMENT_ARRAY_BUFFER) + +GENERATOR = "successor content-pipeline/labs/humanoid-runtime-refit/refit_apparel.py" + +FEMALE_SUBDIR = "Female" + +#: Authored, not refitted. The two FIXED starter pieces are generated from each +#: body's own skin by `author_starter_apparel.py`, because warping the versions +#: drawn on the old body still left them inside the promoted skin (-1.5/-11.0 mm +#: for the suit, -18.3/-19.0 mm for the boots). Refitting them here would +#: overwrite that authored geometry with the warp it replaced. +AUTHORED_ITEM_IDS = frozenset({"under_bodysuit", "boots_canvas_ankle"}) + +#: Minimum skin gap after refit, by policy. Soft cloth may sit almost on the +#: skin; hard armour needs room for its own inner face. +CLEARANCE = {"soft": 0.0015, "shell": 0.0025, "head": 0.0015} +#: A vertex this far inside the skin is clipping, not resting on it. +PENETRATION_LIMIT = 0.0010 +#: Contact resolution is iterative: pushing one vertex out changes which face is +#: closest to its neighbours, so a single pass leaves creases behind. +CLEARANCE_ITERATIONS = 6 +CLEARANCE_EPSILON = 5.0e-5 +#: A component may be re-seated and resized; it may not be restyled. +SHELL_SCALE_CLAMP = (0.88, 1.15) +SHELL_NUDGE_LIMIT = 0.020 +HEAD_SCALE_CLAMP = (0.85, 1.15) + +#: Anything that rides the skull. These are re-seated with the head, never +#: reshaped: a mohawk that follows a per-vertex field stops being a mohawk. +HEAD_GROUPS = {"Hair", "Helmet — bake-off", "Headwear — baseline"} +HEAD_ID_PREFIXES = ("trial_hair", "trial_helm", "trial_visor", "trial_mask", + "trial_hood") +#: Hard-surface pieces that sit on the body rather than the skull. +SHELL_ID_PREFIXES = ("armor_", "trial_back", "trial_hip") + +#: Female-variant thresholds. The field's own residual against bare skin is +#: ~0.9 mm mean / 2.3 mm p90, so anything at this scale is a real misfit and not +#: measurement noise. +VARIANT_PENETRATION_MM = 4.0 +VARIANT_PENETRATION_COUNT = 4 +VARIANT_DRIFT_P95_MM = 8.0 + + +def sha256_file(path: str) -> str: + with open(path, "rb") as handle: + return hashlib.sha256(handle.read()).hexdigest() + + +def policy_for(item: dict) -> str: + if item.get("group") in HEAD_GROUPS: + return "head" + if any(item["id"].startswith(prefix) for prefix in HEAD_ID_PREFIXES): + return "head" + if any(item["id"].startswith(prefix) for prefix in SHELL_ID_PREFIXES): + return "shell" + return "soft" + + +# ---------------------------------------------------------------- body setup + + +def joint_origins(glb: Glb) -> dict[str, np.ndarray]: + """Bind-pose world origin of every joint, from the inverse bind matrices.""" + skin = glb.json["skins"][0] + matrices = glb.accessor(skin["inverseBindMatrices"]).astype(np.float64) + out = {} + for slot, node in enumerate(skin["joints"]): + inverse = matrices[slot].reshape(4, 4).T # glTF matrices are column-major + out[glb.json["nodes"][node]["name"]] = np.linalg.inv(inverse)[:3, 3] + return out + + +def body_geometry(path: str): + """(surface, resampled body, joint origins).""" + glb = Glb.load(path) + joint_names = [glb.json["nodes"][index]["name"] + for index in glb.json["skins"][0]["joints"]] + positions, faces, joints, weights = [], [], [], [] + base = 0 + for primitive in glb.json["meshes"][0]["primitives"]: + vertex = glb.accessor(primitive["attributes"]["POSITION"]).astype(np.float64) + positions.append(vertex) + joints.append(glb.accessor(primitive["attributes"]["JOINTS_0"]).astype(np.int64)) + weights.append(glb.accessor(primitive["attributes"]["WEIGHTS_0"]).astype(np.float64)) + faces.append(glb.accessor(primitive["indices"]).astype(np.int64).reshape(-1, 3) + base) + base += len(vertex) + vertices = np.concatenate(positions) + face_array = np.concatenate(faces) + shares = FIELD.region_weights(joint_names, np.concatenate(joints), + np.concatenate(weights)) + return (FIELD.Surface(vertices, face_array), + FIELD.BodySample(vertices, face_array, shares), + joint_origins(glb)) + + +def head_transform(field: FIELD.DeformationField, sample: FIELD.BodySample): + """Similarity transform describing how the skull itself changed. + + Head accessories are re-seated with this instead of being warped, so a + helmet crest or a mohawk ridge survives the refit as authored. + """ + index = FIELD.REGION_INDEX["head"] + points, share = FIELD.region_samples(sample, index) + core = points[share >= 0.5] + weights = np.zeros((len(core), len(FIELD.REGIONS))) + weights[:, index] = 1.0 + target = core + field.sample(core, weights) + scale, rotation, shift = FIELD.umeyama(core, target) + scale = float(np.clip(scale, *HEAD_SCALE_CLAMP)) + return scale, rotation, shift + + +def build_fields() -> dict[str, dict]: + reference_bodies.materialise() + out = {} + for body_id in ("male", "female"): + reference, reference_sample, origins = body_geometry( + reference_bodies.path_for(body_id)) + target, target_sample, _ = body_geometry(CFG.RUNTIME_BODY[body_id]) + field = FIELD.DeformationField(reference_sample, target_sample, origins) + # Self-test: carrying the reference skin through the field must land it + # ON the promoted skin. Residual here is the field's own error and it + # bounds how well any garment can be refitted. + moved = reference.vertices + field.sample(reference.vertices, + reference_sample.vertex_weights) + _, _, residual = target.closest(moved) + _, _, raw = target.closest(reference.vertices) + scale, rotation, shift = head_transform(field, reference_sample) + out[body_id] = { + "reference": reference, "target": target, "field": field, + "head": (scale, rotation, shift), + "residual_mean_mm": round(float(residual.mean() * 1000), 3), + "residual_p90_mm": round(float(np.percentile(residual, 90) * 1000), 3), + "raw_mean_mm": round(float(raw.mean() * 1000), 3), + } + print(f"[field] {body_id}: skin gap {raw.mean() * 1000:.2f} -> " + f"{residual.mean() * 1000:.2f} mm mean, " + f"{np.percentile(residual, 90) * 1000:.2f} mm p90; " + f"head scale {scale:.4f}, shift " + f"{np.round(shift * 1000, 1).tolist()} mm") + return out + + +# ---------------------------------------------------------------- item mesh + + +class ItemMesh: + """One garment primitive's editable state.""" + + def __init__(self, glb: Glb, primitive: dict, joint_names: list[str]): + attributes = primitive["attributes"] + self.position = glb.accessor(attributes["POSITION"]).astype(np.float64) + self.has_normal = "NORMAL" in attributes + self.faces = (glb.accessor(primitive["indices"]).astype(np.int64).reshape(-1, 3) + if "indices" in primitive else + np.arange(len(self.position)).reshape(-1, 3)) + if "JOINTS_0" in attributes: + self.shares = FIELD.region_weights( + joint_names, + glb.accessor(attributes["JOINTS_0"]).astype(np.int64), + glb.accessor(attributes["WEIGHTS_0"]).astype(np.float64)) + else: + self.shares = None + key = np.round(self.position, 6) + _, welded = np.unique(key, axis=0, return_inverse=True) + self.welded = welded.reshape(-1) + self.welded_count = int(self.welded.max()) + 1 + self.edge_a, self.edge_b = FIELD.graph_edges(self.faces, self.welded, + self.welded_count) + + def components(self) -> list[np.ndarray]: + """Connected shells, by welded position.""" + parent = np.arange(self.welded_count) + + def find(x): + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + for a, b in zip(self.edge_a, self.edge_b): + ra, rb = find(a), find(b) + if ra != rb: + parent[max(ra, rb)] = min(ra, rb) + labels = np.array([find(x) for x in range(self.welded_count)])[self.welded] + return [np.where(labels == root)[0] for root in np.unique(labels)] + + def recompute_normals(self) -> np.ndarray: + """Area-weighted per-index normals; split vertices keep their hard edges.""" + a = self.position[self.faces[:, 0]] + b = self.position[self.faces[:, 1]] + c = self.position[self.faces[:, 2]] + face_normal = np.cross(b - a, c - a) + out = np.zeros_like(self.position) + for column in range(3): + np.add.at(out, self.faces[:, column], face_normal) + lengths = np.linalg.norm(out, axis=1, keepdims=True) + return out / np.where(lengths > 1e-20, lengths, 1.0) + + +# ---------------------------------------------------------------- fitting + + +def apply_soft(mesh: ItemMesh, field: FIELD.DeformationField) -> None: + mesh.position = mesh.position + field.sample(mesh.position, mesh.shares) + + +def apply_shell(mesh: ItemMesh, field: FIELD.DeformationField, + components: list[np.ndarray]) -> list[dict]: + displaced = mesh.position + field.sample(mesh.position, mesh.shares) + report = [] + for component in components: + source = mesh.position[component] + if len(component) < 4: + mesh.position[component] = displaced[component] + continue + scale, rotation, shift = FIELD.umeyama(source, displaced[component]) + scale = float(np.clip(scale, *SHELL_SCALE_CLAMP)) + mesh.position[component] = source @ (scale * rotation).T + shift + report.append({"vertices": int(len(component)), "scale": round(scale, 4), + "shift_mm": [round(float(v) * 1000.0, 2) for v in shift]}) + return report + + +def apply_head(mesh: ItemMesh, transform) -> None: + scale, rotation, shift = transform + mesh.position = mesh.position @ (scale * rotation).T + shift + + +def soft_clearance(mesh: ItemMesh, surface: FIELD.Surface, minimum: float, + before: np.ndarray) -> dict: + """Push cloth out of the skin, never pull it in, and keep it smooth. + + The target is `min(minimum, before)`, never `minimum` alone. Garments carry + real interior surfaces -- a boot lining, the inside of a glove finger, the + hidden back of a strap -- and forcing every vertex outside the skin would + turn those into balloons. A vertex that was already inside only has to stay + as deep as it was; a vertex that was clear must stay clear. + """ + target = np.minimum(minimum, before) + pushed = 0 + worst = 0.0 + for _ in range(CLEARANCE_ITERATIONS): + signed, _, direction = surface.signed(mesh.position) + deficit = np.maximum(0.0, target - signed) + touching = deficit > CLEARANCE_EPSILON + if not touching.any(): + break + pushed = max(pushed, int(touching.sum())) + worst = max(worst, float(deficit.max())) + correction = direction * deficit[:, None] + pooled = np.zeros((mesh.welded_count, 3)) + hits = np.bincount(mesh.welded, minlength=mesh.welded_count).astype(np.float64) + np.add.at(pooled, mesh.welded, correction) + pooled /= np.where(hits > 0, hits, 1.0)[:, None] + pooled = FIELD.relax(pooled, mesh.edge_a, mesh.edge_b, mesh.welded_count, + passes=1, weight=0.35) + smoothed = pooled[mesh.welded] + # Relaxation spreads the push over neighbours, which is what keeps cloth + # smooth, but it must not leave the offending vertex short. + achieved = np.maximum((smoothed * direction).sum(1), 1e-9) + gain = np.where(touching, np.clip(deficit / achieved, 1.0, 4.0), 1.0) + mesh.position = mesh.position + smoothed * gain[:, None] + return {"pushed": pushed, "max_push_mm": round(worst * 1000.0, 3)} + + +def shell_clearance(mesh: ItemMesh, surface: FIELD.Surface, minimum: float, + before: np.ndarray, components: list[np.ndarray]) -> dict: + """One bounded rigid nudge per shell. Hard surfaces are never re-warped.""" + target = np.minimum(minimum, before) + signed, _, direction = surface.signed(mesh.position) + deficit = np.maximum(0.0, target - signed) + pushed = 0 + worst = 0.0 + for component in components: + rows = component[deficit[component] > CLEARANCE_EPSILON] + if len(rows) == 0: + continue + pushed += len(rows) + worst = max(worst, float(deficit[rows].max())) + weight = deficit[rows] + axis = (direction[rows] * weight[:, None]).sum(0) + length = float(np.linalg.norm(axis)) + if length < 1e-12: + continue + axis = axis / length + along = np.maximum((direction[rows] * axis).sum(1), 0.25) + travel = float(min(SHELL_NUDGE_LIMIT, (deficit[rows] / along).max())) + mesh.position[component] = mesh.position[component] + axis * travel + return {"pushed": pushed, "max_push_mm": round(worst * 1000.0, 3)} + + +def measure(position: np.ndarray, before: np.ndarray, + surface: FIELD.Surface) -> dict: + """Fit quality is relative: did each vertex keep the gap it was authored with?""" + signed, _, _ = surface.signed(position) + drift = signed - before + lost = np.maximum(0.0, -drift) + return { + "penetrating": int((drift < -PENETRATION_LIMIT).sum()), + "max_penetration_mm": float(lost.max() * 1000.0), + "drift_p95_mm": float(np.percentile(np.abs(drift), 95) * 1000.0), + "drift_max_mm": float(np.abs(drift).max() * 1000.0), + "clearance_min_mm": float(signed.min() * 1000.0), + "clearance_mean_mm": float(signed.mean() * 1000.0), + } + + +# ---------------------------------------------------------------- export + + +def rewrite_glb(source: Glb, positions: dict[int, np.ndarray], + normals: dict[int, np.ndarray], destination: str) -> int: + """Copy a GLB through, replacing only POSITION and NORMAL.""" + gltf = json.loads(json.dumps({k: v for k, v in source.json.items() + if k not in ("accessors", "bufferViews", "buffers")})) + gltf["asset"] = {"version": "2.0", "generator": GENERATOR} + builder = GlbBuilder(gltf) + cache: dict[int, int] = {} + + def carry(index: int) -> int: + if index not in cache: + accessor = source.json["accessors"][index] + new_index = builder.add_accessor( + np.ascontiguousarray(source.accessor(index)), + target=(TARGET_ELEMENT_ARRAY_BUFFER + if accessor["type"] == "SCALAR" else TARGET_ARRAY_BUFFER)) + for key in ("min", "max", "normalized", "name"): + if key in accessor: + builder.json["accessors"][new_index][key] = accessor[key] + cache[index] = new_index + return cache[index] + + slot = 0 + for mesh in gltf.get("meshes", []): + for primitive in mesh["primitives"]: + rebuilt = {} + for name, accessor_index in primitive["attributes"].items(): + if name == "POSITION" and slot in positions: + rebuilt[name] = builder.add_accessor( + positions[slot].astype(np.float32), + target=TARGET_ARRAY_BUFFER, minmax=True) + elif name == "NORMAL" and slot in normals: + rebuilt[name] = builder.add_accessor( + normals[slot].astype(np.float32), target=TARGET_ARRAY_BUFFER) + else: + rebuilt[name] = carry(accessor_index) + primitive["attributes"] = rebuilt + if "indices" in primitive: + primitive["indices"] = carry(primitive["indices"]) + for target in primitive.get("targets", []): + for name, accessor_index in list(target.items()): + target[name] = carry(accessor_index) + slot += 1 + for skin in gltf.get("skins", []): + if "inverseBindMatrices" in skin: + skin["inverseBindMatrices"] = carry(skin["inverseBindMatrices"]) + for animation in gltf.get("animations", []): + for sampler in animation["samplers"]: + sampler["input"] = carry(sampler["input"]) + sampler["output"] = carry(sampler["output"]) + for image in gltf.get("images", []): + if "bufferView" in image: + payload, _ = source.buffer_view_bytes(image["bufferView"]) + image["bufferView"] = builder.add_view(bytes(payload)) + os.makedirs(os.path.dirname(destination), exist_ok=True) + return builder.write(destination) + + +# ---------------------------------------------------------------- item pass + + +def fit(original: Glb, joint_names: list[str], policy: str, body: dict): + """Run one item through one body's fit. Returns positions/normals/stats.""" + field, reference, target = body["field"], body["reference"], body["target"] + positions: dict[int, np.ndarray] = {} + normals: dict[int, np.ndarray] = {} + stats: list[dict] = [] + shells: list[dict] = [] + slot = 0 + for mesh_json in original.json["meshes"]: + for primitive in mesh_json["primitives"]: + mesh = ItemMesh(original, primitive, joint_names) + start = mesh.position.copy() + before, _, _ = reference.signed(start) + if policy == "head": + apply_head(mesh, body["head"]) + push = {"pushed": 0, "max_push_mm": 0.0} + elif policy == "shell": + components = mesh.components() + shells.extend(apply_shell(mesh, field, components)) + push = shell_clearance(mesh, target, CLEARANCE[policy], before, + components) + else: + apply_soft(mesh, field) + push = soft_clearance(mesh, target, CLEARANCE[policy], before) + moved = np.linalg.norm(mesh.position - start, axis=1) + stats.append({**measure(mesh.position, before, target), **push, + "vertices": int(len(start)), + "moved_mean_mm": float(moved.mean() * 1000.0), + "moved_max_mm": float(moved.max() * 1000.0)}) + positions[slot] = mesh.position + if mesh.has_normal: + normals[slot] = mesh.recompute_normals() + slot += 1 + return positions, normals, stats, shells + + +def cross_check(original: Glb, joint_names: list[str], + positions: dict[int, np.ndarray], body: dict) -> dict: + """How does one body's fit read on the other body?""" + worst = {"penetrating": 0, "max_penetration_mm": 0.0, "drift_p95_mm": 0.0} + slot = 0 + for mesh_json in original.json["meshes"]: + for primitive in mesh_json["primitives"]: + mesh = ItemMesh(original, primitive, joint_names) + before, _, _ = body["reference"].signed(mesh.position) + check = measure(positions[slot], before, body["target"]) + worst["penetrating"] += check["penetrating"] + worst["max_penetration_mm"] = max(worst["max_penetration_mm"], + check["max_penetration_mm"]) + worst["drift_p95_mm"] = max(worst["drift_p95_mm"], check["drift_p95_mm"]) + slot += 1 + return worst + + +def summarise(stats: list[dict]) -> dict: + return { + "moved_mean_mm": round(max(s["moved_mean_mm"] for s in stats), 3), + "moved_max_mm": round(max(s["moved_max_mm"] for s in stats), 3), + "penetrating": int(sum(s["penetrating"] for s in stats)), + "max_penetration_mm": round(max(s["max_penetration_mm"] for s in stats), 3), + "drift_p95_mm": round(max(s["drift_p95_mm"] for s in stats), 3), + "clearance_min_mm": round(min(s["clearance_min_mm"] for s in stats), 3), + "pushed": int(sum(s["pushed"] for s in stats)), + "max_push_mm": round(max(s["max_push_mm"] for s in stats), 3), + } + + +def refit_one(item: dict, bodies: dict, source_path: str, original: Glb) -> dict: + policy = policy_for(item) + joint_names = ([original.json["nodes"][i]["name"] + for i in original.json["skins"][0]["joints"]] + if original.json.get("skins") else []) + record: dict[str, Any] = { + "id": item["id"], "group": item["group"], "layer": item["layer"], + "slot": item["slot"], "glb": item["glb"], "policy": policy, + "skinned": bool(original.json.get("skins")), + "source_sha256": sha256_file(source_path), + } + if not joint_names: + record.update({"decision": "not_applicable", + "reason": "rigid bone-anchored accessory with no skin binding; " + "its placement follows the unchanged rest skeleton"}) + return record + + results = {body_id: fit(original, joint_names, policy, bodies[body_id]) + for body_id in ("male", "female")} + male_positions, male_normals, male_stats, male_shells = results["male"] + female_positions, female_normals, female_stats, _ = results["female"] + + shared = cross_check(original, joint_names, male_positions, bodies["female"]) + needs_variant = ((shared["max_penetration_mm"] > VARIANT_PENETRATION_MM + and shared["penetrating"] >= VARIANT_PENETRATION_COUNT) + or shared["drift_p95_mm"] > VARIANT_DRIFT_P95_MM) + + male_bytes = rewrite_glb(original, male_positions, male_normals, source_path) + record["male"] = {"path": item["glb"], "bytes": male_bytes, + "sha256": sha256_file(source_path), **summarise(male_stats)} + if male_shells: + record["male"]["shells"] = male_shells + record["shared_on_female"] = {k: round(v, 3) if isinstance(v, float) else v + for k, v in shared.items()} + + if needs_variant: + relative = os.path.join(FEMALE_SUBDIR, item["glb"]).replace(os.sep, "/") + female_path = os.path.normpath(os.path.join(CFG.EQUIPMENT, relative)) + female_bytes = rewrite_glb(original, female_positions, female_normals, + female_path) + record["female"] = {"path": relative, "bytes": female_bytes, + "sha256": sha256_file(female_path), + **summarise(female_stats)} + record["decision"] = "refit_both" + record["reason"] = ( + f"the male fit reads {shared['max_penetration_mm']:.1f} mm into the " + f"female body over {shared['penetrating']} vertices with " + f"{shared['drift_p95_mm']:.1f} mm p95 clearance drift") + else: + record["decision"] = "refit_shared" + record["reason"] = ( + f"the male fit holds on the female body: " + f"{shared['max_penetration_mm']:.1f} mm penetration over " + f"{shared['penetrating']} vertices, " + f"{shared['drift_p95_mm']:.1f} mm p95 clearance drift") + return record + + +def main() -> None: + CFG.ensure_dirs() + wanted = set(sys.argv[1:]) + with open(os.path.join(CFG.EQUIPMENT, "manifest.json"), encoding="utf-8") as handle: + manifest = json.load(handle) + bodies = build_fields() + + # Restore every catalogued source from the branch base before refitting, so + # a re-run refits the authored geometry instead of a refit of a refit. + baseline = os.path.join(CFG.BUILD_DIR, "apparel_baseline") + reference_bodies.stage_apparel(baseline, manifest) + + records = [] + for item in manifest["items"]: + if wanted and item["id"] not in wanted: + continue + if item["id"] in AUTHORED_ITEM_IDS: + records.append({"id": item["id"], "group": item["group"], + "layer": item["layer"], "slot": item["slot"], + "glb": item["glb"], "policy": "authored", + "skinned": True, "decision": "authored", + "reason": "generated per body by " + "author_starter_apparel.py"}) + print(f" {item['id']:<32} authored (skipped: per-body geometry)") + continue + live = os.path.normpath(os.path.join(CFG.EQUIPMENT, item["glb"])) + staged = os.path.join(baseline, f"{item['id']}.glb") + shutil.copyfile(staged, live) + record = refit_one(item, bodies, live, Glb.load(staged)) + records.append(record) + male = record.get("male") + print(f" {record['id']:<32} {record['policy']:<5} {record['decision']:<14}" + + (f" moved {male['moved_mean_mm']:6.2f}/{male['moved_max_mm']:6.2f} mm" + f" lost {male['max_penetration_mm']:6.2f} mm" + f" gap>={male['clearance_min_mm']:7.2f} mm" if male else "")) + + report = { + "generator": GENERATOR, + "fields": {body_id: {k: v for k, v in body.items() + if k.endswith("_mm")} for body_id, body in bodies.items()}, + "audited": len(records), + "refit_male": sum(1 for r in records if r.get("male")), + "female_variants": sum(1 for r in records if r.get("female")), + "shared": sum(1 for r in records if r["decision"] == "refit_shared"), + "not_applicable": sum(1 for r in records if r["decision"] == "not_applicable"), + "items": records, + } + with open(os.path.join(CFG.REPORT_DIR, "apparel_refit.json"), "w", + encoding="utf-8") as handle: + json.dump(report, handle, indent=2) + print(json.dumps({k: v for k, v in report.items() if k != "items"}, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/content-pipeline/labs/humanoid-runtime-refit/refit_config.py b/content-pipeline/labs/humanoid-runtime-refit/refit_config.py new file mode 100644 index 00000000..3ad9f42c --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/refit_config.py @@ -0,0 +1,128 @@ +"""Shared paths and constants for the humanoid runtime refit lab. + +One place for every path the promotion, refit, validation and proof steps agree +on, so a moved asset breaks loudly in one file instead of silently in five. +""" + +from __future__ import annotations + +import os + +LAB_DIR = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.abspath(os.path.join(LAB_DIR, "..", "..", "..")) + +ASSETS = os.path.join(REPO, "client-3d/public/assets") +PAWN_PACK = os.path.join(ASSETS, "pawn-pack") +EQUIPMENT = os.path.join(PAWN_PACK, "equipment") +FACE_KIT_ASSETS = os.path.join(ASSETS, "face-kit/assets") + +BUILD_DIR = os.path.join(LAB_DIR, "build") +TEXTURE_DIR = os.path.join(BUILD_DIR, "textures") +REPORT_DIR = os.path.join(LAB_DIR, "reports") +PROOF_DIR = os.path.join(LAB_DIR, "proof") + +# The FINAL reviewed bodies from Bunker's `body-refine-20260802` lane. These +# exact geometry-and-skin artifacts carry the hand-shaped shared-topology heads, +# neck grafts, and final body-transition pass. Their replacement hand meshes +# remain source evidence but are not promoted: they separate at the wrist under +# authored clips, so `canonical_hands.py` restores the pinned runtime hands. +# The refinement files intentionally carry no clips; promotion copies the +# hash-pinned 47-clip bank from `RUNTIME_SHELL` without re-exporting it. +# +# Source commit: d3fdfa2831bf938c0d9c8bf06f8796a00a935af5 +SOURCE_DIR = os.path.join(LAB_DIR, "source") +REFINED = { + "male": os.path.join(SOURCE_DIR, "male/pawn_male_refined.glb"), + "female": os.path.join(SOURCE_DIR, "female/pawn_female_refined.glb"), +} +REFINED_SIDECAR = { + "male": os.path.join(SOURCE_DIR, "male/pawn_male_refined.json"), + "female": os.path.join(SOURCE_DIR, "female/pawn_female_refined.json"), +} +REFINED_SHA = { + "male": "d5cb14b321a5f6024820e6c49eaa7f4200dca303336c95758792847f34211401", + "female": "bd99fdb32fc40291780d92b1494f07a428c439204b306a2a6d32d2bc403c0c8b", +} + +# The runtime SHELL: the pre-refit male body, whose skeleton order, animation +# bank and node layout the promoted bodies inherit byte-for-byte. It is read +# from the pinned git checkout in `build/reference/` rather than the live path, +# because the male promotion writes over that path. +RUNTIME_SHELL = os.path.join(BUILD_DIR, "reference/male.glb") +RUNTIME_BODY = { + "male": os.path.join(PAWN_PACK, "pawn_male.glb"), + "female": os.path.join(PAWN_PACK, "pawn_female.glb"), +} +# `game_pack.json` -> `pawns.male.bare_file`, which `pawns.ts` selects for a male +# with no leg coverage. The refit unified the old accommodation body and its +# full-volume sibling into one male, so the bare path is now the same mesh -- +# but it is still a separately loaded GLB, so it needs the same zone primitives +# or masks would silently do nothing on a bare-legged pawn. +RUNTIME_BODY_ALIAS = { + "male": (os.path.join(PAWN_PACK, "pawn_male_bare.glb"),), + "female": (), +} +# Skin materials are the canonical coverage zones (`body_zones.ZONES`), one +# primitive each, so the runtime can hide exactly what a garment encloses. Both +# bodies use the same vocabulary; the old `hum_{m,f}_{body,head}` split carried +# no rendering difference (identical PBR) and no runtime meaning. +MATERIALS = { + "male": {"face": "RB_Face"}, + "female": {"face": "RB_Face"}, +} + +# `client-3d/src/render/pawns.ts` -> defaultSkinColor. The promoted GLB has to +# read correctly on its own, so its baked default tone is the runtime default. +DEFAULT_SKIN_HEX = "#cc9978" + +# Neutral, non-uncanny default from the reviewed style set. `regal` and +# `veteran` eyes are excluded by the 2026-08-02 art review +# (client-3d/src/humanoid-lab/creator.ts REJECTED_EYE_STYLES). +DEFAULT_FACE = { + "skinColor": DEFAULT_SKIN_HEX, + "eyeColor": "#7eb7c7", + "browColor": "#35241e", + "lipColor": "#74443f", + "styles": {"eyes": "stoic", "brows": "stoic", "nose": "stoic", "mouth": "stoic"}, +} + +FACE_ATLASES = { + "eyes": "face-eyes-v3.png", + "brows": "face-brows-v3.png", + "noses": "face-noses-v3.png", + "mouths": "face-mouths-v3.png", + "semantic": "face-iris-mask-v3.png", +} + +FACE_TEXTURE_SIZE = 256 # matches render/faceDecal.ts FACE_TEXTURE_SIZE + +# The reserved `RB_Face` island is a 114.6 x 177 mm shield -- 1 : 1.54, far +# taller than the kit's square canvas. Painting the canvas over the whole panel +# stretches every feature vertically by that ratio. The canvas is drawn into the +# TOP `FACE_PANEL_V_SPAN` of the panel instead, anchored at the brow line the +# panel's own top edge follows; the remainder is flat tone, which is exactly +# what the chin below the mouth should be. +FACE_PANEL_V_SPAN = 0.82 +# Keep the static transparent face overlay just proud of its opaque skin base. +# This matches `client-3d/src/render/faceDecal.ts`. +FACE_OVERLAY_INFLATE = 0.0015 +SKIN_TEXTURE_SIZE = 512 + +# Gentle ambient occlusion only: enough tonal definition to read the faceted +# form, never a dirt or pore pass. AO is remapped into [AO_FLOOR, 1]. +# +# The ray length is deliberately short. At 0.22 m every neck vertex saw the jaw +# and saturated at the floor, which rendered as a hard brown choker rather than +# contact shading; 0.07 m is about a jaw-to-throat gap, so occlusion falls off +# with the crevice instead of filling it. AO_SMOOTH_PASSES then relaxes the +# field over the surface graph so a single sparse edge loop cannot draw a band. +AO_FLOOR = 0.87 +AO_GAMMA = 0.9 +AO_DISTANCE = 0.07 +AO_SMOOTH_PASSES = 3 +AO_SMOOTH_WEIGHT = 0.55 + + +def ensure_dirs() -> None: + for path in (BUILD_DIR, TEXTURE_DIR, REPORT_DIR, PROOF_DIR): + os.makedirs(path, exist_ok=True) diff --git a/content-pipeline/labs/humanoid-runtime-refit/refit_female_armor.py b/content-pipeline/labs/humanoid-runtime-refit/refit_female_armor.py new file mode 100644 index 00000000..98fb9750 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/refit_female_armor.py @@ -0,0 +1,173 @@ +"""Sit the female armour on the female body instead of hovering over it. + +Every armour piece was fitted to the male frame and copied across. Measured +against the bodies, the nearest point of each female variant to her skin: + + piece male female + Gorget 2.5 mm 14.0 mm + Bicep_L 1.1 mm 9.7 mm + Bicep_R 1.5 mm 10.1 mm + Nape_Reinforcement 4.6 mm 9.9 mm + Reinforcement 4.8 mm 18.1 mm + +The male pieces rest on the skin. The female ones float up to 18 mm clear, and +their contact fraction is 0.000 - they never touch her anywhere. On a narrower +frame the copied shell simply stands off. + +The correction is a uniform draw-in toward the body's vertical axis, not a +projection onto the skin. Projection would shrink-wrap the armour and destroy +the authored silhouette; a uniform scale keeps the piece the shape the artist +made and just seats it. Height is untouched, so nothing slides up or down the +torso, and the scale is solved per piece against the measured gap. + + blender --background --factory-startup --python refit_female_armor.py + +Writes the female variants in place. Re-run `verify_wearable_fit.py` after. +""" +from __future__ import annotations + +import json +import os +import struct +import sys + +import numpy as np +from mathutils import Vector +from mathutils.bvhtree import BVHTree + +LAB_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, LAB_DIR) + +import pose_probe as POSE # noqa: E402 + +REPO = os.path.abspath(os.path.join(LAB_DIR, "..", "..", "..")) +EQUIPMENT = os.path.join(REPO, "client-3d", "public", "assets", "pawn-pack", "equipment") + +#: Pieces to seat, and the standoff each should end up with. These are the +#: male values: the male set is the reference for how close the armour sits. +TARGETS_MM = { + "Armor/Gorget.glb": 2.5, + "Armor/Bicep_L.glb": 1.1, + "Armor/Bicep_R.glb": 1.5, + "Armor/Nape_Reinforcement.glb": 4.6, + "Armor/Reinforcement.glb": 4.8, +} +#: Solve tolerance and iteration cap for the per-piece scale search. +TOLERANCE_MM = 0.6 +MAX_STEPS = 24 + +CT = {5120: ("b", 1), 5121: ("B", 1), 5122: ("h", 2), 5123: ("H", 2), + 5125: ("I", 4), 5126: ("f", 4)} +NC = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16} + + +def read_glb(path): + raw = open(path, "rb").read() + assert raw[:4] == b"glTF", path + json_len = struct.unpack(" None: + body = POSE.Body("female") + tree = BVHTree.FromPolygons([tuple(p) for p in body.position], + [tuple(f) for f in body.faces]) + axis_z = float(np.median(body.position[:, 2])) + + for relative, target in TARGETS_MM.items(): + path = os.path.join(EQUIPMENT, "Female", relative) + if not os.path.exists(path): + print(f"[armour] {relative}: missing") + continue + doc, chunks = read_glb(path) + bin_index = next(i for i, (k, _) in enumerate(chunks) if k == 0x004E4942) + original = bytearray(chunks[bin_index][1]) + slots = positions(doc, original) + before = nearest_gap_mm(tree, [(x, y, z) for _o, x, y, z in slots]) + + # Draw the piece toward the body's vertical axis. Height is left alone: + # the armour is standing off sideways, not sitting too high. + low, high = 0.55, 1.0 + chosen, achieved = 1.0, before + for _ in range(MAX_STEPS): + factor = 0.5 * (low + high) + moved = [(x * factor, y, axis_z + (z - axis_z) * factor) + for _o, x, y, z in slots] + gap = nearest_gap_mm(tree, moved) + chosen, achieved = factor, gap + if abs(gap - target) <= TOLERANCE_MM: + break + if gap > target: + high = factor # still floating, pull in harder + else: + low = factor # gone too far, ease off + blob = bytearray(original) + for offset, x, y, z in slots: + struct.pack_into(" {achieved:5.1f} mm " + f"(target {target:.1f}, scale {chosen:.4f})") + + +if __name__ == "__main__": + main() diff --git a/content-pipeline/labs/humanoid-runtime-refit/refit_field.py b/content-pipeline/labs/humanoid-runtime-refit/refit_field.py new file mode 100644 index 00000000..cd7f36ec --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/refit_field.py @@ -0,0 +1,501 @@ +"""Region-aware old-body -> new-body deformation field. + +Every catalogued garment is skinned to the same 50-joint rig with the same bind +pose, so a refit only has to move bind-pose POSITIONS: joints, weights, indices, +materials and UVs stay untouched and the piece keeps deforming exactly as it was +authored. + +The field is RADIAL, not nearest-point. A nearest-point wrap silently collapses +wherever the change is large: the promoted male abdomen stands up to 8 cm +further forward than the reference body, and the nearest patch of new skin to an +old belly vertex is a few millimetres away on a neighbouring facet, so the +measured "displacement" comes out at 4 mm and the trousers end up inside the +pelvis. Instead each body is resampled into per-region rig-space profiles -- +r(axial, angle) around a limb axis, r(azimuth, elevation) for the head -- and a +garment vertex is moved by the CHANGE IN PROFILE under it, along its own radial +direction. That is what preserves an authored clearance: cloth 30 mm off a thigh +stays 30 mm off the new thigh, and a pauldron keeps standing proud. + +Regions come from the garment's own skin weights, blended, which is the only +body-part signal a garment reliably carries. +""" + +from __future__ import annotations + +import numpy as np +from scipy.spatial import cKDTree + +# ---------------------------------------------------------------- regions + +BONE_REGION = {} +for _bone in ("root", "pelvis", "spine_01", "spine_03"): + BONE_REGION[_bone] = "torso" +BONE_REGION["neck_01"] = "neck" +BONE_REGION["head"] = "head" +for _side in ("l", "r"): + # The clavicle belongs to the ARM profile, not the trunk. A single vertical + # torso cylinder cannot describe the shoulder girdle: at shoulder height its + # radius jumps from the neck to the deltoid tip, so two bodies tessellated + # differently disagree by 8 cm in the same cell. Measured from the shoulder + # joint outward along the arm axis, the same surface is a clean profile. + BONE_REGION[f"clavicle_{_side}"] = f"arm_{_side}" + BONE_REGION[f"upperarm_{_side}"] = f"arm_{_side}" + BONE_REGION[f"lowerarm_{_side}"] = f"arm_{_side}" + BONE_REGION[f"hand_{_side}"] = f"hand_{_side}" + for _finger in ("index", "middle", "ring", "pinky", "thumb"): + for _link in ("01", "02", "03"): + BONE_REGION[f"{_finger}_{_link}_{_side}"] = f"hand_{_side}" + BONE_REGION[f"thigh_{_side}"] = f"leg_{_side}" + BONE_REGION[f"calf_{_side}"] = f"leg_{_side}" + BONE_REGION[f"foot_{_side}"] = f"foot_{_side}" + +REGIONS = ("torso", "neck", "head", "arm_l", "arm_r", "hand_l", "hand_r", + "leg_l", "leg_r", "foot_l", "foot_r") +REGION_INDEX = {name: index for index, name in enumerate(REGIONS)} + +#: How each region is parameterised. `cyl` sweeps an axis, `sph` a centre. +REGION_FRAME = { + "torso": ("cyl", "pelvis", (0.0, 1.0, 0.0)), + "neck": ("cyl", "neck_01", (0.0, 1.0, 0.0)), + "head": ("sph", "head", None), + "arm_l": ("cyl", "upperarm_l", (1.0, 0.0, 0.0)), + "arm_r": ("cyl", "upperarm_r", (-1.0, 0.0, 0.0)), + "hand_l": ("cyl", "hand_l", (1.0, 0.0, 0.0)), + "hand_r": ("cyl", "hand_r", (-1.0, 0.0, 0.0)), + "leg_l": ("cyl", "thigh_l", (0.0, -1.0, 0.0)), + "leg_r": ("cyl", "thigh_r", (0.0, -1.0, 0.0)), + "foot_l": ("cyl", "foot_l", (0.0, 0.0, 1.0)), + "foot_r": ("cyl", "foot_r", (0.0, 0.0, 1.0)), +} + +#: Bone weight below which a region stops claiming a vertex at all. +REGION_WEIGHT_FLOOR = 0.06 +#: Profile grid. Coarse on purpose: it must describe a silhouette, not a facet. +AXIAL_CELLS = 30 +ANGULAR_CELLS = 32 +PROFILE_SMOOTH_PASSES = 1 + + +def region_weights(joint_names: list[str], joints: np.ndarray, + weights: np.ndarray) -> np.ndarray: + """Per-vertex weight in each region, from the skin binding.""" + lookup = np.array([REGION_INDEX[BONE_REGION.get(name, "torso")] + for name in joint_names], dtype=np.int64) + out = np.zeros((len(joints), len(REGIONS))) + for column in range(joints.shape[1]): + np.add.at(out, (np.arange(len(joints)), lookup[joints[:, column]]), + weights[:, column]) + total = out.sum(1, keepdims=True) + return out / np.where(total > 1e-9, total, 1.0) + + +def dominant_region(joint_names: list[str], joints: np.ndarray, + weights: np.ndarray) -> np.ndarray: + return np.argmax(region_weights(joint_names, joints, weights), axis=1) + + +# ---------------------------------------------------------------- geometry + + +def point_triangle(points, a, b, c): + """Closest point on each triangle (Ericson, Real-Time Collision Detection).""" + ab, ac, ap = b - a, c - a, points - a + d1 = (ab * ap).sum(1) + d2 = (ac * ap).sum(1) + out = np.empty_like(points) + m1 = (d1 <= 0) & (d2 <= 0) + out[m1] = a[m1] + bp = points - b + d3 = (ab * bp).sum(1) + d4 = (ac * bp).sum(1) + m2 = (~m1) & (d3 >= 0) & (d4 <= d3) + out[m2] = b[m2] + vc = d1 * d4 - d3 * d2 + m3 = (~m1) & (~m2) & (vc <= 0) & (d1 >= 0) & (d3 <= 0) + denom = np.where(d1 - d3 == 0, 1.0, d1 - d3) + out[m3] = (a + (d1 / denom)[:, None] * ab)[m3] + cp = points - c + d5 = (ab * cp).sum(1) + d6 = (ac * cp).sum(1) + m4 = (~m1) & (~m2) & (~m3) & (d6 >= 0) & (d5 <= d6) + out[m4] = c[m4] + vb = d5 * d2 - d1 * d6 + m5 = (~m1) & (~m2) & (~m3) & (~m4) & (vb <= 0) & (d2 >= 0) & (d6 <= 0) + denom = np.where(d2 - d6 == 0, 1.0, d2 - d6) + out[m5] = (a + (d2 / denom)[:, None] * ac)[m5] + va = d3 * d6 - d5 * d4 + m6 = ((~m1) & (~m2) & (~m3) & (~m4) & (~m5) & (va <= 0) + & ((d4 - d3) >= 0) & ((d5 - d6) >= 0)) + denom = np.where((d4 - d3) + (d5 - d6) == 0, 1.0, (d4 - d3) + (d5 - d6)) + out[m6] = (b + ((d4 - d3) / denom)[:, None] * (c - b))[m6] + rest = ~(m1 | m2 | m3 | m4 | m5 | m6) + denom = np.where(np.abs(va + vb + vc) < 1e-20, 1.0, va + vb + vc) + out[rest] = (a + (vb / denom)[:, None] * ab + (vc / denom)[:, None] * ac)[rest] + return out + + +class Surface: + """Triangle soup with closest-point queries and a signed-distance sign.""" + + def __init__(self, vertices: np.ndarray, faces: np.ndarray): + self.vertices = vertices + self.faces = faces + self.a = vertices[faces[:, 0]] + self.b = vertices[faces[:, 1]] + self.c = vertices[faces[:, 2]] + centroid = (self.a + self.b + self.c) / 3.0 + normal = np.cross(self.b - self.a, self.c - self.a) + lengths = np.linalg.norm(normal, axis=1, keepdims=True) + self.face_normal = normal / np.where(lengths > 1e-20, lengths, 1.0) + self.face_area = 0.5 * lengths.reshape(-1) + self.tree = cKDTree(centroid) + # Angle-weighted vertex pseudonormals: the sign of a signed distance + # taken from a single facet normal flips wherever the closest point + # lands on an edge, which is most of a low-poly body. + pseudo = np.zeros_like(vertices) + for column in range(3): + np.add.at(pseudo, faces[:, column], + self.face_normal * self.face_area[:, None]) + lengths = np.linalg.norm(pseudo, axis=1, keepdims=True) + self.vertex_normal = pseudo / np.where(lengths > 1e-20, lengths, 1.0) + + def closest(self, points: np.ndarray, k: int = 28): + k = min(k, len(self.faces)) + _, candidates = self.tree.query(points, k=k, workers=-1) + if candidates.ndim == 1: + candidates = candidates[:, None] + best = np.full(len(points), np.inf) + hit = np.zeros_like(points) + face = np.zeros(len(points), dtype=np.int64) + for column in range(candidates.shape[1]): + index = candidates[:, column] + projected = point_triangle(points, self.a[index], self.b[index], self.c[index]) + distance = np.linalg.norm(projected - points, axis=1) + better = distance < best + best[better] = distance[better] + hit[better] = projected[better] + face[better] = index[better] + return hit, face, best + + def signed(self, points: np.ndarray): + """(signed distance, closest point, outward direction). Positive = outside.""" + hit, face, distance = self.closest(points) + triangle = self.faces[face] + # Barycentric blend of the three pseudonormals, which stays continuous + # across edges and vertices unlike the facet normal. + a, b, c = self.a[face], self.b[face], self.c[face] + normal = interpolate_normal(hit, a, b, c, self.vertex_normal[triangle]) + offset = points - hit + length = np.linalg.norm(offset, axis=1, keepdims=True) + direction = np.where(length > 1e-12, offset / np.where(length > 1e-12, length, 1.0), + normal) + sign = np.where((offset * normal).sum(1) >= 0.0, 1.0, -1.0) + return distance * sign, hit, direction * sign[:, None] + + +def interpolate_normal(point, a, b, c, normals): + v0, v1, v2 = b - a, c - a, point - a + d00 = (v0 * v0).sum(1) + d01 = (v0 * v1).sum(1) + d11 = (v1 * v1).sum(1) + d20 = (v2 * v0).sum(1) + d21 = (v2 * v1).sum(1) + denom = d00 * d11 - d01 * d01 + denom = np.where(np.abs(denom) < 1e-20, 1.0, denom) + v = (d11 * d20 - d01 * d21) / denom + w = (d00 * d21 - d01 * d20) / denom + u = 1.0 - v - w + blended = (normals[:, 0] * u[:, None] + normals[:, 1] * v[:, None] + + normals[:, 2] * w[:, None]) + lengths = np.linalg.norm(blended, axis=1, keepdims=True) + return blended / np.where(lengths > 1e-20, lengths, 1.0) + + +def graph_edges(faces: np.ndarray, welded: np.ndarray, count: int): + mapped = welded[faces] + a = np.concatenate([mapped[:, 0], mapped[:, 1], mapped[:, 2]]) + b = np.concatenate([mapped[:, 1], mapped[:, 2], mapped[:, 0]]) + return np.concatenate([a, b]), np.concatenate([b, a]) + + +def relax(values: np.ndarray, edge_a: np.ndarray, edge_b: np.ndarray, + count: int, passes: int, weight: float) -> np.ndarray: + degree = np.bincount(edge_a, minlength=count).astype(np.float64) + degree[degree == 0] = 1.0 + out = values.copy() + for _ in range(passes): + total = np.zeros_like(out) + np.add.at(total, edge_a, out[edge_b]) + out = (1.0 - weight) * out + weight * (total / degree[:, None]) + return out + + +# ---------------------------------------------------------------- profiles + + +BARYCENTRIC = np.array([(1 / 6, 1 / 6, 4 / 6), (1 / 6, 4 / 6, 1 / 6), + (4 / 6, 1 / 6, 1 / 6), (1 / 3, 1 / 3, 1 / 3), + (1 / 2, 1 / 2, 0.0), (0.0, 1 / 2, 1 / 2), + (1 / 2, 0.0, 1 / 2), (1.0, 0.0, 0.0), + (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)]) + + +def resample(vertices, faces, per_vertex): + """Barycentric surface samples plus the interpolated per-vertex payload.""" + points, payload = [], [] + a, b, c = vertices[faces[:, 0]], vertices[faces[:, 1]], vertices[faces[:, 2]] + pa, pb, pc = per_vertex[faces[:, 0]], per_vertex[faces[:, 1]], per_vertex[faces[:, 2]] + for u, v, w in BARYCENTRIC: + points.append(a * u + b * v + c * w) + payload.append(pa * u + pb * v + pc * w) + return np.concatenate(points), np.concatenate(payload) + + +class Frame: + """Rig-space parameterisation of one body region.""" + + def __init__(self, kind: str, origin: np.ndarray, axis: np.ndarray | None): + self.kind = kind + self.origin = origin + if kind == "cyl": + axis = axis / np.linalg.norm(axis) + helper = np.array([0.0, 0.0, 1.0]) + if abs(float(axis @ helper)) > 0.9: + helper = np.array([1.0, 0.0, 0.0]) + u = np.cross(helper, axis) + u /= np.linalg.norm(u) + self.axis, self.u, self.v = axis, u, np.cross(axis, u) + + def decompose(self, points: np.ndarray): + """(axial coordinate, angle in [-pi,pi], radius, unit radial direction).""" + offset = points - self.origin + if self.kind == "cyl": + axial = offset @ self.axis + planar = offset - axial[:, None] * self.axis + radius = np.linalg.norm(planar, axis=1) + angle = np.arctan2(planar @ self.v, planar @ self.u) + direction = planar / np.where(radius > 1e-9, radius, 1.0)[:, None] + return axial, angle, radius, direction + radius = np.linalg.norm(offset, axis=1) + safe = np.where(radius > 1e-9, radius, 1.0) + direction = offset / safe[:, None] + return (np.arcsin(np.clip(offset[:, 1] / safe, -1.0, 1.0)), + np.arctan2(offset[:, 2], offset[:, 0]), radius, direction) + + +class Profile: + """r(axial, angle) for one region of one body, on a fixed grid.""" + + def __init__(self, frame: Frame, points: np.ndarray, weights: np.ndarray, + axial_range: tuple[float, float]): + self.frame = frame + self.low, self.high = axial_range + axial, angle, radius, _ = frame.decompose(points) + span = max(self.high - self.low, 1e-6) + row = np.clip(((axial - self.low) / span * AXIAL_CELLS).astype(int), + 0, AXIAL_CELLS - 1) + column = np.clip(((angle + np.pi) / (2 * np.pi) * ANGULAR_CELLS).astype(int), + 0, ANGULAR_CELLS - 1) + cell = row * ANGULAR_CELLS + column + size = AXIAL_CELLS * ANGULAR_CELLS + # Share-weighted mean radius per cell. A max would take the outer hull + # and lose every concavity a garment has to follow (waist, armpit, + # crotch); a plain mean would let a half-claimed vertex from the + # neighbouring region drag the profile. + total = np.zeros(size) + share = np.zeros(size) + np.add.at(total, cell, radius * weights) + np.add.at(share, cell, weights) + grid = np.where(share > 1e-9, total / np.where(share > 1e-9, share, 1.0), 0.0) + filled = share > 1e-3 + self.filled = filled.reshape(AXIAL_CELLS, ANGULAR_CELLS) + self.grid = self._fill(grid.reshape(AXIAL_CELLS, ANGULAR_CELLS), self.filled) + + @staticmethod + def _fill(grid: np.ndarray, filled: np.ndarray) -> np.ndarray: + out = grid.copy() + known = filled.copy() + for _ in range(max(AXIAL_CELLS, ANGULAR_CELLS)): + if known.all(): + break + total = np.zeros_like(out) + count = np.zeros_like(out) + for shift, axis in ((1, 0), (-1, 0), (1, 1), (-1, 1)): + rolled = np.roll(out, shift, axis=axis) + mask = np.roll(known, shift, axis=axis) + if axis == 0: # axial edges do not wrap + if shift == 1: + mask[0] = False + else: + mask[-1] = False + total += np.where(mask, rolled, 0.0) + count += mask + fresh = (~known) & (count > 0) + out[fresh] = (total[fresh] / count[fresh]) + known |= fresh + for _ in range(PROFILE_SMOOTH_PASSES): + neighbours = (np.roll(out, 1, 1) + np.roll(out, -1, 1) + + np.vstack([out[:1], out[:-1]]) + + np.vstack([out[1:], out[-1:]])) + out = 0.5 * out + 0.5 * neighbours / 4.0 + return out + + def lookup(self, axial: np.ndarray, angle: np.ndarray) -> np.ndarray: + """Bilinear radius lookup, clamped axially and wrapped angularly.""" + return self._bilinear(self.grid, axial, angle) + + def _bilinear(self, grid: np.ndarray, axial: np.ndarray, + angle: np.ndarray) -> np.ndarray: + span = max(self.high - self.low, 1e-6) + row = np.clip((axial - self.low) / span * AXIAL_CELLS - 0.5, + 0.0, AXIAL_CELLS - 1.0) + column = (angle + np.pi) / (2 * np.pi) * ANGULAR_CELLS - 0.5 + r0 = np.floor(row).astype(int) + r1 = np.minimum(r0 + 1, AXIAL_CELLS - 1) + fr = row - r0 + c0 = np.floor(column).astype(int) % ANGULAR_CELLS + c1 = (c0 + 1) % ANGULAR_CELLS + fc = column - np.floor(column) + top = grid[r0, c0] * (1 - fc) + grid[r0, c1] * fc + bottom = grid[r1, c0] * (1 - fc) + grid[r1, c1] * fc + return top * (1 - fr) + bottom * fr + + +#: How far a region's axial extent is allowed to be rescaled. The rest skeleton +#: is identical between the bodies, so any honest change here is small -- a +#: shorter toe box, a slightly deeper heel -- and a wide clamp would only let a +#: sampling artefact stretch a limb. +AXIAL_SCALE_CLAMP = (0.90, 1.10) +#: Only strongly-claimed samples define a region's axial extent. +AXIAL_CORE_SHARE = 0.70 +#: Regions whose axial extent is allowed to change at all. +#: +#: Both bodies share one rest skeleton, so every limb's axial extent is pinned +#: by its bone lengths: an arm or a leg CANNOT have got longer, and measuring +#: one as 10 % longer only means the two bodies distribute their skin weights +#: over differently tessellated surfaces. The foot is the exception -- its toe +#: box extends past the last joint, so its length is real free geometry (the +#: promoted male toe is 8 mm shorter, the female 31 mm) and a boot cap that +#: ignores it hangs off the end. +AXIAL_REGIONS = {"foot_l", "foot_r"} + + +class DeformationField: + """Radial reference-body -> promoted-body displacement, blended by region.""" + + def __init__(self, reference, promoted, joint_origins: dict[str, np.ndarray]): + self.regions: dict[str, tuple] = {} + for name in REGIONS: + kind, anchor, axis = REGION_FRAME[name] + index = REGION_INDEX[name] + origin = (joint_origins[anchor] if kind == "cyl" + else region_centre(reference, index)) + frame = Frame(kind, origin, np.array(axis, dtype=np.float64) + if axis is not None else None) + axial_range = combined_range(frame, reference, promoted, index) + if axial_range is None: + continue + scale, shift = (axial_map(frame, reference, promoted, index) + if name in AXIAL_REGIONS else (1.0, 0.0)) + self.regions[name] = ( + frame, + Profile(frame, *region_samples(reference, index), axial_range), + Profile(frame, *region_samples(promoted, index), axial_range), + scale, shift, + ) + + def sample(self, points: np.ndarray, weights: np.ndarray) -> np.ndarray: + out = np.zeros_like(points) + total = np.zeros(len(points)) + for name, (frame, old, new, scale, shift) in self.regions.items(): + share = weights[:, REGION_INDEX[name]] + active = np.where(share > REGION_WEIGHT_FLOOR)[0] + if len(active) == 0: + continue + axial, angle, _, direction = frame.decompose(points[active]) + mapped = axial * scale + shift + # No confidence gate. Damping cells by how densely they were + # sampled measured strictly worse (reference-skin residual 0.89 mm + # -> 1.15 mm, female feet 8.9 mm -> 13.1 mm): the reference body is + # coarse, so most cells are legitimately interpolated from their + # neighbours and discounting those throws away real displacement. + delta = new.lookup(mapped, angle) - old.lookup(axial, angle) + displacement = direction * delta[:, None] + if frame.kind == "cyl": + # A shorter toe box or a deeper heel is an AXIAL change; radius + # alone cannot express it and the boot cap would hang past it. + displacement = displacement + frame.axis * (mapped - axial)[:, None] + out[active] += displacement * share[active, None] + total[active] += share[active] + return out / np.where(total > 1e-9, total, 1.0)[:, None] + + +def axial_map(frame: Frame, reference, promoted, index: int) -> tuple[float, float]: + """Robust axial extent mapping between the two bodies for one region.""" + spans = [] + for body in (reference, promoted): + points, share = region_samples(body, index) + core = points[share >= AXIAL_CORE_SHARE] + if len(core) < 8: + return 1.0, 0.0 + axial, _, _, _ = frame.decompose(core) + spans.append((float(axial.min()), float(axial.max()))) + (low_old, high_old), (low_new, high_new) = spans + if high_old - low_old < 1e-6: + return 1.0, 0.0 + scale = float(np.clip((high_new - low_new) / (high_old - low_old), *AXIAL_SCALE_CLAMP)) + return scale, low_new - low_old * scale + + +class BodySample: + """A body resampled once: surface points plus per-region weight.""" + + def __init__(self, vertices, faces, weights): + self.vertex_weights = weights + self.points, self.weights = resample(vertices, faces, weights) + + +def region_samples(body: BodySample, index: int): + share = body.weights[:, index] + keep = share > REGION_WEIGHT_FLOOR + return body.points[keep], share[keep] + + +def region_centre(body: BodySample, index: int) -> np.ndarray: + points, share = region_samples(body, index) + if len(points) == 0: + return np.zeros(3) + return (points * share[:, None]).sum(0) / share.sum() + + +def combined_range(frame: Frame, reference: BodySample, promoted: BodySample, + index: int): + values = [] + for body in (reference, promoted): + points, _ = region_samples(body, index) + if len(points) == 0: + continue + axial, _, _, _ = frame.decompose(points) + values.append(axial) + if not values: + return None + joined = np.concatenate(values) + return float(joined.min()), float(joined.max()) + + +def umeyama(source: np.ndarray, destination: np.ndarray) -> tuple[float, np.ndarray, np.ndarray]: + """Least-squares similarity transform (rotation + uniform scale + shift).""" + mu_source = source.mean(0) + mu_destination = destination.mean(0) + centred_source = source - mu_source + centred_destination = destination - mu_destination + covariance = centred_destination.T @ centred_source / len(source) + u, sigma, vt = np.linalg.svd(covariance) + correction = np.eye(3) + if np.linalg.det(u @ vt) < 0: + correction[2, 2] = -1.0 + rotation = u @ correction @ vt + variance = (centred_source ** 2).sum() / len(source) + scale = float((sigma * np.diag(correction)).sum() / variance) if variance > 1e-18 else 1.0 + shift = mu_destination - scale * rotation @ mu_source + return scale, rotation, shift diff --git a/content-pipeline/labs/humanoid-runtime-refit/refit_stage.py b/content-pipeline/labs/humanoid-runtime-refit/refit_stage.py new file mode 100644 index 00000000..f4ac863c --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/refit_stage.py @@ -0,0 +1,166 @@ +"""Blender scene assembly shared by the refit and its proof renders. + +One place that knows how to put a runtime body, an outfit and a pose in front of +a camera, so the fitting step and the evidence step cannot drift apart. Lighting +and framing follow the humanoid adjustment lab's conventions -- same three-area +key/fill/rim rig, same dark neutral world -- because the approved bodies were +reviewed under exactly that light. +""" + +from __future__ import annotations + +import math +import os +import sys + +import bpy +from mathutils import Vector + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import refit_config as CFG # noqa: E402 + +VIEWS = { + "front": (0.0, 4.0), + "threequarter": (35.0, 10.0), + "side": (90.0, 4.0), + "back": (180.0, 4.0), +} + +# Close crops the brief names: every place apparel touches the body. +CROPS = { + "neck_head": ((0.0, 0.0, 1.62), 0.62, 20.0, 8.0, 70.0), + "shoulder_armpit": ((0.20, 0.0, 1.36), 0.66, 40.0, 14.0, 70.0), + "waist_pelvis": ((0.0, 0.0, 0.95), 0.80, 25.0, 6.0, 70.0), + "wrist_glove": ((0.80, 0.0, 1.42), 0.55, 15.0, 22.0, 70.0), + "ankle_boot": ((0.10, 0.0, 0.12), 0.60, 30.0, 14.0, 70.0), + "back_seam": ((0.0, 0.0, 1.25), 0.95, 180.0, 8.0, 70.0), +} + + +def purge() -> None: + bpy.ops.wm.read_factory_settings(use_empty=True) + for collection in (bpy.data.objects, bpy.data.meshes, bpy.data.armatures, + bpy.data.materials, bpy.data.images, bpy.data.actions, + bpy.data.cameras, bpy.data.lights): + for item in list(collection): + collection.remove(item) + + +def import_glb(path: str, prefix: str) -> tuple[list, object]: + """Import one GLB and return (meshes, armature) with prefixed names.""" + before = set(bpy.data.objects) + bpy.ops.import_scene.gltf(filepath=path) + fresh = [o for o in bpy.data.objects if o not in before] + meshes, rig = [], None + for obj in fresh: + obj.name = f"{prefix}_{obj.name}" + if obj.type == "MESH": + meshes.append(obj) + elif obj.type == "ARMATURE": + rig = obj + return meshes, rig + + +def rest_pose(rig) -> None: + if rig is None: + return + rig.animation_data_clear() + for bone in rig.pose.bones: + bone.matrix_basis.identity() + bpy.context.view_layer.update() + + +def bind_to(rig, meshes: list) -> None: + """Re-parent imported apparel onto the body's armature by bone name.""" + for mesh in meshes: + for modifier in list(mesh.modifiers): + if modifier.type == "ARMATURE": + mesh.modifiers.remove(modifier) + mesh.parent = rig + mesh.matrix_parent_inverse = rig.matrix_world.inverted() + modifier = mesh.modifiers.new("Armature", "ARMATURE") + modifier.object = rig + + +def apply_action(rig, action, tau: float) -> None: + """Absolute pose evaluation: rest first, then one frame of `action`.""" + rest_pose(rig) + if action is None: + return + rig.animation_data_create() + rig.animation_data.action = action + if hasattr(rig.animation_data, "action_slot") and action.slots: + rig.animation_data.action_slot = action.slots[0] + start, end = action.frame_range + bpy.context.scene.frame_set(int(round(start + tau * (end - start)))) + bpy.context.view_layer.update() + + +def setup_render(scene, width: int, height: int, samples: int = 64) -> None: + try: + scene.render.engine = "BLENDER_EEVEE_NEXT" + except TypeError: + scene.render.engine = "BLENDER_EEVEE" + scene.render.resolution_x = width + scene.render.resolution_y = height + scene.render.resolution_percentage = 100 + scene.render.film_transparent = False + scene.render.image_settings.file_format = "PNG" + scene.render.image_settings.color_mode = "RGB" + scene.render.image_settings.compression = 100 + if hasattr(scene, "eevee"): + scene.eevee.taa_render_samples = samples + world = bpy.data.worlds.get("REFIT_WORLD") or bpy.data.worlds.new("REFIT_WORLD") + world.use_nodes = True + background = world.node_tree.nodes.get("Background") + if background: + # Mid neutral, not black: clipping is judged on contrast between a + # garment and the skin behind it, and a black void hides both. + background.inputs[0].default_value = (0.13, 0.135, 0.145, 1.0) + background.inputs[1].default_value = 1.0 + scene.world = world + + +def make_lights() -> None: + specs = (("REFIT_KEY", (2.9, -3.6, 3.1), 800.0, 4.0), + ("REFIT_FILL", (-3.4, -2.4, 1.6), 320.0, 5.0), + ("REFIT_RIM", (0.0, 4.0, 3.0), 460.0, 4.0)) + for name, location, power, size in specs: + obj = bpy.data.objects.get(name) + if obj is None: + obj = bpy.data.objects.new(name, bpy.data.lights.new(name, type="AREA")) + bpy.context.scene.collection.objects.link(obj) + obj.data.type = "AREA" + obj.data.size = size + obj.data.energy = power + obj.location = location + direction = (Vector((0.0, 0.0, 1.1)) - Vector(location)).normalized() + obj.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() + + +def make_camera(): + camera = bpy.data.objects.get("REFIT_CAM") + if camera is None: + camera = bpy.data.objects.new("REFIT_CAM", bpy.data.cameras.new("REFIT_CAM")) + bpy.context.scene.collection.objects.link(camera) + bpy.context.scene.camera = camera + return camera + + +def aim(camera, target, distance: float, azimuth: float, elevation: float, + lens: float = 55.0): + focus = Vector(target) + a, e = math.radians(azimuth), math.radians(elevation) + offset = Vector((math.sin(a) * math.cos(e), -math.cos(a) * math.cos(e), + math.sin(e))) * distance + camera.location = focus + offset + camera.rotation_euler = (focus - camera.location).normalized().to_track_quat("-Z", "Y").to_euler() + camera.data.lens = lens + return camera + + +def render_to(path: str) -> str: + os.makedirs(os.path.dirname(path), exist_ok=True) + bpy.context.scene.render.filepath = path + bpy.ops.render.render(write_still=True) + return path diff --git a/content-pipeline/labs/humanoid-runtime-refit/reports/apparel_refit.json b/content-pipeline/labs/humanoid-runtime-refit/reports/apparel_refit.json new file mode 100644 index 00000000..25cc56d9 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/reports/apparel_refit.json @@ -0,0 +1,4697 @@ +{ + "generator": "successor content-pipeline/labs/humanoid-runtime-refit/refit_apparel.py", + "fields": { + "male": { + "residual_mean_mm": 0.795, + "residual_p90_mm": 2.277, + "raw_mean_mm": 2.516 + }, + "female": { + "residual_mean_mm": 0.822, + "residual_p90_mm": 2.146, + "raw_mean_mm": 2.802 + } + }, + "audited": 99, + "refit_male": 96, + "female_variants": 95, + "shared": 1, + "not_applicable": 1, + "items": [ + { + "id": "armor_harness", + "group": "Torso", + "layer": "Armor", + "slot": "armor_harness", + "glb": "Armor/Harness.glb", + "policy": "shell", + "skinned": true, + "source_sha256": "1786729157ecab5fac5e9d429a056e8f95cce8b3cff9bb235c90f6407752ac1a", + "male": { + "path": "Armor/Harness.glb", + "bytes": 847256, + "sha256": "866b80b1b589e22fc12129079756801fe641da04ac68365b331953bc56b7a0fe", + "moved_mean_mm": 24.974, + "moved_max_mm": 42.075, + "penetrating": 686, + "max_penetration_mm": 38.09, + "drift_p95_mm": 35.864, + "clearance_min_mm": -25.2, + "pushed": 185, + "max_push_mm": 28.325, + "shells": [ + { + "vertices": 508, + "scale": 1.0581, + "shift_mm": [ + -2.15, + -78.9, + 72.21 + ] + }, + { + "vertices": 6, + "scale": 1.0127, + "shift_mm": [ + -170.25, + 1.6, + 421.58 + ] + }, + { + "vertices": 474, + "scale": 1.0545, + "shift_mm": [ + -1.62, + -74.26, + 70.45 + ] + } + ] + }, + "shared_on_female": { + "penetrating": 435, + "max_penetration_mm": 34.581, + "drift_p95_mm": 41.181 + }, + "female": { + "path": "Female/Armor/Harness.glb", + "bytes": 847256, + "sha256": "9c4f3c702936c7e0871b6f415214e2cc09d574456792fef100edfc81e9d23540", + "moved_mean_mm": 23.112, + "moved_max_mm": 36.137, + "penetrating": 580, + "max_penetration_mm": 40.428, + "drift_p95_mm": 33.599, + "clearance_min_mm": -26.0, + "pushed": 101, + "max_push_mm": 18.953 + }, + "decision": "refit_both", + "reason": "the male fit reads 34.6 mm into the female body over 435 vertices with 41.2 mm p95 clearance drift" + }, + { + "id": "armor_nape_reinforcement", + "group": "Torso", + "layer": "Armor", + "slot": "armor_nape_reinforcement", + "glb": "Armor/Nape_Reinforcement.glb", + "policy": "shell", + "skinned": true, + "source_sha256": "f6543b93363fe48162166be8e1aca606f3977ce914ac82ed700bc7b369e71cae", + "male": { + "path": "Armor/Nape_Reinforcement.glb", + "bytes": 862988, + "sha256": "7bce6fb3da0931c6ae2d94cf68642183ffd8d894162c26afd7bcdb6013a8daa6", + "moved_mean_mm": 10.045, + "moved_max_mm": 19.543, + "penetrating": 1117, + "max_penetration_mm": 14.671, + "drift_p95_mm": 14.671, + "clearance_min_mm": 4.645, + "pushed": 0, + "max_push_mm": 0.0, + "shells": [ + { + "vertices": 1148, + "scale": 1.0065, + "shift_mm": [ + 0.83, + 31.23, + -440.95 + ] + }, + { + "vertices": 120, + "scale": 1.0444, + "shift_mm": [ + 1.34, + -67.23, + -205.76 + ] + } + ] + }, + "shared_on_female": { + "penetrating": 0, + "max_penetration_mm": 0.0, + "drift_p95_mm": 20.139 + }, + "female": { + "path": "Female/Armor/Nape_Reinforcement.glb", + "bytes": 862988, + "sha256": "d5856ea6190938b79efb1b359dbdd4de5aad5080af22dc3471b7549f1fef2d0d", + "moved_mean_mm": 10.283, + "moved_max_mm": 17.678, + "penetrating": 985, + "max_penetration_mm": 11.421, + "drift_p95_mm": 10.05, + "clearance_min_mm": 9.93, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 0.0 mm into the female body over 0 vertices with 20.1 mm p95 clearance drift" + }, + { + "id": "armor_reinforcement", + "group": "Torso", + "layer": "Armor", + "slot": "armor_reinforcement", + "glb": "Armor/Reinforcement.glb", + "policy": "shell", + "skinned": true, + "source_sha256": "98d8326d01fe37ba1a65ca55e0c05c82bac90c5a2b05e88e588f83d45d5fc22d", + "male": { + "path": "Armor/Reinforcement.glb", + "bytes": 802584, + "sha256": "db35a86cbfd4a7d36ee8b2bc5bbb92eb9d8887330113948a2da945cac24e6b6a", + "moved_mean_mm": 14.866, + "moved_max_mm": 31.671, + "penetrating": 104, + "max_penetration_mm": 35.46, + "drift_p95_mm": 34.705, + "clearance_min_mm": 3.325, + "pushed": 0, + "max_push_mm": 0.0, + "shells": [ + { + "vertices": 160, + "scale": 1.0384, + "shift_mm": [ + -6.1, + -61.13, + 168.06 + ] + } + ] + }, + "shared_on_female": { + "penetrating": 104, + "max_penetration_mm": 33.21, + "drift_p95_mm": 32.754 + }, + "female": { + "path": "Female/Armor/Reinforcement.glb", + "bytes": 802584, + "sha256": "dc0edb59eee39d904d571d83c78c8b89bf8a604916ec58f7bf64b40c58331a80", + "moved_mean_mm": 15.133, + "moved_max_mm": 25.979, + "penetrating": 104, + "max_penetration_mm": 39.939, + "drift_p95_mm": 39.702, + "clearance_min_mm": 16.569, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 33.2 mm into the female body over 104 vertices with 32.8 mm p95 clearance drift" + }, + { + "id": "armor_gorget", + "group": "Torso", + "layer": "Armor", + "slot": "armor_gorget", + "glb": "Armor/Gorget.glb", + "policy": "shell", + "skinned": true, + "source_sha256": "1cc4d6635242ef2240f9413685615ee41e6ac02bd70f5a35450fda82e4add98a", + "male": { + "path": "Armor/Gorget.glb", + "bytes": 816244, + "sha256": "bbd326dd734126a52d2b8f8a839fb0d00c66eba07788bbefa19ea27af9cd40c1", + "moved_mean_mm": 10.649, + "moved_max_mm": 12.98, + "penetrating": 253, + "max_penetration_mm": 32.394, + "drift_p95_mm": 30.462, + "clearance_min_mm": 2.5, + "pushed": 6, + "max_push_mm": 8.342, + "shells": [ + { + "vertices": 100, + "scale": 0.9891, + "shift_mm": [ + 59.09, + 16.54, + -64.75 + ] + }, + { + "vertices": 104, + "scale": 0.9894, + "shift_mm": [ + -55.6, + 16.41, + -69.35 + ] + }, + { + "vertices": 58, + "scale": 0.954, + "shift_mm": [ + -6.58, + 79.81, + -108.85 + ] + }, + { + "vertices": 58, + "scale": 0.952, + "shift_mm": [ + 9.19, + 82.51, + -106.1 + ] + } + ] + }, + "shared_on_female": { + "penetrating": 78, + "max_penetration_mm": 13.35, + "drift_p95_mm": 21.26 + }, + "female": { + "path": "Female/Armor/Gorget.glb", + "bytes": 816248, + "sha256": "abfb88344d8b9ed0de7575aca9101f1445404e4aec72103c328643bc383590f3", + "moved_mean_mm": 16.535, + "moved_max_mm": 21.104, + "penetrating": 165, + "max_penetration_mm": 20.64, + "drift_p95_mm": 19.303, + "clearance_min_mm": 12.503, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 13.3 mm into the female body over 78 vertices with 21.3 mm p95 clearance drift" + }, + { + "id": "armor_bicep_l", + "group": "Bicep_L", + "layer": "Armor", + "slot": "armor_bicep_l", + "glb": "Armor/Bicep_L.glb", + "policy": "shell", + "skinned": true, + "source_sha256": "ac1e7ae21162578960adb70ae3c37a55b9aeed0f591ec7b7ec3d78025b2a3742", + "male": { + "path": "Armor/Bicep_L.glb", + "bytes": 803268, + "sha256": "bd02ae1ec190042bb1015a367784d75dbd469218ed39f8d663a26783db7924d9", + "moved_mean_mm": 13.968, + "moved_max_mm": 20.777, + "penetrating": 84, + "max_penetration_mm": 36.354, + "drift_p95_mm": 33.866, + "clearance_min_mm": 1.07, + "pushed": 9, + "max_push_mm": 3.602, + "shells": [ + { + "vertices": 72, + "scale": 1.0494, + "shift_mm": [ + 44.78, + -79.06, + -13.8 + ] + }, + { + "vertices": 72, + "scale": 1.0179, + "shift_mm": [ + -6.28, + -20.37, + -21.64 + ] + } + ] + }, + "shared_on_female": { + "penetrating": 3, + "max_penetration_mm": 2.094, + "drift_p95_mm": 22.149 + }, + "female": { + "path": "Female/Armor/Bicep_L.glb", + "bytes": 803268, + "sha256": "add5e1b26672d5371bf19e6aa34b669828d3a23125cc24318c26e5bfd44bbcf2", + "moved_mean_mm": 8.913, + "moved_max_mm": 11.686, + "penetrating": 24, + "max_penetration_mm": 8.947, + "drift_p95_mm": 10.312, + "clearance_min_mm": 9.74, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 2.1 mm into the female body over 3 vertices with 22.1 mm p95 clearance drift" + }, + { + "id": "armor_bicep_r", + "group": "Bicep_R", + "layer": "Armor", + "slot": "armor_bicep_r", + "glb": "Armor/Bicep_R.glb", + "policy": "shell", + "skinned": true, + "source_sha256": "8ad3bdfae32aeebb7f520545941543adab73519beba8e5ed077c666e79c56f65", + "male": { + "path": "Armor/Bicep_R.glb", + "bytes": 803272, + "sha256": "0dc1cf487873f6e94e3db7aee09b194bf088de8f36a3ee766864726bb7283ba1", + "moved_mean_mm": 14.759, + "moved_max_mm": 21.535, + "penetrating": 84, + "max_penetration_mm": 39.059, + "drift_p95_mm": 36.845, + "clearance_min_mm": 1.467, + "pushed": 9, + "max_push_mm": 3.297, + "shells": [ + { + "vertices": 72, + "scale": 1.0493, + "shift_mm": [ + -44.23, + -78.75, + -14.13 + ] + }, + { + "vertices": 72, + "scale": 1.0187, + "shift_mm": [ + 6.34, + -21.51, + -22.67 + ] + } + ] + }, + "shared_on_female": { + "penetrating": 12, + "max_penetration_mm": 14.65, + "drift_p95_mm": 21.973 + }, + "female": { + "path": "Female/Armor/Bicep_R.glb", + "bytes": 803272, + "sha256": "eb7b2e1ec9fe715a70f0b0d3f3e5fd9301f7a36fb1083cfe428431bc6c6ff4fb", + "moved_mean_mm": 8.794, + "moved_max_mm": 11.645, + "penetrating": 27, + "max_penetration_mm": 15.843, + "drift_p95_mm": 14.614, + "clearance_min_mm": 10.091, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 14.7 mm into the female body over 12 vertices with 22.0 mm p95 clearance drift" + }, + { + "id": "helmet_a", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "slot": "cranium", + "glb": "Armor/Helmet_A.glb", + "policy": "head", + "skinned": true, + "source_sha256": "1c8e80e0bc8d143c07c6db0925ab14cd106fc2b6a7ba276b464bca85afc482ff", + "male": { + "path": "Armor/Helmet_A.glb", + "bytes": 858356, + "sha256": "e91d1a3344c172683d907d58e993854c00830f4ecbfd098da2f6cf139c51f159", + "moved_mean_mm": 14.877, + "moved_max_mm": 19.102, + "penetrating": 646, + "max_penetration_mm": 31.916, + "drift_p95_mm": 31.229, + "clearance_min_mm": -18.928, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 260, + "max_penetration_mm": 19.324, + "drift_p95_mm": 18.666 + }, + "female": { + "path": "Female/Armor/Helmet_A.glb", + "bytes": 858360, + "sha256": "bf11eab6dfec60de110360d6a12f0c3901b5d23ae857a66796a777341fffc309", + "moved_mean_mm": 17.337, + "moved_max_mm": 24.345, + "penetrating": 746, + "max_penetration_mm": 26.175, + "drift_p95_mm": 25.242, + "clearance_min_mm": -13.51, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 19.3 mm into the female body over 260 vertices with 18.7 mm p95 clearance drift" + }, + { + "id": "helmet_b", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "slot": "cranium", + "glb": "Armor/Helmet_B.glb", + "policy": "head", + "skinned": true, + "source_sha256": "469a1c3b3a63187a68a6fdce7fb7426fa90bb7def7e297f6b7721d29e9747686", + "male": { + "path": "Armor/Helmet_B.glb", + "bytes": 864660, + "sha256": "110362f84da176b9bb24a2c4d929dd114a987767200d119a27e4f7aba14bfbfe", + "moved_mean_mm": 14.417, + "moved_max_mm": 19.958, + "penetrating": 839, + "max_penetration_mm": 33.048, + "drift_p95_mm": 28.904, + "clearance_min_mm": -18.28, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 722, + "max_penetration_mm": 20.159, + "drift_p95_mm": 13.852 + }, + "female": { + "path": "Female/Armor/Helmet_B.glb", + "bytes": 864660, + "sha256": "54797dda442a0437bb66eee40ba8e22379d0f7a5736cecea4fd9b0f7d8f413d4", + "moved_mean_mm": 17.88, + "moved_max_mm": 29.136, + "penetrating": 837, + "max_penetration_mm": 28.877, + "drift_p95_mm": 17.887, + "clearance_min_mm": -12.129, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 20.2 mm into the female body over 722 vertices with 13.9 mm p95 clearance drift" + }, + { + "id": "helmet_c", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "slot": "cranium", + "glb": "Armor/Helmet_C.glb", + "policy": "head", + "skinned": true, + "source_sha256": "3872e6c4cb8a14fd4a3f8103ff72e7163c0a9005392dce5a179cd7281ea8ad0c", + "male": { + "path": "Armor/Helmet_C.glb", + "bytes": 871152, + "sha256": "2c71b4be39da29ceed169a31f3e85298e5f88b1997252752ad7a810a35b6f105", + "moved_mean_mm": 14.0, + "moved_max_mm": 18.548, + "penetrating": 756, + "max_penetration_mm": 32.382, + "drift_p95_mm": 31.619, + "clearance_min_mm": -6.387, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 678, + "max_penetration_mm": 17.825, + "drift_p95_mm": 17.091 + }, + "female": { + "path": "Female/Armor/Helmet_C.glb", + "bytes": 871148, + "sha256": "fc5108a822700fdcb834ca28c00ee7301d0a0b0859e957a2c17d1fb648a747c3", + "moved_mean_mm": 16.823, + "moved_max_mm": 25.598, + "penetrating": 970, + "max_penetration_mm": 26.914, + "drift_p95_mm": 25.968, + "clearance_min_mm": -1.554, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 17.8 mm into the female body over 678 vertices with 17.1 mm p95 clearance drift" + }, + { + "id": "helmet_d", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "slot": "cranium", + "glb": "Armor/Helmet_D.glb", + "policy": "head", + "skinned": true, + "source_sha256": "56540be82ab5c012f51dc52abb2c3d5cc048142b0d2fea09f834ccc783ec9d1f", + "male": { + "path": "Armor/Helmet_D.glb", + "bytes": 875220, + "sha256": "bd92a85d8351c820423232cf099e6693107c66e057ae0d9aa1c4b0c18c86669e", + "moved_mean_mm": 15.54, + "moved_max_mm": 21.303, + "penetrating": 731, + "max_penetration_mm": 39.309, + "drift_p95_mm": 36.005, + "clearance_min_mm": -9.65, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 548, + "max_penetration_mm": 25.794, + "drift_p95_mm": 24.575 + }, + "female": { + "path": "Female/Armor/Helmet_D.glb", + "bytes": 875220, + "sha256": "601390f6a1c96bd539d4fa2f5166c4f1ae05a1df296da876bbbf3b1184491d7d", + "moved_mean_mm": 18.619, + "moved_max_mm": 29.616, + "penetrating": 1002, + "max_penetration_mm": 35.1, + "drift_p95_mm": 31.548, + "clearance_min_mm": -8.87, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 25.8 mm into the female body over 548 vertices with 24.6 mm p95 clearance drift" + }, + { + "id": "helmet_v1port", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "slot": "cranium", + "glb": "Armor/Helmet_V1PORT.glb", + "policy": "head", + "skinned": true, + "source_sha256": "f3d8fe6acd690a86fc3538bd1f550f35c28c650217bc2880909be93ce3c7177b", + "male": { + "path": "Armor/Helmet_V1PORT.glb", + "bytes": 839832, + "sha256": "ba99836a98bc3062c14a7a20a68fe3abba32d811a9e6dda16b1bc0752ec648e7", + "moved_mean_mm": 13.494, + "moved_max_mm": 19.737, + "penetrating": 405, + "max_penetration_mm": 36.721, + "drift_p95_mm": 25.83, + "clearance_min_mm": -16.336, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 208, + "max_penetration_mm": 25.435, + "drift_p95_mm": 18.957 + }, + "female": { + "path": "Female/Armor/Helmet_V1PORT.glb", + "bytes": 839832, + "sha256": "09f201aebc6e5766387b866f833bfab0a9ffd777f3d40ba03b691cd7dccaa4c4", + "moved_mean_mm": 17.178, + "moved_max_mm": 28.399, + "penetrating": 442, + "max_penetration_mm": 34.353, + "drift_p95_mm": 19.154, + "clearance_min_mm": -11.487, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 25.4 mm into the female body over 208 vertices with 19.0 mm p95 clearance drift" + }, + { + "id": "helmet_s1", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "slot": "cranium", + "glb": "Armor/Helmet_S1.glb", + "policy": "head", + "skinned": true, + "source_sha256": "3294d62dbfb02d8933da34c7717a6f75cb9c016035f4f63963dcc6a8c93a6b06", + "male": { + "path": "Armor/Helmet_S1.glb", + "bytes": 846792, + "sha256": "dc3e6e3b836b64ce25f62cd6ad7f1fbdc6980cd6ee16cad7cdc4cdd1ad7c8289", + "moved_mean_mm": 13.57, + "moved_max_mm": 19.737, + "penetrating": 424, + "max_penetration_mm": 36.721, + "drift_p95_mm": 26.43, + "clearance_min_mm": -14.071, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 224, + "max_penetration_mm": 25.435, + "drift_p95_mm": 18.721 + }, + "female": { + "path": "Female/Armor/Helmet_S1.glb", + "bytes": 846792, + "sha256": "e55e575213528eb4cdaf08d937f28bde11ad8b517f31a1e25cb9a73cd558fe5c", + "moved_mean_mm": 17.042, + "moved_max_mm": 28.173, + "penetrating": 482, + "max_penetration_mm": 34.353, + "drift_p95_mm": 20.511, + "clearance_min_mm": -9.92, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 25.4 mm into the female body over 224 vertices with 18.7 mm p95 clearance drift" + }, + { + "id": "helmet_s2", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "slot": "cranium", + "glb": "Armor/Helmet_S2.glb", + "policy": "head", + "skinned": true, + "source_sha256": "be800c6e1786b89553a7c29184be1c9ccb8cd9e031f82278dada6a039062c76f", + "male": { + "path": "Armor/Helmet_S2.glb", + "bytes": 883276, + "sha256": "1a13f6243c4c1b29c042b6ecb0acd080c148b6a2d03b1e6b8d35a36ddd4f5f9c", + "moved_mean_mm": 14.061, + "moved_max_mm": 18.867, + "penetrating": 688, + "max_penetration_mm": 34.049, + "drift_p95_mm": 19.004, + "clearance_min_mm": -19.317, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 398, + "max_penetration_mm": 22.114, + "drift_p95_mm": 15.952 + }, + "female": { + "path": "Female/Armor/Helmet_S2.glb", + "bytes": 883276, + "sha256": "9e53fd8f8b668494d4932d1b9e98f958647bd2605422a454af3ec2ad4f0f826d", + "moved_mean_mm": 17.716, + "moved_max_mm": 24.641, + "penetrating": 878, + "max_penetration_mm": 29.117, + "drift_p95_mm": 17.559, + "clearance_min_mm": -13.295, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 22.1 mm into the female body over 398 vertices with 16.0 mm p95 clearance drift" + }, + { + "id": "helmet_s3", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "slot": "cranium", + "glb": "Armor/Helmet_S3.glb", + "policy": "head", + "skinned": true, + "source_sha256": "bb1de47526ca216059b6645e05082c14de2887278c70b0e31dc0e0aed4207f4a", + "male": { + "path": "Armor/Helmet_S3.glb", + "bytes": 888140, + "sha256": "ab961cc4a6a56098d52d5ae86c580545c98a55cd3b1251f9ff1199e1e3ec01c4", + "moved_mean_mm": 15.146, + "moved_max_mm": 20.147, + "penetrating": 656, + "max_penetration_mm": 35.105, + "drift_p95_mm": 27.17, + "clearance_min_mm": -12.863, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 556, + "max_penetration_mm": 23.477, + "drift_p95_mm": 17.038 + }, + "female": { + "path": "Female/Armor/Helmet_S3.glb", + "bytes": 888140, + "sha256": "4b6f91dc50c854010a5bef05c67d295f864d4de0b48575db571b7b27f61da09d", + "moved_mean_mm": 19.682, + "moved_max_mm": 27.605, + "penetrating": 861, + "max_penetration_mm": 32.142, + "drift_p95_mm": 20.028, + "clearance_min_mm": -9.024, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 23.5 mm into the female body over 556 vertices with 17.0 mm p95 clearance drift" + }, + { + "id": "hat_warm", + "group": "Headwear \u2014 baseline", + "layer": "Under", + "slot": "cranium", + "glb": "Under/Hat_Warm.glb", + "policy": "head", + "skinned": true, + "source_sha256": "25207acfda673de0dcf6079fc6fe51f3d94a3cce3f7d9f05593e2a8ec7b6a366", + "male": { + "path": "Under/Hat_Warm.glb", + "bytes": 810984, + "sha256": "911eb6ecbb61be32cc79f3a0d56bcb7680362c5399e7325352cf43d7385d5569", + "moved_mean_mm": 19.102, + "moved_max_mm": 27.267, + "penetrating": 115, + "max_penetration_mm": 17.709, + "drift_p95_mm": 12.549, + "clearance_min_mm": -12.243, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 140, + "max_penetration_mm": 24.375, + "drift_p95_mm": 17.619 + }, + "female": { + "path": "Female/Under/Hat_Warm.glb", + "bytes": 810984, + "sha256": "641ee4a279b9c8334fbd078defa94550d580192dbc74f91a699aa2eb3f84b6a9", + "moved_mean_mm": 23.153, + "moved_max_mm": 35.863, + "penetrating": 179, + "max_penetration_mm": 33.029, + "drift_p95_mm": 25.252, + "clearance_min_mm": -12.333, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 24.4 mm into the female body over 140 vertices with 17.6 mm p95 clearance drift" + }, + { + "id": "hat_field_cap", + "group": "Headwear \u2014 baseline", + "layer": "Under", + "slot": "cranium", + "glb": "../../items/custom/accessories/field_cap.glb", + "policy": "head", + "skinned": false, + "source_sha256": "3de7e61851e11d4ae62b04c5b3aec654413f8b06f4e612b904f488de89c1a84f", + "decision": "not_applicable", + "reason": "rigid bone-anchored accessory with no skin binding; its placement follows the unchanged rest skeleton" + }, + { + "id": "under_tank", + "group": "Torso", + "layer": "Under", + "slot": "under_torso", + "glb": "Under/Tank.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "6614c478ab20f7691276040c3dde6e42e703e3534cce65f17711044f17cef87c", + "male": { + "path": "Under/Tank.glb", + "bytes": 824212, + "sha256": "f5f6e6845beb1a6df8b0a065fc726709b1d52912629868fe7a95e40764d6f987", + "moved_mean_mm": 21.187, + "moved_max_mm": 65.595, + "penetrating": 184, + "max_penetration_mm": 16.631, + "drift_p95_mm": 9.496, + "clearance_min_mm": -7.443, + "pushed": 61, + "max_push_mm": 16.353 + }, + "shared_on_female": { + "penetrating": 72, + "max_penetration_mm": 11.615, + "drift_p95_mm": 25.338 + }, + "female": { + "path": "Female/Under/Tank.glb", + "bytes": 824212, + "sha256": "12037a9ab00367cf14c4f017732c78117c81600f45d74ab165e7506d5618e20b", + "moved_mean_mm": 19.298, + "moved_max_mm": 64.338, + "penetrating": 156, + "max_penetration_mm": 7.609, + "drift_p95_mm": 8.064, + "clearance_min_mm": -8.605, + "pushed": 57, + "max_push_mm": 4.937 + }, + "decision": "refit_both", + "reason": "the male fit reads 11.6 mm into the female body over 72 vertices with 25.3 mm p95 clearance drift" + }, + { + "id": "under_shorts", + "group": "Legs", + "layer": "Under", + "slot": "under_legs", + "glb": "Under/Shorts.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "e52ff60e170b790941205daebd2f794b1299ad8898b81082afed4fad01adfe1b", + "male": { + "path": "Under/Shorts.glb", + "bytes": 819736, + "sha256": "1d8a902ea281d760335044630d37444fbc7b35643a276b06437a0836f15d158a", + "moved_mean_mm": 19.365, + "moved_max_mm": 71.084, + "penetrating": 200, + "max_penetration_mm": 25.141, + "drift_p95_mm": 15.147, + "clearance_min_mm": 1.5, + "pushed": 21, + "max_push_mm": 8.857 + }, + "shared_on_female": { + "penetrating": 28, + "max_penetration_mm": 9.655, + "drift_p95_mm": 34.668 + }, + "female": { + "path": "Female/Under/Shorts.glb", + "bytes": 819732, + "sha256": "6c013321f2c6f7dd743bcf7618df6c842665ad6d26f797c3bbf3a6f294576c95", + "moved_mean_mm": 23.879, + "moved_max_mm": 57.282, + "penetrating": 88, + "max_penetration_mm": 30.941, + "drift_p95_mm": 7.272, + "clearance_min_mm": -5.018, + "pushed": 103, + "max_push_mm": 25.711 + }, + "decision": "refit_both", + "reason": "the male fit reads 9.7 mm into the female body over 28 vertices with 34.7 mm p95 clearance drift" + }, + { + "id": "hair_mop", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/Hair_Mop.glb", + "policy": "head", + "skinned": true, + "source_sha256": "6279e55849198be5f761e472c5736a10cd76d8bd422902940396d95fb11a249f", + "male": { + "path": "Under/Hair_Mop.glb", + "bytes": 814548, + "sha256": "c7f32850960d89a53b2ac4fefbf288c5b8e4fc36527886d585275118b5b21508", + "moved_mean_mm": 13.149, + "moved_max_mm": 17.495, + "penetrating": 98, + "max_penetration_mm": 40.205, + "drift_p95_mm": 35.675, + "clearance_min_mm": -42.813, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 112, + "max_penetration_mm": 22.3, + "drift_p95_mm": 20.89 + }, + "female": { + "path": "Female/Under/Hair_Mop.glb", + "bytes": 814548, + "sha256": "221090cd837ff313c6978c7f5ed6d98f5637a0c107195c35709b030ccdba4c8b", + "moved_mean_mm": 15.061, + "moved_max_mm": 19.085, + "penetrating": 160, + "max_penetration_mm": 30.144, + "drift_p95_mm": 26.638, + "clearance_min_mm": -33.024, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 22.3 mm into the female body over 112 vertices with 20.9 mm p95 clearance drift" + }, + { + "id": "hair_afro2", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/Hair_Afro.glb", + "policy": "head", + "skinned": true, + "source_sha256": "c752c7e1a081f46b57028650b72d527173ba84bed7f0d8a039ec85c645e3500f", + "male": { + "path": "Under/Hair_Afro.glb", + "bytes": 807244, + "sha256": "ce18a7ff31f40b0ed0149ccb4fa7b14b3dd8f343814ecf112ba44ae55b17ef44", + "moved_mean_mm": 14.606, + "moved_max_mm": 20.848, + "penetrating": 136, + "max_penetration_mm": 40.003, + "drift_p95_mm": 36.103, + "clearance_min_mm": -40.103, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 152, + "max_penetration_mm": 25.694, + "drift_p95_mm": 21.358 + }, + "female": { + "path": "Female/Under/Hair_Afro.glb", + "bytes": 807244, + "sha256": "20415a8e276a7af39d0124d921a8576f30dad4e7b9bf8752f8fb6453c4c7c27f", + "moved_mean_mm": 17.644, + "moved_max_mm": 24.743, + "penetrating": 184, + "max_penetration_mm": 32.75, + "drift_p95_mm": 28.637, + "clearance_min_mm": -32.487, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 25.7 mm into the female body over 152 vertices with 21.4 mm p95 clearance drift" + }, + { + "id": "hair_crop2", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/Hair_Crop.glb", + "policy": "head", + "skinned": true, + "source_sha256": "2feafd76bec48d40e86ebe8154e66d273468260fbba95c448b735b83a278d380", + "male": { + "path": "Under/Hair_Crop.glb", + "bytes": 807248, + "sha256": "725bbd31bcffeb041507d5fee1149502af90ed893b0ef8f4850142f9c2d10722", + "moved_mean_mm": 13.287, + "moved_max_mm": 17.653, + "penetrating": 100, + "max_penetration_mm": 40.101, + "drift_p95_mm": 35.011, + "clearance_min_mm": -42.751, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 102, + "max_penetration_mm": 21.635, + "drift_p95_mm": 19.627 + }, + "female": { + "path": "Female/Under/Hair_Crop.glb", + "bytes": 807248, + "sha256": "7ef20dc9bfe55e590ff870b87cada16ec5fc8d7a36cea4eaa1c97c365a03dd75", + "moved_mean_mm": 14.856, + "moved_max_mm": 19.101, + "penetrating": 146, + "max_penetration_mm": 29.346, + "drift_p95_mm": 26.571, + "clearance_min_mm": -33.074, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 21.6 mm into the female body over 102 vertices with 19.6 mm p95 clearance drift" + }, + { + "id": "under_bodysuit", + "group": "Underlayer", + "layer": "Under", + "slot": "under_full", + "glb": "Under/under_bodysuit.glb", + "policy": "authored", + "skinned": true, + "decision": "authored", + "reason": "generated per body by author_starter_apparel.py" + }, + { + "id": "trial_helm_a", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "cranium", + "glb": "Armor/trial_helm_a.glb", + "policy": "head", + "skinned": true, + "source_sha256": "3d8a92650200bd296d2878cbbd94fec817a80871e85169acc5bcfa341b61bc5d", + "male": { + "path": "Armor/trial_helm_a.glb", + "bytes": 822892, + "sha256": "d418e83ebe51177f304cb3ebefd4260a58cd98cb3a43b3094cba0a82d8c421df", + "moved_mean_mm": 10.325, + "moved_max_mm": 16.555, + "penetrating": 322, + "max_penetration_mm": 40.302, + "drift_p95_mm": 34.92, + "clearance_min_mm": -67.63, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 239, + "max_penetration_mm": 26.164, + "drift_p95_mm": 18.198 + }, + "female": { + "path": "Female/Armor/trial_helm_a.glb", + "bytes": 822892, + "sha256": "d1ebbfdf30724bbaddf95c582a5da0d9da5e6c0568433f699fad368bc908b3cb", + "moved_mean_mm": 12.738, + "moved_max_mm": 17.52, + "penetrating": 346, + "max_penetration_mm": 33.394, + "drift_p95_mm": 24.822, + "clearance_min_mm": -66.321, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 26.2 mm into the female body over 239 vertices with 18.2 mm p95 clearance drift" + }, + { + "id": "trial_helm_b", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "cranium", + "glb": "Armor/trial_helm_b.glb", + "policy": "head", + "skinned": true, + "source_sha256": "cb8cdd60c7044029aa2b84184c13e3f7ff8f44e4494cc81a40d7f1d103b1b963", + "male": { + "path": "Armor/trial_helm_b.glb", + "bytes": 823316, + "sha256": "8c33891fcadff624358b5b90fa3f685fc0e1e088fe9498ee850801e6984aad92", + "moved_mean_mm": 9.078, + "moved_max_mm": 20.333, + "penetrating": 417, + "max_penetration_mm": 41.902, + "drift_p95_mm": 38.461, + "clearance_min_mm": -61.266, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 333, + "max_penetration_mm": 25.56, + "drift_p95_mm": 20.837 + }, + "female": { + "path": "Female/Armor/trial_helm_b.glb", + "bytes": 823312, + "sha256": "8bfb118fe67c2f0420199171c326c23fbad43418f504e058547fdf9fd00d62d3", + "moved_mean_mm": 14.875, + "moved_max_mm": 23.663, + "penetrating": 404, + "max_penetration_mm": 36.12, + "drift_p95_mm": 31.257, + "clearance_min_mm": -63.783, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 25.6 mm into the female body over 333 vertices with 20.8 mm p95 clearance drift" + }, + { + "id": "trial_helm_c", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "cranium", + "glb": "Armor/trial_helm_c.glb", + "policy": "head", + "skinned": true, + "source_sha256": "e03df252b53cfaeb1078e407d0572e075ffe4ca2b7b2473cb525b70aa4916d93", + "male": { + "path": "Armor/trial_helm_c.glb", + "bytes": 812312, + "sha256": "58c5e227d409e63cf2f8ee22a7d810cdaf5b1ee93a5147ac143afeba784088cc", + "moved_mean_mm": 12.418, + "moved_max_mm": 18.999, + "penetrating": 120, + "max_penetration_mm": 36.101, + "drift_p95_mm": 28.565, + "clearance_min_mm": -31.319, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 71, + "max_penetration_mm": 20.051, + "drift_p95_mm": 15.548 + }, + "female": { + "path": "Female/Armor/trial_helm_c.glb", + "bytes": 812312, + "sha256": "a224d93c2d67eaff6e2fce7f18a0796cd8b089a623a098b11b7211381239351a", + "moved_mean_mm": 15.653, + "moved_max_mm": 21.738, + "penetrating": 140, + "max_penetration_mm": 33.67, + "drift_p95_mm": 23.073, + "clearance_min_mm": -31.253, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 20.1 mm into the female body over 71 vertices with 15.5 mm p95 clearance drift" + }, + { + "id": "trial_helm_d", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "cranium", + "glb": "Armor/trial_helm_d.glb", + "policy": "head", + "skinned": true, + "source_sha256": "1c63048f66c2826e7a5cbaee3b146b160097b1c92ec39d6db9b7317aa92816b3", + "male": { + "path": "Armor/trial_helm_d.glb", + "bytes": 826336, + "sha256": "c3989eb44be7d409e49b1c84b9690562f3729f283963000cad196ad9f3cd00ff", + "moved_mean_mm": 10.246, + "moved_max_mm": 21.432, + "penetrating": 291, + "max_penetration_mm": 40.073, + "drift_p95_mm": 36.09, + "clearance_min_mm": -62.813, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 240, + "max_penetration_mm": 24.201, + "drift_p95_mm": 19.864 + }, + "female": { + "path": "Female/Armor/trial_helm_d.glb", + "bytes": 826340, + "sha256": "2d7364e115f4efe9d791d83e66ae09e52e99b0224ea753a5b9976aadfbdf9870", + "moved_mean_mm": 12.834, + "moved_max_mm": 26.423, + "penetrating": 302, + "max_penetration_mm": 34.761, + "drift_p95_mm": 29.463, + "clearance_min_mm": -53.504, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 24.2 mm into the female body over 240 vertices with 19.9 mm p95 clearance drift" + }, + { + "id": "trial_hood_a", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "cranium", + "glb": "Armor/trial_hood_a.glb", + "policy": "head", + "skinned": true, + "source_sha256": "9fdecfb7c02381edc7ecdc40fa1d5ba7ec434dee1fddcfc370aeb3beb73d6db8", + "male": { + "path": "Armor/trial_hood_a.glb", + "bytes": 835860, + "sha256": "d59f5229bcc41bb23ae03c2c8b45de747c70fdb58cc3158a3bf5603105f49eff", + "moved_mean_mm": 9.212, + "moved_max_mm": 19.094, + "penetrating": 521, + "max_penetration_mm": 41.444, + "drift_p95_mm": 38.23, + "clearance_min_mm": -66.247, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 464, + "max_penetration_mm": 23.114, + "drift_p95_mm": 20.768 + }, + "female": { + "path": "Female/Armor/trial_hood_a.glb", + "bytes": 835864, + "sha256": "fac7e289caa79808fe037c68777a4cef83a055798cf1bc723e15c426fc7ad8f1", + "moved_mean_mm": 14.386, + "moved_max_mm": 24.682, + "penetrating": 550, + "max_penetration_mm": 34.449, + "drift_p95_mm": 31.335, + "clearance_min_mm": -62.38, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 23.1 mm into the female body over 464 vertices with 20.8 mm p95 clearance drift" + }, + { + "id": "trial_visor_a", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "face", + "glb": "Armor/trial_visor_a.glb", + "policy": "head", + "skinned": true, + "source_sha256": "99fc2f8488cf43592aca41499f8653063981cab493abf08bc345d9365838ecc8", + "male": { + "path": "Armor/trial_visor_a.glb", + "bytes": 814040, + "sha256": "43efdcf0c5dbb1c7643b68d85261df2963daec8c70e248d3f7ef437c95839be7", + "moved_mean_mm": 4.617, + "moved_max_mm": 9.751, + "penetrating": 398, + "max_penetration_mm": 41.314, + "drift_p95_mm": 39.326, + "clearance_min_mm": -31.799, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 304, + "max_penetration_mm": 24.634, + "drift_p95_mm": 22.525 + }, + "female": { + "path": "Female/Armor/trial_visor_a.glb", + "bytes": 814044, + "sha256": "8718fbecc49e18a8fb285ebec090b7777ca3b59444f51425b30af77afac1fbf8", + "moved_mean_mm": 14.083, + "moved_max_mm": 18.531, + "penetrating": 393, + "max_penetration_mm": 37.526, + "drift_p95_mm": 34.993, + "clearance_min_mm": -22.919, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 24.6 mm into the female body over 304 vertices with 22.5 mm p95 clearance drift" + }, + { + "id": "trial_mask_b", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "face", + "glb": "Armor/trial_mask_b.glb", + "policy": "head", + "skinned": true, + "source_sha256": "9f2b186136009289f07f1b5428272c35010512702e1d9b80ccdfa81c3ffe54a1", + "male": { + "path": "Armor/trial_mask_b.glb", + "bytes": 799420, + "sha256": "af5e259d76ed2d3a3b8aa206f5cae873a886abfae731d82d32b311ca0aee4e62", + "moved_mean_mm": 9.482, + "moved_max_mm": 18.051, + "penetrating": 99, + "max_penetration_mm": 38.995, + "drift_p95_mm": 37.513, + "clearance_min_mm": -42.413, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 84, + "max_penetration_mm": 20.559, + "drift_p95_mm": 19.052 + }, + "female": { + "path": "Female/Armor/trial_mask_b.glb", + "bytes": 799420, + "sha256": "7a871b54359a0892f230ba2f829ab3ebe6c0f36e26e3a58afb318f8afb338e6a", + "moved_mean_mm": 11.894, + "moved_max_mm": 20.41, + "penetrating": 104, + "max_penetration_mm": 28.689, + "drift_p95_mm": 27.234, + "clearance_min_mm": -32.144, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 20.6 mm into the female body over 84 vertices with 19.1 mm p95 clearance drift" + }, + { + "id": "trial_back_a", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "back", + "glb": "Armor/trial_back_a.glb", + "policy": "shell", + "skinned": true, + "source_sha256": "a4bbf92c0dd4bfe1b12a0afc44518835343358aab49583184da4aa50df60214e", + "male": { + "path": "Armor/trial_back_a.glb", + "bytes": 848284, + "sha256": "e588875fed99a7b4b8e2d6f1e1fbc1abd1e2318b06f5d74388e9d1f27f4f831b", + "moved_mean_mm": 13.381, + "moved_max_mm": 30.293, + "penetrating": 799, + "max_penetration_mm": 29.883, + "drift_p95_mm": 18.988, + "clearance_min_mm": 19.741, + "pushed": 0, + "max_push_mm": 0.0, + "shells": [ + { + "vertices": 572, + "scale": 1.0334, + "shift_mm": [ + -0.49, + -18.44, + 127.35 + ] + }, + { + "vertices": 61, + "scale": 1.0418, + "shift_mm": [ + -0.45, + -76.65, + -200.64 + ] + }, + { + "vertices": 311, + "scale": 1.0409, + "shift_mm": [ + -15.54, + -62.58, + -41.91 + ] + }, + { + "vertices": 36, + "scale": 0.9633, + "shift_mm": [ + -1.56, + 53.15, + -47.04 + ] + }, + { + "vertices": 17, + "scale": 1.0656, + "shift_mm": [ + -82.83, + -61.66, + -609.7 + ] + }, + { + "vertices": 17, + "scale": 1.0602, + "shift_mm": [ + 105.69, + -36.48, + -670.2 + ] + } + ] + }, + "shared_on_female": { + "penetrating": 52, + "max_penetration_mm": 8.667, + "drift_p95_mm": 19.408 + }, + "female": { + "path": "Female/Armor/trial_back_a.glb", + "bytes": 848284, + "sha256": "b823fd6a0b3678d20a05879a7703ec1dad02decfd0315e2345c089baa0bb5566", + "moved_mean_mm": 8.261, + "moved_max_mm": 37.152, + "penetrating": 831, + "max_penetration_mm": 21.563, + "drift_p95_mm": 15.284, + "clearance_min_mm": 22.771, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 8.7 mm into the female body over 52 vertices with 19.4 mm p95 clearance drift" + }, + { + "id": "trial_back_b", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "back", + "glb": "Armor/trial_back_b.glb", + "policy": "shell", + "skinned": true, + "source_sha256": "263978126f9642f19d76921dbbd8d613e40599334bfd750c0b5a15723cb78dec", + "male": { + "path": "Armor/trial_back_b.glb", + "bytes": 855012, + "sha256": "27a1f09536585ec93ad7efb8f5f6cdfae97efeb9c90f0a4ddc5fc3dc271c6ad6", + "moved_mean_mm": 16.483, + "moved_max_mm": 26.434, + "penetrating": 802, + "max_penetration_mm": 28.294, + "drift_p95_mm": 10.568, + "clearance_min_mm": 5.528, + "pushed": 0, + "max_push_mm": 0.0, + "shells": [ + { + "vertices": 254, + "scale": 1.0182, + "shift_mm": [ + 33.91, + -7.37, + 68.17 + ] + }, + { + "vertices": 22, + "scale": 1.0174, + "shift_mm": [ + 69.53, + 31.43, + 160.19 + ] + }, + { + "vertices": 12, + "scale": 0.9939, + "shift_mm": [ + 54.61, + 16.88, + 38.21 + ] + }, + { + "vertices": 33, + "scale": 0.9861, + "shift_mm": [ + 56.86, + 45.43, + 126.29 + ] + }, + { + "vertices": 114, + "scale": 0.9899, + "shift_mm": [ + 32.39, + 41.57, + 143.42 + ] + }, + { + "vertices": 34, + "scale": 0.9616, + "shift_mm": [ + -2.55, + 83.72, + 172.16 + ] + }, + { + "vertices": 16, + "scale": 1.0275, + "shift_mm": [ + 1.55, + -26.22, + 46.95 + ] + }, + { + "vertices": 38, + "scale": 0.9873, + "shift_mm": [ + -0.49, + 13.34, + 12.85 + ] + }, + { + "vertices": 16, + "scale": 1.0316, + "shift_mm": [ + -1.47, + -25.58, + 86.68 + ] + }, + { + "vertices": 16, + "scale": 1.0184, + "shift_mm": [ + 0.6, + -17.19, + 35.38 + ] + }, + { + "vertices": 62, + "scale": 0.9889, + "shift_mm": [ + -5.34, + 10.51, + 4.35 + ] + }, + { + "vertices": 265, + "scale": 1.0162, + "shift_mm": [ + -19.27, + -9.61, + 50.11 + ] + }, + { + "vertices": 127, + "scale": 1.0002, + "shift_mm": [ + -50.04, + 32.89, + 150.53 + ] + }, + { + "vertices": 38, + "scale": 0.9873, + "shift_mm": [ + -12.28, + 14.64, + 19.03 + ] + }, + { + "vertices": 12, + "scale": 1.0027, + "shift_mm": [ + -79.82, + 10.19, + 46.15 + ] + }, + { + "vertices": 52, + "scale": 1.0079, + "shift_mm": [ + -80.21, + 15.06, + 94.91 + ] + }, + { + "vertices": 28, + "scale": 1.0265, + "shift_mm": [ + -58.35, + 8.14, + 131.92 + ] + } + ] + }, + "shared_on_female": { + "penetrating": 90, + "max_penetration_mm": 15.203, + "drift_p95_mm": 17.625 + }, + "female": { + "path": "Female/Armor/trial_back_b.glb", + "bytes": 855016, + "sha256": "c3d0e6820fe99c89fea325d86555b462872002477ab2274e8345ecb5db4ff456", + "moved_mean_mm": 15.964, + "moved_max_mm": 38.259, + "penetrating": 947, + "max_penetration_mm": 23.823, + "drift_p95_mm": 14.785, + "clearance_min_mm": 4.907, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 15.2 mm into the female body over 90 vertices with 17.6 mm p95 clearance drift" + }, + { + "id": "trial_hip_front_a", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "hip", + "glb": "Armor/trial_hip_front_a.glb", + "policy": "shell", + "skinned": true, + "source_sha256": "029f530b1f386f4371a7e94e4a392d596ee51ba1b2b8638552a761ff68efcc8f", + "male": { + "path": "Armor/trial_hip_front_a.glb", + "bytes": 824104, + "sha256": "2381b7528854c68e73ce94a1ac6c44aaa6e75fda9642c1c335d497412b0b38bb", + "moved_mean_mm": 77.131, + "moved_max_mm": 120.43, + "penetrating": 459, + "max_penetration_mm": 131.384, + "drift_p95_mm": 101.013, + "clearance_min_mm": 4.122, + "pushed": 0, + "max_push_mm": 0.0, + "shells": [ + { + "vertices": 48, + "scale": 1.15, + "shift_mm": [ + -6.96, + -179.88, + -249.47 + ] + }, + { + "vertices": 112, + "scale": 1.1444, + "shift_mm": [ + 93.93, + -33.83, + -266.41 + ] + }, + { + "vertices": 48, + "scale": 1.15, + "shift_mm": [ + 240.13, + 66.73, + -553.69 + ] + }, + { + "vertices": 50, + "scale": 1.15, + "shift_mm": [ + 135.84, + 45.35, + -514.96 + ] + }, + { + "vertices": 48, + "scale": 1.15, + "shift_mm": [ + 46.75, + 35.42, + -494.83 + ] + }, + { + "vertices": 119, + "scale": 1.1499, + "shift_mm": [ + -105.17, + -39.72, + -261.87 + ] + }, + { + "vertices": 55, + "scale": 1.15, + "shift_mm": [ + -73.88, + 70.73, + -530.64 + ] + }, + { + "vertices": 48, + "scale": 1.15, + "shift_mm": [ + -158.22, + 52.66, + -533.73 + ] + }, + { + "vertices": 53, + "scale": 1.15, + "shift_mm": [ + -266.71, + 67.61, + -548.03 + ] + } + ] + }, + "shared_on_female": { + "penetrating": 379, + "max_penetration_mm": 114.558, + "drift_p95_mm": 87.796 + }, + "female": { + "path": "Female/Armor/trial_hip_front_a.glb", + "bytes": 824104, + "sha256": "e21a4d4dd4f03916c874744677108a8ca2855c67643c2e9e609e16e8985d5b7b", + "moved_mean_mm": 43.453, + "moved_max_mm": 88.244, + "penetrating": 489, + "max_penetration_mm": 97.415, + "drift_p95_mm": 79.889, + "clearance_min_mm": 4.469, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 114.6 mm into the female body over 379 vertices with 87.8 mm p95 clearance drift" + }, + { + "id": "trial_hip_back_a", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "hip", + "glb": "Armor/trial_hip_back_a.glb", + "policy": "shell", + "skinned": true, + "source_sha256": "2283421a1702e636401ccff25704978dcf4161ce1d9e279b0fc32df5fb4b8406", + "male": { + "path": "Armor/trial_hip_back_a.glb", + "bytes": 804564, + "sha256": "c4a6ddf7e970a2b54ce5fe24c4576548915bfb16ae4177b86e25554ca6ef1e48", + "moved_mean_mm": 23.566, + "moved_max_mm": 34.728, + "penetrating": 168, + "max_penetration_mm": 23.203, + "drift_p95_mm": 15.639, + "clearance_min_mm": 10.921, + "pushed": 0, + "max_push_mm": 0.0, + "shells": [ + { + "vertices": 71, + "scale": 1.1251, + "shift_mm": [ + 14.08, + -91.59, + 88.61 + ] + }, + { + "vertices": 112, + "scale": 1.0705, + "shift_mm": [ + 66.32, + -64.13, + -32.51 + ] + }, + { + "vertices": 10, + "scale": 1.113, + "shift_mm": [ + -198.05, + -26.23, + 226.12 + ] + }, + { + "vertices": 14, + "scale": 1.136, + "shift_mm": [ + -32.27, + -133.69, + -78.51 + ] + }, + { + "vertices": 8, + "scale": 1.15, + "shift_mm": [ + 80.2, + -143.28, + -121.44 + ] + }, + { + "vertices": 19, + "scale": 1.1153, + "shift_mm": [ + 47.94, + -112.03, + -98.7 + ] + } + ] + }, + "shared_on_female": { + "penetrating": 6, + "max_penetration_mm": 5.063, + "drift_p95_mm": 41.837 + }, + "female": { + "path": "Female/Armor/trial_hip_back_a.glb", + "bytes": 804564, + "sha256": "3ade262cda136e9e7cb8a934e0f1bad31a561437f5d0777b99e51a528be79dcb", + "moved_mean_mm": 6.556, + "moved_max_mm": 16.629, + "penetrating": 187, + "max_penetration_mm": 24.765, + "drift_p95_mm": 16.911, + "clearance_min_mm": 6.363, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 5.1 mm into the female body over 6 vertices with 41.8 mm p95 clearance drift" + }, + { + "id": "trial_hip_side_a", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "hip", + "glb": "Armor/trial_hip_side_a.glb", + "policy": "shell", + "skinned": true, + "source_sha256": "61716ad6fcc867cf1ef92b5321e2e1a1bc3ee5244b5774c851f89138f3203bfd", + "male": { + "path": "Armor/trial_hip_side_a.glb", + "bytes": 820332, + "sha256": "6868d5b8ee590a919a7a334e5dfe667d30dc42b3cb60fd5e935b39638e160e2a", + "moved_mean_mm": 40.595, + "moved_max_mm": 78.102, + "penetrating": 433, + "max_penetration_mm": 39.423, + "drift_p95_mm": 33.101, + "clearance_min_mm": 2.5, + "pushed": 4, + "max_push_mm": 6.301, + "shells": [ + { + "vertices": 128, + "scale": 1.0221, + "shift_mm": [ + 122.13, + 28.4, + -13.36 + ] + }, + { + "vertices": 128, + "scale": 1.0882, + "shift_mm": [ + 277.88, + 58.69, + -126.29 + ] + }, + { + "vertices": 128, + "scale": 1.0917, + "shift_mm": [ + -266.76, + 49.98, + -128.74 + ] + }, + { + "vertices": 128, + "scale": 1.0281, + "shift_mm": [ + -145.17, + 31.1, + -9.99 + ] + } + ] + }, + "shared_on_female": { + "penetrating": 98, + "max_penetration_mm": 17.471, + "drift_p95_mm": 17.257 + }, + "female": { + "path": "Female/Armor/trial_hip_side_a.glb", + "bytes": 820336, + "sha256": "4e2d946e0727589bd2f518dcf4df09686718e104e90403ce1f6b275ca0c868e5", + "moved_mean_mm": 38.676, + "moved_max_mm": 81.271, + "penetrating": 247, + "max_penetration_mm": 31.536, + "drift_p95_mm": 23.237, + "clearance_min_mm": -7.286, + "pushed": 17, + "max_push_mm": 29.763 + }, + "decision": "refit_both", + "reason": "the male fit reads 17.5 mm into the female body over 98 vertices with 17.3 mm p95 clearance drift" + }, + { + "id": "trial_hair_a", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "cranium", + "glb": "Armor/trial_hair_a.glb", + "policy": "head", + "skinned": true, + "source_sha256": "3bd987e7f9c130bbfbc092fa7279d08848f5218742167450ce8910204e4d5de6", + "male": { + "path": "Armor/trial_hair_a.glb", + "bytes": 824564, + "sha256": "94a0117b701d97fdc6c92703ea878bd33b942a4ab20c50dbc4c45cc1c8b1bdac", + "moved_mean_mm": 13.927, + "moved_max_mm": 19.235, + "penetrating": 182, + "max_penetration_mm": 31.552, + "drift_p95_mm": 25.208, + "clearance_min_mm": -34.296, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 195, + "max_penetration_mm": 21.596, + "drift_p95_mm": 16.303 + }, + "female": { + "path": "Female/Armor/trial_hair_a.glb", + "bytes": 824564, + "sha256": "0003abdd6fdcb49e312f41d7e507dcc56246e833ef805bbaf600dbd51a98f709", + "moved_mean_mm": 14.595, + "moved_max_mm": 21.767, + "penetrating": 264, + "max_penetration_mm": 28.865, + "drift_p95_mm": 21.991, + "clearance_min_mm": -31.884, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 21.6 mm into the female body over 195 vertices with 16.3 mm p95 clearance drift" + }, + { + "id": "trial_hair_b", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "cranium", + "glb": "Armor/trial_hair_b.glb", + "policy": "head", + "skinned": true, + "source_sha256": "86bd3c4df11967f5a40c70c1edf8ddccc1d912dcfdecc4a18a42785b422c2776", + "male": { + "path": "Armor/trial_hair_b.glb", + "bytes": 832380, + "sha256": "8e0732537545185b5926440bf41f0d09a03af4955020e2cd0ea3cc62a135e59e", + "moved_mean_mm": 14.551, + "moved_max_mm": 17.824, + "penetrating": 213, + "max_penetration_mm": 25.529, + "drift_p95_mm": 16.104, + "clearance_min_mm": -45.291, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 239, + "max_penetration_mm": 19.243, + "drift_p95_mm": 12.132 + }, + "female": { + "path": "Female/Armor/trial_hair_b.glb", + "bytes": 832384, + "sha256": "9b519b4d7498c4de5ff9f23dae5837d0972364ca6ea062f1e2c0b0f8f3c8cfd2", + "moved_mean_mm": 15.714, + "moved_max_mm": 19.556, + "penetrating": 350, + "max_penetration_mm": 26.349, + "drift_p95_mm": 17.336, + "clearance_min_mm": -45.492, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 19.2 mm into the female body over 239 vertices with 12.1 mm p95 clearance drift" + }, + { + "id": "trial_hair_c", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "cranium", + "glb": "Armor/trial_hair_c.glb", + "policy": "head", + "skinned": true, + "source_sha256": "7a3f45ab5982f8d4b09fc1ee30fed5abcf7024ad9fe4ad76e7e444ba6e851f9a", + "male": { + "path": "Armor/trial_hair_c.glb", + "bytes": 822124, + "sha256": "b20a1d22780fe89400bb3ea2eb6d09b5359d595e25cc57965d2f0f72d9386451", + "moved_mean_mm": 15.166, + "moved_max_mm": 19.477, + "penetrating": 195, + "max_penetration_mm": 33.372, + "drift_p95_mm": 18.705, + "clearance_min_mm": -17.075, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 223, + "max_penetration_mm": 23.45, + "drift_p95_mm": 15.851 + }, + "female": { + "path": "Female/Armor/trial_hair_c.glb", + "bytes": 822124, + "sha256": "df61a9791496cbb96695a86e5a45ebc1ee8392b40dcbf13d4c8183aa7424bd84", + "moved_mean_mm": 17.161, + "moved_max_mm": 22.366, + "penetrating": 287, + "max_penetration_mm": 30.451, + "drift_p95_mm": 19.795, + "clearance_min_mm": -23.117, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 23.5 mm into the female body over 223 vertices with 15.9 mm p95 clearance drift" + }, + { + "id": "trial_hair_d", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "cranium", + "glb": "Armor/trial_hair_d.glb", + "policy": "head", + "skinned": true, + "source_sha256": "e209192be8a538f5deda88dfa27bbe66079361898d7f7ccf7b22130a2bf4ca0b", + "male": { + "path": "Armor/trial_hair_d.glb", + "bytes": 878008, + "sha256": "217faa7a8a259a951cf97137a43dea78696248795170d2138cfa11a364abef12", + "moved_mean_mm": 14.195, + "moved_max_mm": 18.828, + "penetrating": 459, + "max_penetration_mm": 37.575, + "drift_p95_mm": 19.214, + "clearance_min_mm": -35.217, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 580, + "max_penetration_mm": 20.217, + "drift_p95_mm": 13.697 + }, + "female": { + "path": "Female/Armor/trial_hair_d.glb", + "bytes": 878012, + "sha256": "2f7a49687fb549adec77f6eb7f2559b0e5a8002274069c97c6ab2dffc9702062", + "moved_mean_mm": 15.575, + "moved_max_mm": 21.225, + "penetrating": 774, + "max_penetration_mm": 28.754, + "drift_p95_mm": 16.091, + "clearance_min_mm": -33.221, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 20.2 mm into the female body over 580 vertices with 13.7 mm p95 clearance drift" + }, + { + "id": "under_scfi_top", + "group": "SYNTY TRIALS", + "layer": "Under", + "slot": "under_torso", + "glb": "Under/SCFI_Top_Refit.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "0a78bc7762ed533dfd5bf067a107df91b823924a1716aff65cc7174355efe009", + "male": { + "path": "Under/SCFI_Top_Refit.glb", + "bytes": 308232, + "sha256": "52e123b9f6443b38646a07543c7c47896f2689f9e40fbd034789a08f789dd6ee", + "moved_mean_mm": 57.653, + "moved_max_mm": 76.561, + "penetrating": 2434, + "max_penetration_mm": 35.125, + "drift_p95_mm": 14.352, + "clearance_min_mm": -5.253, + "pushed": 34, + "max_push_mm": 13.934 + }, + "shared_on_female": { + "penetrating": 584, + "max_penetration_mm": 25.576, + "drift_p95_mm": 28.912 + }, + "female": { + "path": "Female/Under/SCFI_Top_Refit.glb", + "bytes": 308232, + "sha256": "193e2204da23605a27d23d7a41b0462f9853302e8d4ea483a357cbfed81231e6", + "moved_mean_mm": 46.575, + "moved_max_mm": 64.08, + "penetrating": 2017, + "max_penetration_mm": 26.287, + "drift_p95_mm": 11.264, + "clearance_min_mm": 1.461, + "pushed": 29, + "max_push_mm": 15.08 + }, + "decision": "refit_both", + "reason": "the male fit reads 25.6 mm into the female body over 584 vertices with 28.9 mm p95 clearance drift" + }, + { + "id": "under_scfi_pants", + "group": "SYNTY TRIALS", + "layer": "Under", + "slot": "under_legs", + "glb": "Under/SCFI_Pants_Refit.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "dc436e4fc9377ce0ad2250e0e2427abbcce5ac5233184f41614c1553d05d742e", + "male": { + "path": "Under/SCFI_Pants_Refit.glb", + "bytes": 217452, + "sha256": "ff6bdf8d8f41f90a5246177f1d31a8058f44b97fbfd2314812c663f4c4f7d6eb", + "moved_mean_mm": 39.671, + "moved_max_mm": 85.812, + "penetrating": 1976, + "max_penetration_mm": 44.235, + "drift_p95_mm": 39.388, + "clearance_min_mm": 1.5, + "pushed": 8, + "max_push_mm": 24.921 + }, + "shared_on_female": { + "penetrating": 274, + "max_penetration_mm": 26.23, + "drift_p95_mm": 38.372 + }, + "female": { + "path": "Female/Under/SCFI_Pants_Refit.glb", + "bytes": 217472, + "sha256": "848ed1bb3763817690c69cb602fc2d1b0b85b69c3df0a2418a54ad10c0b58a52", + "moved_mean_mm": 34.613, + "moved_max_mm": 55.891, + "penetrating": 1937, + "max_penetration_mm": 34.064, + "drift_p95_mm": 30.838, + "clearance_min_mm": 1.5, + "pushed": 172, + "max_push_mm": 30.414 + }, + "decision": "refit_both", + "reason": "the male fit reads 26.2 mm into the female body over 274 vertices with 38.4 mm p95 clearance drift" + }, + { + "id": "top_rigged_tank", + "group": "Tops", + "layer": "Under", + "slot": "under_torso", + "glb": "Under/top_rigged_tank.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "c5e69f50d480631960b682f961306731309db19ccc3264fb0f389df8a9c4c48a", + "male": { + "path": "Under/top_rigged_tank.glb", + "bytes": 100380, + "sha256": "f77061b829a38c539bd40e1029aa1e0fcac3327d0f43962974e4b7bca5f6c2a5", + "moved_mean_mm": 36.275, + "moved_max_mm": 72.353, + "penetrating": 865, + "max_penetration_mm": 12.964, + "drift_p95_mm": 7.819, + "clearance_min_mm": 1.5, + "pushed": 28, + "max_push_mm": 14.044 + }, + "shared_on_female": { + "penetrating": 224, + "max_penetration_mm": 11.807, + "drift_p95_mm": 35.062 + }, + "female": { + "path": "Female/Under/top_rigged_tank.glb", + "bytes": 100380, + "sha256": "5a70dd0dbeb4be3b09321c55a9ae61bfef4619ecfc151885511c8a48b3c59e66", + "moved_mean_mm": 23.839, + "moved_max_mm": 65.556, + "penetrating": 812, + "max_penetration_mm": 14.272, + "drift_p95_mm": 9.22, + "clearance_min_mm": 1.479, + "pushed": 59, + "max_push_mm": 15.425 + }, + "decision": "refit_both", + "reason": "the male fit reads 11.8 mm into the female body over 224 vertices with 35.1 mm p95 clearance drift" + }, + { + "id": "top_frayed_tunic", + "group": "Tops", + "layer": "Under", + "slot": "under_torso", + "glb": "Under/top_frayed_tunic.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "d702b136584cbaef4ce9ca4864a921f4e83d2360258eb968a2c215f1bca1073f", + "male": { + "path": "Under/top_frayed_tunic.glb", + "bytes": 135784, + "sha256": "089528b03c801a6ee56e286909e983b9f6c13c1517ed90a3ce727bd01f18c0fb", + "moved_mean_mm": 32.207, + "moved_max_mm": 71.392, + "penetrating": 1587, + "max_penetration_mm": 16.959, + "drift_p95_mm": 12.057, + "clearance_min_mm": 1.5, + "pushed": 118, + "max_push_mm": 12.912 + }, + "shared_on_female": { + "penetrating": 455, + "max_penetration_mm": 14.032, + "drift_p95_mm": 27.186 + }, + "female": { + "path": "Female/Under/top_frayed_tunic.glb", + "bytes": 135788, + "sha256": "23b09c267ef9fc11b05601c8441521527a3171ed63bdaaa73cdbc682129a317a", + "moved_mean_mm": 27.4, + "moved_max_mm": 66.443, + "penetrating": 1585, + "max_penetration_mm": 14.247, + "drift_p95_mm": 8.743, + "clearance_min_mm": 1.5, + "pushed": 13, + "max_push_mm": 3.437 + }, + "decision": "refit_both", + "reason": "the male fit reads 14.0 mm into the female body over 455 vertices with 27.2 mm p95 clearance drift" + }, + { + "id": "top_plated_rig_vest", + "group": "Tops", + "layer": "Under", + "slot": "under_torso", + "glb": "Under/top_plated_rig_vest.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "bb36685c30ac86f34c941cffe7f618c109a285945d7a28c5f9f8be9617109832", + "male": { + "path": "Under/top_plated_rig_vest.glb", + "bytes": 133088, + "sha256": "9f3d4e246a335d589657fd8b5fb907c7e1f6c7e22146a20f4c889acdaceab988", + "moved_mean_mm": 46.471, + "moved_max_mm": 65.527, + "penetrating": 1619, + "max_penetration_mm": 32.02, + "drift_p95_mm": 19.478, + "clearance_min_mm": 1.5, + "pushed": 76, + "max_push_mm": 13.186 + }, + "shared_on_female": { + "penetrating": 556, + "max_penetration_mm": 22.256, + "drift_p95_mm": 36.256 + }, + "female": { + "path": "Female/Under/top_plated_rig_vest.glb", + "bytes": 133084, + "sha256": "671e17ced76ffa3aae9c8375d6ff2dcbe5a124382c0def14042ba5c1d016cb26", + "moved_mean_mm": 37.883, + "moved_max_mm": 65.333, + "penetrating": 1628, + "max_penetration_mm": 19.645, + "drift_p95_mm": 13.945, + "clearance_min_mm": 1.5, + "pushed": 8, + "max_push_mm": 2.191 + }, + "decision": "refit_both", + "reason": "the male fit reads 22.3 mm into the female body over 556 vertices with 36.3 mm p95 clearance drift" + }, + { + "id": "top_padded_leather_vest", + "group": "Tops", + "layer": "Under", + "slot": "under_torso", + "glb": "Under/top_padded_leather_vest.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "c95c5e0f4334551ff5608d2fb772e6ee1bee4cae2cda597fb9f69d9603cccf75", + "male": { + "path": "Under/top_padded_leather_vest.glb", + "bytes": 134780, + "sha256": "e7e7739eff8497da89917d0242301ecd57ce3de90dd0fcae7042dbbd80e7482f", + "moved_mean_mm": 57.002, + "moved_max_mm": 77.605, + "penetrating": 1463, + "max_penetration_mm": 12.994, + "drift_p95_mm": 10.073, + "clearance_min_mm": 6.462, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 566, + "max_penetration_mm": 12.788, + "drift_p95_mm": 31.012 + }, + "female": { + "path": "Female/Under/top_padded_leather_vest.glb", + "bytes": 134784, + "sha256": "5410decab7f79b452fdd5712c7da81160847e38958a0bfe76ce8d6775003957b", + "moved_mean_mm": 49.952, + "moved_max_mm": 66.728, + "penetrating": 1782, + "max_penetration_mm": 18.735, + "drift_p95_mm": 11.927, + "clearance_min_mm": 5.866, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 12.8 mm into the female body over 566 vertices with 31.0 mm p95 clearance drift" + }, + { + "id": "top_spiked_leather_vest", + "group": "Tops", + "layer": "Under", + "slot": "under_torso", + "glb": "Under/top_spiked_leather_vest.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "fb048dc96984f8d27f3d310c140477539ccf556c215dad3898be427de0363b9a", + "male": { + "path": "Under/top_spiked_leather_vest.glb", + "bytes": 123044, + "sha256": "d08c1a6a21f5793c4929c775677a98cb2b892c36192c8069a238fdd52e8cfc4b", + "moved_mean_mm": 34.139, + "moved_max_mm": 77.687, + "penetrating": 1212, + "max_penetration_mm": 12.512, + "drift_p95_mm": 11.335, + "clearance_min_mm": 1.473, + "pushed": 7, + "max_push_mm": 13.477 + }, + "shared_on_female": { + "penetrating": 601, + "max_penetration_mm": 19.735, + "drift_p95_mm": 33.38 + }, + "female": { + "path": "Female/Under/top_spiked_leather_vest.glb", + "bytes": 123044, + "sha256": "8e4864467293ca27596f4136bdb767201c607bb6a353bfaa4f25e327b54fccb5", + "moved_mean_mm": 29.721, + "moved_max_mm": 66.285, + "penetrating": 1318, + "max_penetration_mm": 18.711, + "drift_p95_mm": 15.849, + "clearance_min_mm": 1.462, + "pushed": 5, + "max_push_mm": 13.632 + }, + "decision": "refit_both", + "reason": "the male fit reads 19.7 mm into the female body over 601 vertices with 33.4 mm p95 clearance drift" + }, + { + "id": "top_reinforced_crop_vest", + "group": "Tops", + "layer": "Under", + "slot": "under_torso", + "glb": "Under/top_reinforced_crop_vest.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "a7871edd26fef18d4c552c1bccf5ec4ebfa10495f27adf2ebd3cbb3fe61010e9", + "male": { + "path": "Under/top_reinforced_crop_vest.glb", + "bytes": 92040, + "sha256": "963eaf7a8c3aa9770a6fed7bc0a7209c8b0bc4b67348b78cb5843b7034eaec07", + "moved_mean_mm": 38.332, + "moved_max_mm": 70.27, + "penetrating": 929, + "max_penetration_mm": 13.335, + "drift_p95_mm": 10.945, + "clearance_min_mm": 4.695, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 358, + "max_penetration_mm": 16.025, + "drift_p95_mm": 32.189 + }, + "female": { + "path": "Female/Under/top_reinforced_crop_vest.glb", + "bytes": 92044, + "sha256": "69580f1aa4e2946a375ab7db6d4304f803716f9dcbce1fdf5b81db6718129f58", + "moved_mean_mm": 27.982, + "moved_max_mm": 64.278, + "penetrating": 931, + "max_penetration_mm": 17.885, + "drift_p95_mm": 15.776, + "clearance_min_mm": 3.8, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 16.0 mm into the female body over 358 vertices with 32.2 mm p95 clearance drift" + }, + { + "id": "top_scrap_plate_tunic", + "group": "Tops", + "layer": "Under", + "slot": "under_torso", + "glb": "Under/top_scrap_plate_tunic.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "dce7327976b0fdd39effc33978ff341cc83b4970fdd14a29753c6f017946aaef", + "male": { + "path": "Under/top_scrap_plate_tunic.glb", + "bytes": 131444, + "sha256": "846ee6fcbe24c2dc78087759c2e2486ee956149a4c408be8aabdd950ff335488", + "moved_mean_mm": 41.595, + "moved_max_mm": 78.542, + "penetrating": 1314, + "max_penetration_mm": 25.892, + "drift_p95_mm": 10.554, + "clearance_min_mm": 1.459, + "pushed": 34, + "max_push_mm": 20.549 + }, + "shared_on_female": { + "penetrating": 302, + "max_penetration_mm": 13.161, + "drift_p95_mm": 29.392 + }, + "female": { + "path": "Female/Under/top_scrap_plate_tunic.glb", + "bytes": 131440, + "sha256": "9ba04553f2ed4623c06f8181eeca7726aa351a82666faf18b1592c312290e7e8", + "moved_mean_mm": 37.505, + "moved_max_mm": 71.025, + "penetrating": 1325, + "max_penetration_mm": 21.771, + "drift_p95_mm": 12.526, + "clearance_min_mm": 1.364, + "pushed": 24, + "max_push_mm": 18.536 + }, + "decision": "refit_both", + "reason": "the male fit reads 13.2 mm into the female body over 302 vertices with 29.4 mm p95 clearance drift" + }, + { + "id": "top_laced_corset_vest", + "group": "Tops", + "layer": "Under", + "slot": "under_torso", + "glb": "Under/top_laced_corset_vest.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "298366a81ef8284daa76cd5c0f3258f212f5737a7cd143b3059f07c9c2ef627f", + "male": { + "path": "Under/top_laced_corset_vest.glb", + "bytes": 142932, + "sha256": "789bad6e14d8fba7e9a1a1dd81c68b3d0c9caebd532b15e6d18849dbb1380967", + "moved_mean_mm": 30.506, + "moved_max_mm": 76.543, + "penetrating": 1191, + "max_penetration_mm": 13.916, + "drift_p95_mm": 10.038, + "clearance_min_mm": 4.537, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 235, + "max_penetration_mm": 15.386, + "drift_p95_mm": 35.138 + }, + "female": { + "path": "Female/Under/top_laced_corset_vest.glb", + "bytes": 142924, + "sha256": "2da263833c592aea0992d2b5f2da4797df8cc98a87d4780f64ecee7b24323ac2", + "moved_mean_mm": 21.096, + "moved_max_mm": 65.459, + "penetrating": 1163, + "max_penetration_mm": 14.028, + "drift_p95_mm": 11.055, + "clearance_min_mm": 4.574, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 15.4 mm into the female body over 235 vertices with 35.1 mm p95 clearance drift" + }, + { + "id": "legs_wrapped_workpants", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "glb": "Under/legs_wrapped_workpants.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "c984bd9c04681f4e2b6f29f9ff21caccc026fb78e582cc4c1c5651f111ae5285", + "male": { + "path": "Under/legs_wrapped_workpants.glb", + "bytes": 190916, + "sha256": "5e257e96e0c0738222f91f9cda2124c22bf0e0ee1a76ad378a5175bc9f99ce67", + "moved_mean_mm": 25.39, + "moved_max_mm": 92.268, + "penetrating": 1690, + "max_penetration_mm": 53.342, + "drift_p95_mm": 46.813, + "clearance_min_mm": 1.5, + "pushed": 31, + "max_push_mm": 26.293 + }, + "shared_on_female": { + "penetrating": 351, + "max_penetration_mm": 36.158, + "drift_p95_mm": 46.169 + }, + "female": { + "path": "Female/Under/legs_wrapped_workpants.glb", + "bytes": 190912, + "sha256": "5d6bb546bee4354d7546b1c9f725c32d535e6efe79c49e75867bfc960a4856fc", + "moved_mean_mm": 29.599, + "moved_max_mm": 100.598, + "penetrating": 1729, + "max_penetration_mm": 60.91, + "drift_p95_mm": 51.902, + "clearance_min_mm": 1.478, + "pushed": 133, + "max_push_mm": 62.952 + }, + "decision": "refit_both", + "reason": "the male fit reads 36.2 mm into the female body over 351 vertices with 46.2 mm p95 clearance drift" + }, + { + "id": "legs_reinforced_denim_pants", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "glb": "Under/legs_reinforced_denim_pants.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "4d464faa1a3677fea6ab92efa7ce8116ff8a20c703dc882d89f922f1de5049c3", + "male": { + "path": "Under/legs_reinforced_denim_pants.glb", + "bytes": 199004, + "sha256": "ae56e4e4910aefb89878fc4142d60b7e87cee3f0cd894f575185fe36bb6693ee", + "moved_mean_mm": 37.109, + "moved_max_mm": 86.241, + "penetrating": 2235, + "max_penetration_mm": 52.265, + "drift_p95_mm": 45.567, + "clearance_min_mm": 1.5, + "pushed": 214, + "max_push_mm": 38.513 + }, + "shared_on_female": { + "penetrating": 482, + "max_penetration_mm": 36.267, + "drift_p95_mm": 35.21 + }, + "female": { + "path": "Female/Under/legs_reinforced_denim_pants.glb", + "bytes": 198992, + "sha256": "92b8eb19e3edff310bef9b3e6563d60825d5a0664815c63c71638deca55aca3d", + "moved_mean_mm": 33.23, + "moved_max_mm": 121.742, + "penetrating": 1927, + "max_penetration_mm": 43.956, + "drift_p95_mm": 29.454, + "clearance_min_mm": -14.441, + "pushed": 428, + "max_push_mm": 67.71 + }, + "decision": "refit_both", + "reason": "the male fit reads 36.3 mm into the female body over 482 vertices with 35.2 mm p95 clearance drift" + }, + { + "id": "legs_plated_trousers", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "glb": "Under/legs_plated_trousers.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "eddbabbe2b0bd9627b4be050ceaf13b2cd6f0943817dd1e0b56709e2ea978e81", + "male": { + "path": "Under/legs_plated_trousers.glb", + "bytes": 172516, + "sha256": "1ea63718f63259d93d297e35116d265e9057d12b7a44d57d370f67605495c67a", + "moved_mean_mm": 39.956, + "moved_max_mm": 90.39, + "penetrating": 1757, + "max_penetration_mm": 49.838, + "drift_p95_mm": 44.754, + "clearance_min_mm": 1.5, + "pushed": 107, + "max_push_mm": 25.158 + }, + "shared_on_female": { + "penetrating": 410, + "max_penetration_mm": 36.828, + "drift_p95_mm": 41.564 + }, + "female": { + "path": "Female/Under/legs_plated_trousers.glb", + "bytes": 172524, + "sha256": "27d8c5775f1ad50c04d38f29f31e5098dd952b73490e4beb91166d5087861afc", + "moved_mean_mm": 104.254, + "moved_max_mm": 119.403, + "penetrating": 1725, + "max_penetration_mm": 55.839, + "drift_p95_mm": 46.591, + "clearance_min_mm": 1.33, + "pushed": 379, + "max_push_mm": 67.49 + }, + "decision": "refit_both", + "reason": "the male fit reads 36.8 mm into the female body over 410 vertices with 41.6 mm p95 clearance drift" + }, + { + "id": "legs_layered_shorts", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "glb": "Under/legs_layered_shorts.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "a24bc77d6df37da9c083734c374e413e54df8b6197313eed594884494eef912b", + "male": { + "path": "Under/legs_layered_shorts.glb", + "bytes": 226952, + "sha256": "cbb79c60671cec8b20a337edc3622390b2e1376d9b6d1e94e5d4e294f424a29e", + "moved_mean_mm": 39.424, + "moved_max_mm": 87.469, + "penetrating": 3105, + "max_penetration_mm": 53.826, + "drift_p95_mm": 48.16, + "clearance_min_mm": 1.463, + "pushed": 423, + "max_push_mm": 51.241 + }, + "shared_on_female": { + "penetrating": 1103, + "max_penetration_mm": 36.045, + "drift_p95_mm": 35.315 + }, + "female": { + "path": "Female/Under/legs_layered_shorts.glb", + "bytes": 226952, + "sha256": "7d52bc8d7cdaf003a79b18281dedd59e9850a5331d1f4ce1c9d12df33aeb104b", + "moved_mean_mm": 32.464, + "moved_max_mm": 122.927, + "penetrating": 2891, + "max_penetration_mm": 58.152, + "drift_p95_mm": 55.877, + "clearance_min_mm": 0.485, + "pushed": 1013, + "max_push_mm": 64.909 + }, + "decision": "refit_both", + "reason": "the male fit reads 36.0 mm into the female body over 1103 vertices with 35.3 mm p95 clearance drift" + }, + { + "id": "legs_strapped_trousers", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "glb": "Under/legs_strapped_trousers.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "2f329df2c336712b2d7097f9508b1eebae3f571bc8204c5a5682397579296bb8", + "male": { + "path": "Under/legs_strapped_trousers.glb", + "bytes": 222368, + "sha256": "414043cda6c0231c9ab4056860d207ee5d600dc3fc05772947329acc08bb507b", + "moved_mean_mm": 33.214, + "moved_max_mm": 91.752, + "penetrating": 2629, + "max_penetration_mm": 50.671, + "drift_p95_mm": 48.364, + "clearance_min_mm": 1.473, + "pushed": 350, + "max_push_mm": 40.381 + }, + "shared_on_female": { + "penetrating": 645, + "max_penetration_mm": 36.398, + "drift_p95_mm": 37.458 + }, + "female": { + "path": "Female/Under/legs_strapped_trousers.glb", + "bytes": 222364, + "sha256": "e6ad6879d49d0c528fecf84b29b5d1ce9e55817ec59e9152b001836764ab8da4", + "moved_mean_mm": 40.297, + "moved_max_mm": 122.068, + "penetrating": 2293, + "max_penetration_mm": 64.747, + "drift_p95_mm": 61.112, + "clearance_min_mm": -0.583, + "pushed": 566, + "max_push_mm": 66.195 + }, + "decision": "refit_both", + "reason": "the male fit reads 36.4 mm into the female body over 645 vertices with 37.5 mm p95 clearance drift" + }, + { + "id": "legs_skirted_workpants", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "glb": "Under/legs_skirted_workpants.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "241e35a84f6590bd81ab8cae28d1ca4b1f2232894576d763a171e021fa59d68a", + "male": { + "path": "Under/legs_skirted_workpants.glb", + "bytes": 184652, + "sha256": "24371513c72469daa5574d4218339110e70a35e1199296c7d963f8c30680e6e1", + "moved_mean_mm": 37.286, + "moved_max_mm": 87.704, + "penetrating": 1876, + "max_penetration_mm": 48.318, + "drift_p95_mm": 42.297, + "clearance_min_mm": 1.452, + "pushed": 113, + "max_push_mm": 24.95 + }, + "shared_on_female": { + "penetrating": 430, + "max_penetration_mm": 28.862, + "drift_p95_mm": 36.453 + }, + "female": { + "path": "Female/Under/legs_skirted_workpants.glb", + "bytes": 184644, + "sha256": "7a8ca2d6aad62a38ed77a6f4b549028e9a7d72a9d82f1db3daa10d55101eb38f", + "moved_mean_mm": 30.549, + "moved_max_mm": 126.313, + "penetrating": 1839, + "max_penetration_mm": 57.069, + "drift_p95_mm": 39.973, + "clearance_min_mm": 1.476, + "pushed": 287, + "max_push_mm": 67.144 + }, + "decision": "refit_both", + "reason": "the male fit reads 28.9 mm into the female body over 430 vertices with 36.5 mm p95 clearance drift" + }, + { + "id": "legs_gaitered_cargo_pants", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "glb": "Under/legs_gaitered_cargo_pants.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "72c0f31bb8c8cff7655a1344a8ebfe542b15ee0ac2ca14de0f330c36d745cefa", + "male": { + "path": "Under/legs_gaitered_cargo_pants.glb", + "bytes": 199300, + "sha256": "f97e97f3f95fc02eef6c3b3ca4a273e84aee6de516a6c9cd640710ecd1e88008", + "moved_mean_mm": 25.052, + "moved_max_mm": 91.524, + "penetrating": 2308, + "max_penetration_mm": 50.053, + "drift_p95_mm": 44.509, + "clearance_min_mm": 1.5, + "pushed": 133, + "max_push_mm": 25.158 + }, + "shared_on_female": { + "penetrating": 471, + "max_penetration_mm": 32.619, + "drift_p95_mm": 33.469 + }, + "female": { + "path": "Female/Under/legs_gaitered_cargo_pants.glb", + "bytes": 199300, + "sha256": "a867c6b839451a3e53c68afd0dac2cbe0c143899683bfac3f3caba1f2a89b797", + "moved_mean_mm": 29.826, + "moved_max_mm": 109.843, + "penetrating": 2129, + "max_penetration_mm": 45.431, + "drift_p95_mm": 32.951, + "clearance_min_mm": 1.485, + "pushed": 332, + "max_push_mm": 63.821 + }, + "decision": "refit_both", + "reason": "the male fit reads 32.6 mm into the female body over 471 vertices with 33.5 mm p95 clearance drift" + }, + { + "id": "legs_padded_canvas_trousers", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "glb": "Under/legs_padded_canvas_trousers.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "c571859921b586bd5042fee88cf8d4d39b139d35663673cb2c42041ee7e9d7e3", + "male": { + "path": "Under/legs_padded_canvas_trousers.glb", + "bytes": 180924, + "sha256": "cc436e9e7b780968b42efcd795be8be0ad5608efba3d91535425ee8c67be130f", + "moved_mean_mm": 34.417, + "moved_max_mm": 81.109, + "penetrating": 1968, + "max_penetration_mm": 46.623, + "drift_p95_mm": 44.181, + "clearance_min_mm": 1.5, + "pushed": 150, + "max_push_mm": 25.657 + }, + "shared_on_female": { + "penetrating": 456, + "max_penetration_mm": 27.623, + "drift_p95_mm": 40.182 + }, + "female": { + "path": "Female/Under/legs_padded_canvas_trousers.glb", + "bytes": 180932, + "sha256": "d0db94d4d1e2da0764dedb602179e0931edbaa82688cc415be7157d555bfea6f", + "moved_mean_mm": 28.986, + "moved_max_mm": 105.62, + "penetrating": 1672, + "max_penetration_mm": 45.9, + "drift_p95_mm": 27.071, + "clearance_min_mm": 1.5, + "pushed": 355, + "max_push_mm": 57.727 + }, + "decision": "refit_both", + "reason": "the male fit reads 27.6 mm into the female body over 456 vertices with 40.2 mm p95 clearance drift" + }, + { + "id": "legs_sashed_patrol_pants", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "glb": "Under/legs_sashed_patrol_pants.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "bf6d4ce3e473b3aebadbd76b8d3f488f84d48fa82e591fe716940b8332d2cded", + "male": { + "path": "Under/legs_sashed_patrol_pants.glb", + "bytes": 202104, + "sha256": "467f2a7cf1bfc2b57929ff5b453cd5f4ddf0b6d0abedbd0a66c5fe04c2cbabe8", + "moved_mean_mm": 37.503, + "moved_max_mm": 86.605, + "penetrating": 2171, + "max_penetration_mm": 54.65, + "drift_p95_mm": 47.086, + "clearance_min_mm": 1.46, + "pushed": 139, + "max_push_mm": 48.078 + }, + "shared_on_female": { + "penetrating": 536, + "max_penetration_mm": 35.776, + "drift_p95_mm": 38.983 + }, + "female": { + "path": "Female/Under/legs_sashed_patrol_pants.glb", + "bytes": 202112, + "sha256": "7fd35fe70c2dd6828840fcf44925490803a833a0b274f3a8b0a4939ef93ddf7d", + "moved_mean_mm": 32.851, + "moved_max_mm": 123.917, + "penetrating": 2085, + "max_penetration_mm": 58.718, + "drift_p95_mm": 42.982, + "clearance_min_mm": 1.478, + "pushed": 397, + "max_push_mm": 63.119 + }, + "decision": "refit_both", + "reason": "the male fit reads 35.8 mm into the female body over 536 vertices with 39.0 mm p95 clearance drift" + }, + { + "id": "legs_layered_wrap_skirt", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "glb": "Under/legs_layered_wrap_skirt.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "18eda0142d283a889a9f1b5e8a0a646600d925b94be7234cdecbca223dfd6372", + "male": { + "path": "Under/legs_layered_wrap_skirt.glb", + "bytes": 184420, + "sha256": "2cc84068c65fc832e3d61a7129b69667f431a80163527eef79272d707fd4f41e", + "moved_mean_mm": 35.799, + "moved_max_mm": 84.904, + "penetrating": 2005, + "max_penetration_mm": 48.733, + "drift_p95_mm": 44.475, + "clearance_min_mm": 1.5, + "pushed": 66, + "max_push_mm": 27.659 + }, + "shared_on_female": { + "penetrating": 537, + "max_penetration_mm": 36.169, + "drift_p95_mm": 38.338 + }, + "female": { + "path": "Female/Under/legs_layered_wrap_skirt.glb", + "bytes": 184428, + "sha256": "7b7a3b13bc32e3133a9b36bd7eb9b80caf2f5e6481937a0d52b5da4b8cd2f504", + "moved_mean_mm": 37.197, + "moved_max_mm": 127.118, + "penetrating": 2013, + "max_penetration_mm": 62.222, + "drift_p95_mm": 57.368, + "clearance_min_mm": 1.489, + "pushed": 302, + "max_push_mm": 65.122 + }, + "decision": "refit_both", + "reason": "the male fit reads 36.2 mm into the female body over 537 vertices with 38.3 mm p95 clearance drift" + }, + { + "id": "boots_canvas_ankle", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "glb": "Under/boots_canvas_ankle.glb", + "policy": "authored", + "skinned": true, + "decision": "authored", + "reason": "generated per body by author_starter_apparel.py" + }, + { + "id": "boots_layered_sneakers", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "glb": "Under/boots_layered_sneakers.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "1839530ac47eb0b3dd4c12c4ec490662f3620713b376430f64c24ad50db08d48", + "male": { + "path": "Under/boots_layered_sneakers.glb", + "bytes": 55416, + "sha256": "da6615015193fd6f2aea75fa9924b42029cc35f07ca5c9f9b1774f549cc0f811", + "moved_mean_mm": 27.49, + "moved_max_mm": 49.491, + "penetrating": 288, + "max_penetration_mm": 16.357, + "drift_p95_mm": 42.266, + "clearance_min_mm": 1.5, + "pushed": 2, + "max_push_mm": 16.742 + }, + "shared_on_female": { + "penetrating": 105, + "max_penetration_mm": 19.538, + "drift_p95_mm": 55.789 + }, + "female": { + "path": "Female/Under/boots_layered_sneakers.glb", + "bytes": 55404, + "sha256": "26b3b3b3599fe6ef20a03bd6492f2e9c8321a7c2102d4577fe6baf20817898e2", + "moved_mean_mm": 33.284, + "moved_max_mm": 42.672, + "penetrating": 277, + "max_penetration_mm": 20.829, + "drift_p95_mm": 41.819, + "clearance_min_mm": 1.5, + "pushed": 11, + "max_push_mm": 9.811 + }, + "decision": "refit_both", + "reason": "the male fit reads 19.5 mm into the female body over 105 vertices with 55.8 mm p95 clearance drift" + }, + { + "id": "boots_split_toe", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "glb": "Under/boots_split_toe.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "4bfa7784f959c9ac37e67f80464fc2cd2594ada1a688135b555969c7a2ed7682", + "male": { + "path": "Under/boots_split_toe.glb", + "bytes": 64444, + "sha256": "65fc2884690fb135e001ab7982e7bf4f3e2f4461fc3ea320aace328a3493b834", + "moved_mean_mm": 15.958, + "moved_max_mm": 47.714, + "penetrating": 318, + "max_penetration_mm": 17.848, + "drift_p95_mm": 22.184, + "clearance_min_mm": -5.975, + "pushed": 38, + "max_push_mm": 10.047 + }, + "shared_on_female": { + "penetrating": 149, + "max_penetration_mm": 26.123, + "drift_p95_mm": 37.394 + }, + "female": { + "path": "Female/Under/boots_split_toe.glb", + "bytes": 64444, + "sha256": "c6f49a6b4e5d032618da37300f3d4772a4230a71f909115330e412483f10ec4b", + "moved_mean_mm": 34.543, + "moved_max_mm": 42.475, + "penetrating": 298, + "max_penetration_mm": 17.073, + "drift_p95_mm": 25.685, + "clearance_min_mm": -3.658, + "pushed": 14, + "max_push_mm": 4.039 + }, + "decision": "refit_both", + "reason": "the male fit reads 26.1 mm into the female body over 149 vertices with 37.4 mm p95 clearance drift" + }, + { + "id": "boots_trail_shoes", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "glb": "Under/boots_trail_shoes.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "8b638d2a65126b71cc14e291fb01a6ea1d5a23f0e5f96e478cfcb3cecbf8d461", + "male": { + "path": "Under/boots_trail_shoes.glb", + "bytes": 68556, + "sha256": "53e6757a88c9f2de42003b00a5e9de2f9c235363d4b1684e80544667db8e903b", + "moved_mean_mm": 19.695, + "moved_max_mm": 43.816, + "penetrating": 246, + "max_penetration_mm": 20.212, + "drift_p95_mm": 37.876, + "clearance_min_mm": 1.499, + "pushed": 50, + "max_push_mm": 19.443 + }, + "shared_on_female": { + "penetrating": 122, + "max_penetration_mm": 19.577, + "drift_p95_mm": 47.31 + }, + "female": { + "path": "Female/Under/boots_trail_shoes.glb", + "bytes": 68568, + "sha256": "4b11796a3562da66670baba2823a5909b008bcc1aa725bbe1a395d35cb5df4a4", + "moved_mean_mm": 33.99, + "moved_max_mm": 43.55, + "penetrating": 239, + "max_penetration_mm": 22.747, + "drift_p95_mm": 38.212, + "clearance_min_mm": 1.462, + "pushed": 31, + "max_push_mm": 14.654 + }, + "decision": "refit_both", + "reason": "the male fit reads 19.6 mm into the female body over 122 vertices with 47.3 mm p95 clearance drift" + }, + { + "id": "boots_double_strap", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "glb": "Under/boots_double_strap.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "1d2181a0fac74a15847356fa39dd528dc5249549febc7441449603d979d0f3f1", + "male": { + "path": "Under/boots_double_strap.glb", + "bytes": 79728, + "sha256": "2854cc8fbf384bd2ddb12fb726857a660d88a45f738b8235e54604538a34b0d8", + "moved_mean_mm": 16.024, + "moved_max_mm": 47.876, + "penetrating": 573, + "max_penetration_mm": 18.791, + "drift_p95_mm": 30.138, + "clearance_min_mm": 1.5, + "pushed": 26, + "max_push_mm": 22.432 + }, + "shared_on_female": { + "penetrating": 186, + "max_penetration_mm": 16.198, + "drift_p95_mm": 41.134 + }, + "female": { + "path": "Female/Under/boots_double_strap.glb", + "bytes": 79724, + "sha256": "e00ec720c0c5f00af6680a83499a48b8a32807400ca3237864966528e0f54835", + "moved_mean_mm": 30.143, + "moved_max_mm": 43.386, + "penetrating": 499, + "max_penetration_mm": 20.314, + "drift_p95_mm": 30.164, + "clearance_min_mm": 1.452, + "pushed": 26, + "max_push_mm": 13.174 + }, + "decision": "refit_both", + "reason": "the male fit reads 16.2 mm into the female body over 186 vertices with 41.1 mm p95 clearance drift" + }, + { + "id": "boots_rubberized_canvas", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "glb": "Under/boots_rubberized_canvas.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "45e6a3f591b3750d11174c05489cb87ae3e05536a9da2c6315d9b5bcfddc76aa", + "male": { + "path": "Under/boots_rubberized_canvas.glb", + "bytes": 91504, + "sha256": "1297f17fa95fead6c099a72de7f8a4512c1c883959ee8149acacdf35e1787ee4", + "moved_mean_mm": 20.172, + "moved_max_mm": 47.584, + "penetrating": 582, + "max_penetration_mm": 29.877, + "drift_p95_mm": 36.73, + "clearance_min_mm": 1.5, + "pushed": 30, + "max_push_mm": 17.849 + }, + "shared_on_female": { + "penetrating": 304, + "max_penetration_mm": 29.18, + "drift_p95_mm": 48.647 + }, + "female": { + "path": "Female/Under/boots_rubberized_canvas.glb", + "bytes": 91504, + "sha256": "0245dc1cef9e6587a63e3216092bacec37e8339e93cfb898d83f7d51a12c667b", + "moved_mean_mm": 29.632, + "moved_max_mm": 43.682, + "penetrating": 596, + "max_penetration_mm": 23.956, + "drift_p95_mm": 34.172, + "clearance_min_mm": 1.5, + "pushed": 28, + "max_push_mm": 9.56 + }, + "decision": "refit_both", + "reason": "the male fit reads 29.2 mm into the female body over 304 vertices with 48.6 mm p95 clearance drift" + }, + { + "id": "boots_toe_capped", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "glb": "Under/boots_toe_capped.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "2185ae8984fa8c0c2bcf434dc369eee59f3696885d4180bf0ecd1f8c85438008", + "male": { + "path": "Under/boots_toe_capped.glb", + "bytes": 44016, + "sha256": "ed3967d438467d1c249426563c78355bbd7bb134e7190fe4c9ad2798bb46e17d", + "moved_mean_mm": 16.062, + "moved_max_mm": 47.447, + "penetrating": 253, + "max_penetration_mm": 18.376, + "drift_p95_mm": 25.408, + "clearance_min_mm": 1.479, + "pushed": 38, + "max_push_mm": 23.962 + }, + "shared_on_female": { + "penetrating": 107, + "max_penetration_mm": 17.647, + "drift_p95_mm": 35.015 + }, + "female": { + "path": "Female/Under/boots_toe_capped.glb", + "bytes": 44016, + "sha256": "6eab43391eb0ff3d1901fd511d253255c884b48697c5154cfca57c7accfaf917", + "moved_mean_mm": 35.815, + "moved_max_mm": 43.686, + "penetrating": 224, + "max_penetration_mm": 18.967, + "drift_p95_mm": 30.417, + "clearance_min_mm": 1.489, + "pushed": 18, + "max_push_mm": 7.836 + }, + "decision": "refit_both", + "reason": "the male fit reads 17.6 mm into the female body over 107 vertices with 35.0 mm p95 clearance drift" + }, + { + "id": "boots_combat_sneakers", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "glb": "Under/boots_combat_sneakers.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "122a32ce463931f2b4a5ed8e935ae2e783b7f1cdbd09a7904787ac657607d1ea", + "male": { + "path": "Under/boots_combat_sneakers.glb", + "bytes": 61276, + "sha256": "2e79aa8ada888a5d0e55647330b0dd1bde00728bc7af08a449b9d64df8d9dd5f", + "moved_mean_mm": 22.136, + "moved_max_mm": 46.837, + "penetrating": 273, + "max_penetration_mm": 24.083, + "drift_p95_mm": 46.96, + "clearance_min_mm": 1.5, + "pushed": 14, + "max_push_mm": 17.674 + }, + "shared_on_female": { + "penetrating": 109, + "max_penetration_mm": 47.071, + "drift_p95_mm": 57.899 + }, + "female": { + "path": "Female/Under/boots_combat_sneakers.glb", + "bytes": 61280, + "sha256": "40b1f316ccba557d7105ea9890b427034cb0997b735ecf75c34f545cde0675b0", + "moved_mean_mm": 31.006, + "moved_max_mm": 42.73, + "penetrating": 224, + "max_penetration_mm": 20.731, + "drift_p95_mm": 37.613, + "clearance_min_mm": 1.5, + "pushed": 2, + "max_push_mm": 10.268 + }, + "decision": "refit_both", + "reason": "the male fit reads 47.1 mm into the female body over 109 vertices with 57.9 mm p95 clearance drift" + }, + { + "id": "boots_cuffed_runners", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "glb": "Under/boots_cuffed_runners.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "a41a5716e510c2431f16ce50f077f65e0927a2a19a9f45bd17c5fbddf38b8149", + "male": { + "path": "Under/boots_cuffed_runners.glb", + "bytes": 62648, + "sha256": "9b0ea8a76b09bb04da560b24f9c528193a564c39e2ccc4828879fe298161ca34", + "moved_mean_mm": 18.388, + "moved_max_mm": 50.395, + "penetrating": 185, + "max_penetration_mm": 22.245, + "drift_p95_mm": 39.004, + "clearance_min_mm": 1.5, + "pushed": 11, + "max_push_mm": 19.076 + }, + "shared_on_female": { + "penetrating": 86, + "max_penetration_mm": 21.57, + "drift_p95_mm": 48.218 + }, + "female": { + "path": "Female/Under/boots_cuffed_runners.glb", + "bytes": 62648, + "sha256": "cf70bcf65d23305d03d2cfc6a92809d183d874e35abff485496a84f4cdcfe9ff", + "moved_mean_mm": 32.166, + "moved_max_mm": 40.982, + "penetrating": 187, + "max_penetration_mm": 21.325, + "drift_p95_mm": 32.588, + "clearance_min_mm": 1.488, + "pushed": 22, + "max_push_mm": 15.319 + }, + "decision": "refit_both", + "reason": "the male fit reads 21.6 mm into the female body over 86 vertices with 48.2 mm p95 clearance drift" + }, + { + "id": "boots_reinforced_street", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "glb": "Under/boots_reinforced_street.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "f9ec0acbab606fdd70706896a8b1f6608885a9079d5be3b7490471704201d581", + "male": { + "path": "Under/boots_reinforced_street.glb", + "bytes": 72384, + "sha256": "c70e667130be62226b4e98fac381658fa2ebe51fd5ca746679dcd10b1efb327d", + "moved_mean_mm": 18.726, + "moved_max_mm": 46.323, + "penetrating": 457, + "max_penetration_mm": 23.639, + "drift_p95_mm": 25.195, + "clearance_min_mm": 1.5, + "pushed": 68, + "max_push_mm": 15.478 + }, + "shared_on_female": { + "penetrating": 226, + "max_penetration_mm": 22.955, + "drift_p95_mm": 36.048 + }, + "female": { + "path": "Female/Under/boots_reinforced_street.glb", + "bytes": 72388, + "sha256": "bc70685489254e4bcbe6fc8593da1b2cfc1457db0d022269c66b12fd19336b5c", + "moved_mean_mm": 33.397, + "moved_max_mm": 43.423, + "penetrating": 428, + "max_penetration_mm": 19.767, + "drift_p95_mm": 34.288, + "clearance_min_mm": 1.464, + "pushed": 46, + "max_push_mm": 10.25 + }, + "decision": "refit_both", + "reason": "the male fit reads 23.0 mm into the female body over 226 vertices with 36.0 mm p95 clearance drift" + }, + { + "id": "gloves_knuckled_half", + "group": "Gloves", + "layer": "Under", + "slot": "under_hands", + "glb": "Under/gloves_knuckled_half.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "9ed6e75b3d437f8245c46836428450b23b931ccf9634f2ddb9284be33499f691", + "male": { + "path": "Under/gloves_knuckled_half.glb", + "bytes": 43396, + "sha256": "fd60ddaf6f51b96f820b394460c4afed179f90a5143f0d7b0e68a8d2e1fc4437", + "moved_mean_mm": 0.374, + "moved_max_mm": 6.133, + "penetrating": 8, + "max_penetration_mm": 7.485, + "drift_p95_mm": 2.997, + "clearance_min_mm": 5.094, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 8, + "max_penetration_mm": 7.484, + "drift_p95_mm": 2.997 + }, + "female": { + "path": "Female/Under/gloves_knuckled_half.glb", + "bytes": 43396, + "sha256": "82dc4642a2007d8dcd2c275cbf165ed8ffc0f94cfecf987850bed12cb5bbcebd", + "moved_mean_mm": 0.356, + "moved_max_mm": 5.836, + "penetrating": 8, + "max_penetration_mm": 6.551, + "drift_p95_mm": 2.494, + "clearance_min_mm": 5.116, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 7.5 mm into the female body over 8 vertices with 3.0 mm p95 clearance drift" + }, + { + "id": "gloves_guarded_leather", + "group": "Gloves", + "layer": "Under", + "slot": "under_hands", + "glb": "Under/gloves_guarded_leather.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "cdbef349a980b4bd587a9e0b65e72f0edae53b2f7f7136efa2207eb4c1ecd7cf", + "male": { + "path": "Under/gloves_guarded_leather.glb", + "bytes": 75172, + "sha256": "b30715b22e2931cb8f22eb060c12f17ee39311b4d94729ec46cdae8c2bc2dbf4", + "moved_mean_mm": 1.095, + "moved_max_mm": 13.611, + "penetrating": 34, + "max_penetration_mm": 9.846, + "drift_p95_mm": 6.363, + "clearance_min_mm": 4.416, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 34, + "max_penetration_mm": 9.845, + "drift_p95_mm": 8.058 + }, + "female": { + "path": "Female/Under/gloves_guarded_leather.glb", + "bytes": 75172, + "sha256": "4f7cfca859f4da855dca1d85eea37f19386e60c9d6cd4295a4540d35e9e0db5d", + "moved_mean_mm": 0.859, + "moved_max_mm": 9.641, + "penetrating": 21, + "max_penetration_mm": 9.729, + "drift_p95_mm": 8.263, + "clearance_min_mm": 4.421, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 9.8 mm into the female body over 34 vertices with 8.1 mm p95 clearance drift" + }, + { + "id": "gloves_plain_work", + "group": "Gloves", + "layer": "Under", + "slot": "under_hands", + "glb": "Under/gloves_plain_work.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "7e8d737a267c626d8be0ea3e0ffa4baabb651065987b0ca8020bfbbc8c3abab8", + "male": { + "path": "Under/gloves_plain_work.glb", + "bytes": 46360, + "sha256": "16db1c2c83a7965a179fbebe1ccf36d7dbac02a83bc55ee41bfa0e8cc6cf89b9", + "moved_mean_mm": 1.777, + "moved_max_mm": 15.079, + "penetrating": 49, + "max_penetration_mm": 10.398, + "drift_p95_mm": 7.583, + "clearance_min_mm": 1.5, + "pushed": 1, + "max_push_mm": 8.601 + }, + "shared_on_female": { + "penetrating": 47, + "max_penetration_mm": 10.397, + "drift_p95_mm": 9.01 + }, + "female": { + "path": "Female/Under/gloves_plain_work.glb", + "bytes": 46356, + "sha256": "816b28bb67f4b65cdf0727b99930902764014a96b30f655b44818fd1900d964d", + "moved_mean_mm": 1.696, + "moved_max_mm": 15.044, + "penetrating": 39, + "max_penetration_mm": 9.108, + "drift_p95_mm": 8.052, + "clearance_min_mm": 1.5, + "pushed": 1, + "max_push_mm": 8.5 + }, + "decision": "refit_both", + "reason": "the male fit reads 10.4 mm into the female body over 47 vertices with 9.0 mm p95 clearance drift" + }, + { + "id": "gloves_reinforced_knit", + "group": "Gloves", + "layer": "Under", + "slot": "under_hands", + "glb": "Under/gloves_reinforced_knit.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "3e106066d5843ee33818c16450d6209bf6007b3173fe290f4e5fbb4a7abb25ca", + "male": { + "path": "Under/gloves_reinforced_knit.glb", + "bytes": 49012, + "sha256": "bbf327d07de9d39024964404a3f03c95c18baaa02c350f1aecf82b91bc40dd38", + "moved_mean_mm": 0.008, + "moved_max_mm": 0.341, + "penetrating": 0, + "max_penetration_mm": 0.189, + "drift_p95_mm": 0.03, + "clearance_min_mm": 5.221, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 0, + "max_penetration_mm": 0.189, + "drift_p95_mm": 0.03 + }, + "decision": "refit_shared", + "reason": "the male fit holds on the female body: 0.2 mm penetration over 0 vertices, 0.0 mm p95 clearance drift" + }, + { + "id": "gloves_light_utility", + "group": "Gloves", + "layer": "Under", + "slot": "under_hands", + "glb": "Under/gloves_light_utility.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "b115fa0e9e2a701fec68a483dfac0f3f1bae65e01d78b04908aace36ea31ed00", + "male": { + "path": "Under/gloves_light_utility.glb", + "bytes": 56624, + "sha256": "1d266af174b0dcae56cfafef9a11901b4cf233496474ef6e6877d9b2eef13218", + "moved_mean_mm": 0.181, + "moved_max_mm": 7.993, + "penetrating": 15, + "max_penetration_mm": 9.888, + "drift_p95_mm": 0.131, + "clearance_min_mm": 4.843, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 15, + "max_penetration_mm": 9.887, + "drift_p95_mm": 0.131 + }, + "female": { + "path": "Female/Under/gloves_light_utility.glb", + "bytes": 56628, + "sha256": "d8c13d15cb2ddad823f87b952c855b07151bc256fc2f36c2dd0025dff5737de1", + "moved_mean_mm": 0.155, + "moved_max_mm": 6.322, + "penetrating": 9, + "max_penetration_mm": 2.755, + "drift_p95_mm": 0.118, + "clearance_min_mm": 5.076, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 9.9 mm into the female body over 15 vertices with 0.1 mm p95 clearance drift" + }, + { + "id": "gloves_tipped_work", + "group": "Gloves", + "layer": "Under", + "slot": "under_hands", + "glb": "Under/gloves_tipped_work.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "c0c2c3b2f5bd574ddb824775dbe50c3ee9c4a31c5305e894c33ea0e0ce194487", + "male": { + "path": "Under/gloves_tipped_work.glb", + "bytes": 76788, + "sha256": "042a2c177295afce9d1ae322a54bfc2567846975013840adf69bd66e61e2a803", + "moved_mean_mm": 5.093, + "moved_max_mm": 17.204, + "penetrating": 119, + "max_penetration_mm": 13.476, + "drift_p95_mm": 10.07, + "clearance_min_mm": 3.595, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 112, + "max_penetration_mm": 13.475, + "drift_p95_mm": 10.773 + }, + "female": { + "path": "Female/Under/gloves_tipped_work.glb", + "bytes": 76784, + "sha256": "666cf7dbd00905e1143b3e49f368c55317fbb6b8fc087af70d1a167f0a3d891d", + "moved_mean_mm": 4.607, + "moved_max_mm": 11.727, + "penetrating": 99, + "max_penetration_mm": 10.734, + "drift_p95_mm": 12.689, + "clearance_min_mm": 4.401, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 13.5 mm into the female body over 112 vertices with 10.8 mm p95 clearance drift" + }, + { + "id": "gloves_plated_handwraps", + "group": "Gloves", + "layer": "Under", + "slot": "under_hands", + "glb": "Under/gloves_plated_handwraps.glb", + "policy": "soft", + "skinned": true, + "source_sha256": "172905566d7f05b6ab75e33f39d094eb3ebbfd81cd092e8106b1360e401735e4", + "male": { + "path": "Under/gloves_plated_handwraps.glb", + "bytes": 59280, + "sha256": "64cce264f147acfc00f79de986176a811898ce7c4e018b30ee0fe19751cd4fff", + "moved_mean_mm": 3.562, + "moved_max_mm": 17.533, + "penetrating": 81, + "max_penetration_mm": 13.767, + "drift_p95_mm": 11.453, + "clearance_min_mm": 3.496, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 79, + "max_penetration_mm": 13.766, + "drift_p95_mm": 11.453 + }, + "female": { + "path": "Female/Under/gloves_plated_handwraps.glb", + "bytes": 59276, + "sha256": "b99b16c3f6366fd0212396254ef7b7759e989b0cef8203ebadb1e53837deacae", + "moved_mean_mm": 2.829, + "moved_max_mm": 13.823, + "penetrating": 79, + "max_penetration_mm": 11.341, + "drift_p95_mm": 16.722, + "clearance_min_mm": 3.343, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 13.8 mm into the female body over 79 vertices with 11.5 mm p95 clearance drift" + }, + { + "id": "hair_banded_mohawk", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_banded_mohawk.glb", + "policy": "head", + "skinned": true, + "source_sha256": "77fa5bc24aa4088633401d8e87d0fda6441eb038838b89af20cb1cccc8dcf1d6", + "male": { + "path": "Under/hair_banded_mohawk.glb", + "bytes": 70284, + "sha256": "27cdd4fe31b15360a8e69ba60748fc69c9eb3d5159af09303b4530dcb5b8e22a", + "moved_mean_mm": 16.645, + "moved_max_mm": 23.103, + "penetrating": 633, + "max_penetration_mm": 25.919, + "drift_p95_mm": 22.834, + "clearance_min_mm": -19.606, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 812, + "max_penetration_mm": 23.656, + "drift_p95_mm": 19.685 + }, + "female": { + "path": "Female/Under/hair_banded_mohawk.glb", + "bytes": 70284, + "sha256": "3d1367502d47ed1b46d49d11bfd59a04cdf5cbaec146cc204f47b02e217783aa", + "moved_mean_mm": 19.871, + "moved_max_mm": 29.195, + "penetrating": 912, + "max_penetration_mm": 29.452, + "drift_p95_mm": 27.874, + "clearance_min_mm": -19.356, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 23.7 mm into the female body over 812 vertices with 19.7 mm p95 clearance drift" + }, + { + "id": "hair_spiked_topknot", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_spiked_topknot.glb", + "policy": "head", + "skinned": true, + "source_sha256": "8417ae03d48154486d3a59d12a4a9e3cb27362b06c4677fbe3764b5a5a30d140", + "male": { + "path": "Under/hair_spiked_topknot.glb", + "bytes": 80860, + "sha256": "77665c29ba83f0e31a712c690a4f2517bc279903e068a8e6989dac1d06074ce6", + "moved_mean_mm": 16.802, + "moved_max_mm": 22.14, + "penetrating": 541, + "max_penetration_mm": 23.872, + "drift_p95_mm": 15.82, + "clearance_min_mm": -29.656, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 697, + "max_penetration_mm": 22.316, + "drift_p95_mm": 16.675 + }, + "female": { + "path": "Female/Under/hair_spiked_topknot.glb", + "bytes": 80860, + "sha256": "68e6fb86bf1a26baa77f7ca2e7023d2c307c427c22e6d37285a1a00a21d42b5d", + "moved_mean_mm": 19.374, + "moved_max_mm": 27.798, + "penetrating": 929, + "max_penetration_mm": 26.777, + "drift_p95_mm": 21.664, + "clearance_min_mm": -29.652, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 22.3 mm into the female body over 697 vertices with 16.7 mm p95 clearance drift" + }, + { + "id": "hair_twin_pigtails", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_twin_pigtails.glb", + "policy": "head", + "skinned": true, + "source_sha256": "a63d3bc4e480042d246e686889b6b36d1569f59541018a67d956ace17f8aacfd", + "male": { + "path": "Under/hair_twin_pigtails.glb", + "bytes": 136452, + "sha256": "e86e8877ac0af6f1e17a10426c4c6a0a83ecfaa09bafce4d6b8bbe2abd7e1266", + "moved_mean_mm": 14.342, + "moved_max_mm": 22.831, + "penetrating": 727, + "max_penetration_mm": 34.817, + "drift_p95_mm": 21.235, + "clearance_min_mm": -27.924, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 362, + "max_penetration_mm": 22.878, + "drift_p95_mm": 24.155 + }, + "female": { + "path": "Female/Under/hair_twin_pigtails.glb", + "bytes": 136448, + "sha256": "c8c7a85139a13d60520fa5fc188b515c41211d2bdd2029acbfadab0fbeb5b05b", + "moved_mean_mm": 17.331, + "moved_max_mm": 29.344, + "penetrating": 1169, + "max_penetration_mm": 29.396, + "drift_p95_mm": 20.88, + "clearance_min_mm": -23.413, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 22.9 mm into the female body over 362 vertices with 24.2 mm p95 clearance drift" + }, + { + "id": "hair_swept_spikes", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_swept_spikes.glb", + "policy": "head", + "skinned": true, + "source_sha256": "d8fc059ae40380c7fef018e1802bc516dab26de27ba35b94ae02bae59838ed58", + "male": { + "path": "Under/hair_swept_spikes.glb", + "bytes": 87292, + "sha256": "18ffb36ec2782a328b0bc15bc364c93d522b7a18fd2f9cb193e107da72376b32", + "moved_mean_mm": 15.597, + "moved_max_mm": 22.948, + "penetrating": 712, + "max_penetration_mm": 38.415, + "drift_p95_mm": 25.46, + "clearance_min_mm": -24.335, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 726, + "max_penetration_mm": 25.349, + "drift_p95_mm": 19.261 + }, + "female": { + "path": "Female/Under/hair_swept_spikes.glb", + "bytes": 87292, + "sha256": "6c914a76b9cfc2fbdfc60e6b8a971bec68cd4bcd2e3e2486f209e4dd52af4996", + "moved_mean_mm": 18.795, + "moved_max_mm": 28.966, + "penetrating": 986, + "max_penetration_mm": 34.477, + "drift_p95_mm": 24.339, + "clearance_min_mm": -30.704, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 25.3 mm into the female body over 726 vertices with 19.3 mm p95 clearance drift" + }, + { + "id": "hair_short_ponytail", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_short_ponytail.glb", + "policy": "head", + "skinned": true, + "source_sha256": "42c96c4c7a4499c14d33454f217e6362dfe11dc1d5eb0d5a81da8c81decc3b53", + "male": { + "path": "Under/hair_short_ponytail.glb", + "bytes": 116144, + "sha256": "b2ee66523c98b592b2fe260aadb4c1acbe4db233d55f3a17d9c2912c4413f2ed", + "moved_mean_mm": 16.262, + "moved_max_mm": 24.944, + "penetrating": 720, + "max_penetration_mm": 34.66, + "drift_p95_mm": 21.93, + "clearance_min_mm": -25.223, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 915, + "max_penetration_mm": 24.609, + "drift_p95_mm": 17.471 + }, + "female": { + "path": "Female/Under/hair_short_ponytail.glb", + "bytes": 116144, + "sha256": "ff7400251e99018b37a4035ee942c1a04176fb8047bfcb432118be4034be649f", + "moved_mean_mm": 18.743, + "moved_max_mm": 33.649, + "penetrating": 1211, + "max_penetration_mm": 30.762, + "drift_p95_mm": 19.854, + "clearance_min_mm": -24.322, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 24.6 mm into the female body over 915 vertices with 17.5 mm p95 clearance drift" + }, + { + "id": "hair_spiked_mohawk", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_spiked_mohawk.glb", + "policy": "head", + "skinned": true, + "source_sha256": "5a9ab2659875bf090774876b9d33596323d3bae2fd4cf77890260485e830dbfe", + "male": { + "path": "Under/hair_spiked_mohawk.glb", + "bytes": 97172, + "sha256": "2e399dc712bd179d5de5de8ae95c8e25f4bf7e6ffe98d3124bac20f86799bb93", + "moved_mean_mm": 16.855, + "moved_max_mm": 28.775, + "penetrating": 223, + "max_penetration_mm": 10.913, + "drift_p95_mm": 14.055, + "clearance_min_mm": -28.252, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 295, + "max_penetration_mm": 22.015, + "drift_p95_mm": 17.854 + }, + "female": { + "path": "Female/Under/hair_spiked_mohawk.glb", + "bytes": 97172, + "sha256": "c145f4e629ab625499e360b342a29239bf79a7c3e64a6d0e0ab723f8c0b2b075", + "moved_mean_mm": 19.687, + "moved_max_mm": 39.051, + "penetrating": 479, + "max_penetration_mm": 28.712, + "drift_p95_mm": 17.995, + "clearance_min_mm": -27.97, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 22.0 mm into the female body over 295 vertices with 17.9 mm p95 clearance drift" + }, + { + "id": "hair_flared_wings", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_flared_wings.glb", + "policy": "head", + "skinned": true, + "source_sha256": "6562615cf7b1d211484d27e5d84a627bab4915b89ad73caa4cb86c35763afb07", + "male": { + "path": "Under/hair_flared_wings.glb", + "bytes": 56140, + "sha256": "6fd705c4faec1125f39aca1352780bb0c5102187db811f02d387ec7987ce5f52", + "moved_mean_mm": 13.832, + "moved_max_mm": 22.527, + "penetrating": 135, + "max_penetration_mm": 13.364, + "drift_p95_mm": 14.442, + "clearance_min_mm": -41.262, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 24, + "max_penetration_mm": 5.154, + "drift_p95_mm": 13.378 + }, + "female": { + "path": "Female/Under/hair_flared_wings.glb", + "bytes": 56140, + "sha256": "ec897a91bcc7516cd8c1b1608d8fda725d8a6d30cc3e6355dcc9d29b53c860fd", + "moved_mean_mm": 15.435, + "moved_max_mm": 29.01, + "penetrating": 259, + "max_penetration_mm": 9.675, + "drift_p95_mm": 10.777, + "clearance_min_mm": -44.843, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 5.2 mm into the female body over 24 vertices with 13.4 mm p95 clearance drift" + }, + { + "id": "hair_low_tufts", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_low_tufts.glb", + "policy": "head", + "skinned": true, + "source_sha256": "b2f2ec9c5b9fc2916d26b0ab494e85ad7e68d4199140d01089536e088176c17a", + "male": { + "path": "Under/hair_low_tufts.glb", + "bytes": 67944, + "sha256": "4d0bb206001e0556edf3a06e2260621678cce7e2fccf3c68712dfceb6822a381", + "moved_mean_mm": 13.022, + "moved_max_mm": 19.018, + "penetrating": 160, + "max_penetration_mm": 22.38, + "drift_p95_mm": 15.395, + "clearance_min_mm": -32.153, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 90, + "max_penetration_mm": 9.617, + "drift_p95_mm": 15.398 + }, + "female": { + "path": "Female/Under/hair_low_tufts.glb", + "bytes": 67944, + "sha256": "f3190e8946803d88faa3decb518b8384a1b0049fac526ccee4aec1136460ac56", + "moved_mean_mm": 14.627, + "moved_max_mm": 23.419, + "penetrating": 327, + "max_penetration_mm": 16.104, + "drift_p95_mm": 12.207, + "clearance_min_mm": -36.555, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 9.6 mm into the female body over 90 vertices with 15.4 mm p95 clearance drift" + }, + { + "id": "hair_swept_bob", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_swept_bob.glb", + "policy": "head", + "skinned": true, + "source_sha256": "718881953f946bb30fef671b2d5a6930cd661b6f4576842637c3e7ac855bdca7", + "male": { + "path": "Under/hair_swept_bob.glb", + "bytes": 91032, + "sha256": "f0dbe1af613a6a79309e8171bd8bc751acba3217a4fe5bffe31f247c65880e0a", + "moved_mean_mm": 16.227, + "moved_max_mm": 22.496, + "penetrating": 906, + "max_penetration_mm": 39.755, + "drift_p95_mm": 38.069, + "clearance_min_mm": -34.155, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 862, + "max_penetration_mm": 26.259, + "drift_p95_mm": 21.136 + }, + "female": { + "path": "Female/Under/hair_swept_bob.glb", + "bytes": 91032, + "sha256": "6448df045f9fb1af9886ab1d3d15d7d636ad3338def198bb6a812e57ce12f847", + "moved_mean_mm": 23.067, + "moved_max_mm": 39.654, + "penetrating": 1060, + "max_penetration_mm": 28.213, + "drift_p95_mm": 21.762, + "clearance_min_mm": -25.035, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 26.3 mm into the female body over 862 vertices with 21.1 mm p95 clearance drift" + }, + { + "id": "hair_braided_crown", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_braided_crown.glb", + "policy": "head", + "skinned": true, + "source_sha256": "e3fd895b8dbcb2597e993597f05a57d0157d201a733748f9975c3b78e4674be4", + "male": { + "path": "Under/hair_braided_crown.glb", + "bytes": 229464, + "sha256": "c5cff7249e4862ce276fb4d68f639de36bf815f65b30741f58cfa7a4c6059445", + "moved_mean_mm": 14.852, + "moved_max_mm": 22.463, + "penetrating": 1781, + "max_penetration_mm": 42.457, + "drift_p95_mm": 33.112, + "clearance_min_mm": -18.247, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 1311, + "max_penetration_mm": 25.201, + "drift_p95_mm": 26.314 + }, + "female": { + "path": "Female/Under/hair_braided_crown.glb", + "bytes": 229464, + "sha256": "a391a95ae7963c35b85da8bf80641815f623b8f3cbd1c71bd9ae44ea3d76f7d8", + "moved_mean_mm": 24.735, + "moved_max_mm": 38.508, + "penetrating": 1650, + "max_penetration_mm": 32.168, + "drift_p95_mm": 31.65, + "clearance_min_mm": -21.266, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 25.2 mm into the female body over 1311 vertices with 26.3 mm p95 clearance drift" + }, + { + "id": "hair_ridge_mohawk", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_ridge_mohawk.glb", + "policy": "head", + "skinned": true, + "source_sha256": "5aa872cdbdc556f3c7578966b7cbb0366de9f5d62ff4f104570d254f8a8f4fe4", + "male": { + "path": "Under/hair_ridge_mohawk.glb", + "bytes": 28444, + "sha256": "c125ceb5895d5f4d530aaea7efc8b5149e99bfdc228f4c70729531a7ebd35cee", + "moved_mean_mm": 18.4, + "moved_max_mm": 22.093, + "penetrating": 180, + "max_penetration_mm": 34.788, + "drift_p95_mm": 32.147, + "clearance_min_mm": -18.561, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 197, + "max_penetration_mm": 23.14, + "drift_p95_mm": 20.59 + }, + "female": { + "path": "Female/Under/hair_ridge_mohawk.glb", + "bytes": 28444, + "sha256": "7713355fbaa50afbf2f9950f670b265347fd557e506e971f9e6737e88b585254", + "moved_mean_mm": 21.953, + "moved_max_mm": 27.204, + "penetrating": 256, + "max_penetration_mm": 30.668, + "drift_p95_mm": 28.284, + "clearance_min_mm": -18.896, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 23.1 mm into the female body over 197 vertices with 20.6 mm p95 clearance drift" + }, + { + "id": "hair_side_ponytail", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_side_ponytail.glb", + "policy": "head", + "skinned": true, + "source_sha256": "9a50da68be8f7bf54e0456cefbea93359cb0f08bce876927ec5bf0b92fbad24d", + "male": { + "path": "Under/hair_side_ponytail.glb", + "bytes": 152532, + "sha256": "fb67f9ff6a87730b86952b24505eda736fe727af8dab5b2e760c2749abd6554c", + "moved_mean_mm": 18.456, + "moved_max_mm": 24.353, + "penetrating": 801, + "max_penetration_mm": 36.849, + "drift_p95_mm": 27.917, + "clearance_min_mm": -19.18, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 1104, + "max_penetration_mm": 21.044, + "drift_p95_mm": 17.701 + }, + "female": { + "path": "Female/Under/hair_side_ponytail.glb", + "bytes": 152536, + "sha256": "a681147bb58c4a8f7aac5c8c9fd8f6bf119025fea3d7b53f3725d71bb38eee1d", + "moved_mean_mm": 24.818, + "moved_max_mm": 34.693, + "penetrating": 1787, + "max_penetration_mm": 25.596, + "drift_p95_mm": 20.254, + "clearance_min_mm": -19.756, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 21.0 mm into the female body over 1104 vertices with 17.7 mm p95 clearance drift" + }, + { + "id": "hair_split_mohawk", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_split_mohawk.glb", + "policy": "head", + "skinned": true, + "source_sha256": "7c947fa126b1292deeb918f6120a34e5413b045f9c7241a783cea9bae8ba149f", + "male": { + "path": "Under/hair_split_mohawk.glb", + "bytes": 105764, + "sha256": "a9c25fa690062f4997de172ba48ac0f7343335d4aca9cfd78ee5e83251fbb8df", + "moved_mean_mm": 16.987, + "moved_max_mm": 28.917, + "penetrating": 791, + "max_penetration_mm": 39.71, + "drift_p95_mm": 30.55, + "clearance_min_mm": -10.969, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 852, + "max_penetration_mm": 27.463, + "drift_p95_mm": 22.459 + }, + "female": { + "path": "Female/Under/hair_split_mohawk.glb", + "bytes": 105764, + "sha256": "ac9a62123470bb55c67f32a6bede4c880385e46d5dc31d97af1b1961f7575b53", + "moved_mean_mm": 20.741, + "moved_max_mm": 38.834, + "penetrating": 1151, + "max_penetration_mm": 38.138, + "drift_p95_mm": 31.649, + "clearance_min_mm": -12.113, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 27.5 mm into the female body over 852 vertices with 22.5 mm p95 clearance drift" + }, + { + "id": "hair_fauxhawk", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_fauxhawk.glb", + "policy": "head", + "skinned": true, + "source_sha256": "1cf9bfa3d96fb1e0cc0b4a6761963039b8a143ecab3b024d61de3adc2709ee16", + "male": { + "path": "Under/hair_fauxhawk.glb", + "bytes": 95248, + "sha256": "e031817cc6af60e25a4905591c402be73702a928e2feae14d6c281138208481e", + "moved_mean_mm": 18.305, + "moved_max_mm": 25.023, + "penetrating": 794, + "max_penetration_mm": 22.726, + "drift_p95_mm": 22.039, + "clearance_min_mm": -21.17, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 955, + "max_penetration_mm": 23.691, + "drift_p95_mm": 18.729 + }, + "female": { + "path": "Female/Under/hair_fauxhawk.glb", + "bytes": 95244, + "sha256": "a8dad284948ef7729a9972d719960f1c1c5113dbe3c66ebded604f624f345072", + "moved_mean_mm": 21.552, + "moved_max_mm": 31.869, + "penetrating": 1274, + "max_penetration_mm": 29.355, + "drift_p95_mm": 26.089, + "clearance_min_mm": -21.663, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 23.7 mm into the female body over 955 vertices with 18.7 mm p95 clearance drift" + }, + { + "id": "hair_slicked_crop", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_slicked_crop.glb", + "policy": "head", + "skinned": true, + "source_sha256": "e102bedd662f6e8934ad28d96e57e5a0dacf22dfdc2adb94a7b44e5fc3e47ec3", + "male": { + "path": "Under/hair_slicked_crop.glb", + "bytes": 59760, + "sha256": "74f11589238a01d98426fb978cd9c94dc703cff3aadfeb77f40aba483f4f3330", + "moved_mean_mm": 13.899, + "moved_max_mm": 18.382, + "penetrating": 372, + "max_penetration_mm": 38.882, + "drift_p95_mm": 26.667, + "clearance_min_mm": -13.732, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 356, + "max_penetration_mm": 26.087, + "drift_p95_mm": 17.334 + }, + "female": { + "path": "Female/Under/hair_slicked_crop.glb", + "bytes": 59760, + "sha256": "ce0d62c1e64befdf0abca520bd88c0a1b474b7694280b0bdc3b8f9bbee107ab6", + "moved_mean_mm": 15.513, + "moved_max_mm": 20.467, + "penetrating": 494, + "max_penetration_mm": 35.617, + "drift_p95_mm": 24.524, + "clearance_min_mm": -20.104, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 26.1 mm into the female body over 356 vertices with 17.3 mm p95 clearance drift" + }, + { + "id": "hair_side_sweep", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_side_sweep.glb", + "policy": "head", + "skinned": true, + "source_sha256": "9594aed5534cea94f86d58740a166c1e2f54f3fce5047657c572edcf460cb461", + "male": { + "path": "Under/hair_side_sweep.glb", + "bytes": 74400, + "sha256": "70ec019b681390bb120761199d6f68d89227f5983b6df86ef3581f32f79434b2", + "moved_mean_mm": 13.933, + "moved_max_mm": 20.36, + "penetrating": 609, + "max_penetration_mm": 32.196, + "drift_p95_mm": 24.531, + "clearance_min_mm": -29.451, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 502, + "max_penetration_mm": 21.688, + "drift_p95_mm": 17.305 + }, + "female": { + "path": "Female/Under/hair_side_sweep.glb", + "bytes": 74400, + "sha256": "1ca0ee05f3b62fc19900897266036a93418fbd23cddac825242526f5570bcb1f", + "moved_mean_mm": 17.415, + "moved_max_mm": 26.15, + "penetrating": 765, + "max_penetration_mm": 28.501, + "drift_p95_mm": 20.36, + "clearance_min_mm": -26.682, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 21.7 mm into the female body over 502 vertices with 17.3 mm p95 clearance drift" + }, + { + "id": "hair_flared_shag", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_flared_shag.glb", + "policy": "head", + "skinned": true, + "source_sha256": "a537fca5d4bb898a79a065d2d3a38bd91538f9e459fe06f8c7ed03c244a472ae", + "male": { + "path": "Under/hair_flared_shag.glb", + "bytes": 91004, + "sha256": "f0ead5fa46a311342a529388bb2c76e7cc60fd9a7b4967e655bd3a39fbbf0d2d", + "moved_mean_mm": 15.289, + "moved_max_mm": 19.344, + "penetrating": 419, + "max_penetration_mm": 30.331, + "drift_p95_mm": 15.633, + "clearance_min_mm": -24.688, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 504, + "max_penetration_mm": 20.127, + "drift_p95_mm": 17.355 + }, + "female": { + "path": "Female/Under/hair_flared_shag.glb", + "bytes": 91004, + "sha256": "e62cddfcdb7b07a6fbd9e88e552640da68e29b503556c0f44b97129546d54e17", + "moved_mean_mm": 17.945, + "moved_max_mm": 24.249, + "penetrating": 805, + "max_penetration_mm": 27.785, + "drift_p95_mm": 16.549, + "clearance_min_mm": -30.464, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 20.1 mm into the female body over 504 vertices with 17.4 mm p95 clearance drift" + }, + { + "id": "hair_cropped_buzz", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_cropped_buzz.glb", + "policy": "head", + "skinned": true, + "source_sha256": "195315a136eb21f6186dfd4ede2c715b8768aaa4ec8832c7a85e42ebe765f212", + "male": { + "path": "Under/hair_cropped_buzz.glb", + "bytes": 87740, + "sha256": "23743304b951906298f41082d1ac8dce5405fc9c1ad76c51e57ce8896a6fee73", + "moved_mean_mm": 15.919, + "moved_max_mm": 21.078, + "penetrating": 394, + "max_penetration_mm": 33.668, + "drift_p95_mm": 22.553, + "clearance_min_mm": -17.679, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 591, + "max_penetration_mm": 23.604, + "drift_p95_mm": 18.773 + }, + "female": { + "path": "Female/Under/hair_cropped_buzz.glb", + "bytes": 87740, + "sha256": "4eab51b641b8ed98cef15755622fb8cc6ebca232dc3449212d72a195f6fe39c6", + "moved_mean_mm": 17.727, + "moved_max_mm": 24.98, + "penetrating": 788, + "max_penetration_mm": 31.402, + "drift_p95_mm": 23.339, + "clearance_min_mm": -27.18, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 23.6 mm into the female body over 591 vertices with 18.8 mm p95 clearance drift" + }, + { + "id": "hair_messy_crop", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_messy_crop.glb", + "policy": "head", + "skinned": true, + "source_sha256": "bbbb56a57609b38eeb7ef6f0cc49e1869d32e077ec92ddfbfeb02079054526fd", + "male": { + "path": "Under/hair_messy_crop.glb", + "bytes": 77440, + "sha256": "6b0fb5805244ab504dae872de50f22632a8ff2db3fb494019236dec6e06e5669", + "moved_mean_mm": 14.619, + "moved_max_mm": 19.497, + "penetrating": 395, + "max_penetration_mm": 33.024, + "drift_p95_mm": 15.638, + "clearance_min_mm": -37.409, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 455, + "max_penetration_mm": 19.618, + "drift_p95_mm": 14.813 + }, + "female": { + "path": "Female/Under/hair_messy_crop.glb", + "bytes": 77444, + "sha256": "b26ec0cd1345593933a2ffed18cc3d64320fd4638a1c7409d32c0d1e7e0fe1ca", + "moved_mean_mm": 16.014, + "moved_max_mm": 22.329, + "penetrating": 651, + "max_penetration_mm": 26.461, + "drift_p95_mm": 15.722, + "clearance_min_mm": -34.701, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 19.6 mm into the female body over 455 vertices with 14.8 mm p95 clearance drift" + }, + { + "id": "hair_textured_crop", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_textured_crop.glb", + "policy": "head", + "skinned": true, + "source_sha256": "df2c49a8d52d345fbd5e02d63343e4d9901087e75c4837049b5e9b14069ac880", + "male": { + "path": "Under/hair_textured_crop.glb", + "bytes": 89336, + "sha256": "c1171d17bdef052ae4163f04e1104aaa03b787dd5a95061a9692963e796f0c04", + "moved_mean_mm": 14.466, + "moved_max_mm": 21.905, + "penetrating": 492, + "max_penetration_mm": 33.069, + "drift_p95_mm": 20.802, + "clearance_min_mm": -35.501, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 519, + "max_penetration_mm": 22.417, + "drift_p95_mm": 19.056 + }, + "female": { + "path": "Female/Under/hair_textured_crop.glb", + "bytes": 89336, + "sha256": "84a5b79962fecf784b99b1288145d728cd0b3a9d61af6c2f6463d2c3ad209faa", + "moved_mean_mm": 16.405, + "moved_max_mm": 26.489, + "penetrating": 764, + "max_penetration_mm": 29.374, + "drift_p95_mm": 23.515, + "clearance_min_mm": -32.959, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 22.4 mm into the female body over 519 vertices with 19.1 mm p95 clearance drift" + }, + { + "id": "hair_shoulder_shag", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_shoulder_shag.glb", + "policy": "head", + "skinned": true, + "source_sha256": "7865ea57918ba3bdcbf420bad2cd1c4b33a0a85ca774e1dd44b197e4b2f4cc55", + "male": { + "path": "Under/hair_shoulder_shag.glb", + "bytes": 99936, + "sha256": "1b2dd243ea74319a89e2f7ab5c9164a817eba82406ba63d4de1001bd377ff768", + "moved_mean_mm": 13.393, + "moved_max_mm": 18.903, + "penetrating": 574, + "max_penetration_mm": 38.211, + "drift_p95_mm": 30.752, + "clearance_min_mm": -24.69, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 382, + "max_penetration_mm": 24.02, + "drift_p95_mm": 18.741 + }, + "female": { + "path": "Female/Under/hair_shoulder_shag.glb", + "bytes": 99940, + "sha256": "3c23cab55b9d38cdc741666a58880443a707fbf123df7ae5dae4828d32822975", + "moved_mean_mm": 15.751, + "moved_max_mm": 24.569, + "penetrating": 715, + "max_penetration_mm": 31.021, + "drift_p95_mm": 25.812, + "clearance_min_mm": -22.813, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 24.0 mm into the female body over 382 vertices with 18.7 mm p95 clearance drift" + }, + { + "id": "hair_messy_side_part", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_messy_side_part.glb", + "policy": "head", + "skinned": true, + "source_sha256": "9a01587de17bcf92a315c4f5a4b2770ad1eed8f57fc0ff5a4502a12edf7f15cb", + "male": { + "path": "Under/hair_messy_side_part.glb", + "bytes": 103500, + "sha256": "5851f781fdd308a35db26f734eaf76e858c5ef705123101e709a90717bff3b06", + "moved_mean_mm": 14.617, + "moved_max_mm": 20.401, + "penetrating": 513, + "max_penetration_mm": 38.874, + "drift_p95_mm": 35.112, + "clearance_min_mm": -33.974, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 596, + "max_penetration_mm": 23.194, + "drift_p95_mm": 21.869 + }, + "female": { + "path": "Female/Under/hair_messy_side_part.glb", + "bytes": 103500, + "sha256": "250408e83f0721cdcef46d7965977b6aeac7fed2835c3d34bfa0926d7fa6741d", + "moved_mean_mm": 16.262, + "moved_max_mm": 24.528, + "penetrating": 925, + "max_penetration_mm": 31.934, + "drift_p95_mm": 29.771, + "clearance_min_mm": -33.008, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 23.2 mm into the female body over 596 vertices with 21.9 mm p95 clearance drift" + }, + { + "id": "hair_flat_top", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_flat_top.glb", + "policy": "head", + "skinned": true, + "source_sha256": "4c635041305009ebdf760749c0e3c89617642c937c61544133466057fca68b50", + "male": { + "path": "Under/hair_flat_top.glb", + "bytes": 90980, + "sha256": "ff7599cd17bcb2cb903ce3703b37ffaa62f1a8099cb052d267dff481904d1ed5", + "moved_mean_mm": 13.563, + "moved_max_mm": 21.62, + "penetrating": 496, + "max_penetration_mm": 33.821, + "drift_p95_mm": 21.748, + "clearance_min_mm": -41.86, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 431, + "max_penetration_mm": 21.668, + "drift_p95_mm": 18.479 + }, + "female": { + "path": "Female/Under/hair_flat_top.glb", + "bytes": 90980, + "sha256": "d4c8434f7d48344ed4dc27bfe0b11734e545ab4b670ff1ad54246ae021d72bd9", + "moved_mean_mm": 15.122, + "moved_max_mm": 26.862, + "penetrating": 645, + "max_penetration_mm": 29.278, + "drift_p95_mm": 21.891, + "clearance_min_mm": -45.603, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 21.7 mm into the female body over 431 vertices with 18.5 mm p95 clearance drift" + }, + { + "id": "hair_top_patch", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "glb": "Under/hair_top_patch.glb", + "policy": "head", + "skinned": true, + "source_sha256": "3ad4ddf9a297c58559d48ea020278813ff8658a416e8601f3c692c425ea2c523", + "male": { + "path": "Under/hair_top_patch.glb", + "bytes": 47576, + "sha256": "1c92bc6ab7eeb0315368b21a9865c9cf77d9f42c44b08288d54a0795fa8d95dc", + "moved_mean_mm": 15.014, + "moved_max_mm": 18.202, + "penetrating": 297, + "max_penetration_mm": 29.805, + "drift_p95_mm": 20.923, + "clearance_min_mm": -19.034, + "pushed": 0, + "max_push_mm": 0.0 + }, + "shared_on_female": { + "penetrating": 332, + "max_penetration_mm": 20.285, + "drift_p95_mm": 15.248 + }, + "female": { + "path": "Female/Under/hair_top_patch.glb", + "bytes": 47580, + "sha256": "a0127d217e942c86ec597e0012a4430099a61ca5ea48741f4e58961cd7ef0aa1", + "moved_mean_mm": 16.325, + "moved_max_mm": 19.952, + "penetrating": 400, + "max_penetration_mm": 27.894, + "drift_p95_mm": 22.211, + "clearance_min_mm": -19.722, + "pushed": 0, + "max_push_mm": 0.0 + }, + "decision": "refit_both", + "reason": "the male fit reads 20.3 mm into the female body over 332 vertices with 15.2 mm p95 clearance drift" + } + ] +} \ No newline at end of file diff --git a/content-pipeline/labs/humanoid-runtime-refit/reports/body_shading.json b/content-pipeline/labs/humanoid-runtime-refit/reports/body_shading.json new file mode 100644 index 00000000..50628d6c --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/reports/body_shading.json @@ -0,0 +1,26 @@ +{ + "male": { + "npy": "content-pipeline/labs/humanoid-runtime-refit/build/male_vertex_ao.npy", + "sha256": "eef073140b4e73146ab950430a35baec3b870ea6003e5c571610bedd1092d994", + "vertices": 13277, + "welded": 3064, + "rays": 64, + "distance_m": 0.07, + "floor": 0.87, + "gamma": 0.9, + "min": 0.87, + "mean": 0.9375081542561331 + }, + "female": { + "npy": "content-pipeline/labs/humanoid-runtime-refit/build/female_vertex_ao.npy", + "sha256": "6788aa5e66f04d9e5060898844a1566fc038c9548b02ace384828382689fc719", + "vertices": 13354, + "welded": 3080, + "rays": 64, + "distance_m": 0.07, + "floor": 0.87, + "gamma": 0.9, + "min": 0.87, + "mean": 0.9381034717047285 + } +} \ No newline at end of file diff --git a/content-pipeline/labs/humanoid-runtime-refit/reports/body_zone_coverage.json b/content-pipeline/labs/humanoid-runtime-refit/reports/body_zone_coverage.json new file mode 100644 index 00000000..2c3746d7 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/reports/body_zone_coverage.json @@ -0,0 +1,20899 @@ +{ + "generator": "successor content-pipeline/labs/humanoid-runtime-refit/body_zone_coverage.py", + "policy": { + "near_limit_m": 0.03, + "reach_out_m": 0.03, + "reach_in_m": 0.015, + "cone_rays": 9, + "cone_half_angle_deg": 35.0, + "cone_hit_fraction": 0.7778, + "samples_per_triangle": 7, + "cover_fraction": 0.99, + "exposed_limit_cm2": 12.0 + }, + "poses": [ + { + "clip": "idle", + "phase": 0.0 + }, + { + "clip": "walk_f", + "phase": 0.25 + }, + { + "clip": "walk_f", + "phase": 0.75 + }, + { + "clip": "run_f", + "phase": 0.2 + }, + { + "clip": "run_f", + "phase": 0.7 + }, + { + "clip": "crouch_idle", + "phase": 0.5 + }, + { + "clip": "crouch_walk", + "phase": 0.3 + }, + { + "clip": "kneel_loop", + "phase": 0.5 + }, + { + "clip": "meditate_loop", + "phase": 0.5 + }, + { + "clip": "swing_h1", + "phase": 0.4 + }, + { + "clip": "swing_thrust", + "phase": 0.5 + }, + { + "clip": "rifle_aim", + "phase": 0.5 + }, + { + "clip": "melee_ready", + "phase": 0.5 + }, + { + "clip": "melee_turn_180", + "phase": 0.5 + }, + { + "clip": "death_f", + "phase": 0.6 + } + ], + "bodies": { + "male": { + "path": "client-3d/public/assets/pawn-pack/pawn_male.glb", + "sha256": "a865e80f3fe740c135633c2c03b94afcfb0d10def3756cb7ca43fec5e51e7f9c" + }, + "female": { + "path": "client-3d/public/assets/pawn-pack/pawn_female.glb", + "sha256": "9eb7242ce49fcd7d7afa53a898c35e5b7b5fd0d5d99d7109bfc82e33e4f095e9" + } + }, + "items": { + "armor_harness": { + "glb": "Armor/Harness.glb", + "group": "Torso", + "layer": "Armor", + "slot": "armor_harness", + "sha256": "866b80b1b589e22fc12129079756801fe641da04ac68365b331953bc56b7a0fe", + "glb_female": "Female/Armor/Harness.glb", + "glb_female_sha256": "9c4f3c702936c7e0871b6f415214e2cc09d574456792fef100edfc81e9d23540", + "attached": true, + "garment_triangles": 334, + "female_garment_triangles": 334, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.41317, + "exposed_cm2": 2254.493, + "area_cm2": 3841.793, + "worst_pose": "death_f@0.6" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.01808, + "exposed_cm2": 1173.449, + "area_cm2": 1151.607, + "worst_pose": "rifle_aim@0.5" + }, + "right_upper_arm": { + "covered_fraction": 0.01918, + "exposed_cm2": 1150.235, + "area_cm2": 1281.261, + "worst_pose": "walk_f@0.75" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.37878, + "exposed_cm2": 2037.665, + "area_cm2": 3280.077, + "worst_pose": "death_f@0.6" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.01489, + "exposed_cm2": 865.113, + "area_cm2": 843.36, + "worst_pose": "meditate_loop@0.5" + }, + "right_upper_arm": { + "covered_fraction": 0.00466, + "exposed_cm2": 874.085, + "area_cm2": 982.65, + "worst_pose": "meditate_loop@0.5" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "armor_nape_reinforcement": { + "glb": "Armor/Nape_Reinforcement.glb", + "group": "Torso", + "layer": "Armor", + "slot": "armor_nape_reinforcement", + "sha256": "7bce6fb3da0931c6ae2d94cf68642183ffd8d894162c26afd7bcdb6013a8daa6", + "glb_female": "Female/Armor/Nape_Reinforcement.glb", + "glb_female_sha256": "d5856ea6190938b79efb1b359dbdd4de5aad5080af22dc3471b7549f1fef2d0d", + "attached": true, + "garment_triangles": 528, + "female_garment_triangles": 528, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.01743, + "exposed_cm2": 4030.892, + "area_cm2": 3841.793, + "worst_pose": "melee_ready@0.5" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.01067, + "exposed_cm2": 3329.849, + "area_cm2": 3280.077, + "worst_pose": "swing_h1@0.4" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "armor_reinforcement": { + "glb": "Armor/Reinforcement.glb", + "group": "Torso", + "layer": "Armor", + "slot": "armor_reinforcement", + "sha256": "db35a86cbfd4a7d36ee8b2bc5bbb92eb9d8887330113948a2da945cac24e6b6a", + "glb_female": "Female/Armor/Reinforcement.glb", + "glb_female_sha256": "dc0edb59eee39d904d571d83c78c8b89bf8a604916ec58f7bf64b40c58331a80", + "attached": true, + "garment_triangles": 68, + "female_garment_triangles": 68, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.05179, + "exposed_cm2": 3916.859, + "area_cm2": 3841.793, + "worst_pose": "swing_thrust@0.5" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1190.691, + "area_cm2": 1183.674, + "worst_pose": "crouch_idle@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.07293, + "exposed_cm2": 3113.798, + "area_cm2": 3280.077, + "worst_pose": "melee_turn_180@0.5" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1066.575, + "area_cm2": 1054.218, + "worst_pose": "crouch_idle@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "armor_gorget": { + "glb": "Armor/Gorget.glb", + "group": "Torso", + "layer": "Armor", + "slot": "armor_gorget", + "sha256": "bbd326dd734126a52d2b8f8a839fb0d00c66eba07788bbefa19ea27af9cd40c1", + "glb_female": "Female/Armor/Gorget.glb", + "glb_female_sha256": "abfb88344d8b9ed0de7575aca9101f1445404e4aec72103c328643bc383590f3", + "attached": true, + "garment_triangles": 184, + "female_garment_triangles": 184, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1183.149, + "area_cm2": 1183.674, + "worst_pose": "walk_f@0.25" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1206.883, + "area_cm2": 1151.607, + "worst_pose": "idle@0" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1206.797, + "area_cm2": 1281.261, + "worst_pose": "idle@0" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1054.205, + "area_cm2": 1054.218, + "worst_pose": "idle@0" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "armor_bicep_l": { + "glb": "Armor/Bicep_L.glb", + "group": "Bicep_L", + "layer": "Armor", + "slot": "armor_bicep_l", + "sha256": "bd02ae1ec190042bb1015a367784d75dbd469218ed39f8d663a26783db7924d9", + "glb_female": "Female/Armor/Bicep_L.glb", + "glb_female_sha256": "add5e1b26672d5371bf19e6aa34b669828d3a23125cc24318c26e5bfd44bbcf2", + "attached": true, + "garment_triangles": 88, + "female_garment_triangles": 88, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.00171, + "exposed_cm2": 4062.453, + "area_cm2": 3841.793, + "worst_pose": "swing_h1@0.4" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.36289, + "exposed_cm2": 733.698, + "area_cm2": 1151.607, + "worst_pose": "death_f@0.6" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.00113, + "exposed_cm2": 3361.974, + "area_cm2": 3280.077, + "worst_pose": "swing_h1@0.4" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.34383, + "exposed_cm2": 597.62, + "area_cm2": 843.36, + "worst_pose": "run_f@0.7" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "armor_bicep_r": { + "glb": "Armor/Bicep_R.glb", + "group": "Bicep_R", + "layer": "Armor", + "slot": "armor_bicep_r", + "sha256": "0dc1cf487873f6e94e3db7aee09b194bf088de8f36a3ee766864726bb7283ba1", + "glb_female": "Female/Armor/Bicep_R.glb", + "glb_female_sha256": "eb7b2e1ec9fe715a70f0b0d3f3e5fd9301f7a36fb1083cfe428431bc6c6ff4fb", + "attached": true, + "garment_triangles": 88, + "female_garment_triangles": 88, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.00316, + "exposed_cm2": 4089.413, + "area_cm2": 3841.793, + "worst_pose": "melee_ready@0.5" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.4205, + "exposed_cm2": 737.723, + "area_cm2": 1281.261, + "worst_pose": "melee_ready@0.5" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.00302, + "exposed_cm2": 3399.45, + "area_cm2": 3280.077, + "worst_pose": "swing_thrust@0.5" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.34719, + "exposed_cm2": 652.043, + "area_cm2": 982.65, + "worst_pose": "melee_ready@0.5" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "helmet_a": { + "glb": "Armor/Helmet_A.glb", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "slot": "cranium", + "sha256": "e91d1a3344c172683d907d58e993854c00830f4ecbfd098da2f6cf139c51f159", + "glb_female": "Female/Armor/Helmet_A.glb", + "glb_female_sha256": "bf11eab6dfec60de110360d6a12f0c3901b5d23ae857a66796a777341fffc309", + "attached": true, + "garment_triangles": 572, + "female_garment_triangles": 572, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.32609, + "exposed_cm2": 797.313, + "area_cm2": 1183.674, + "worst_pose": "walk_f@0.75" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.48551, + "exposed_cm2": 551.139, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "helmet_b": { + "glb": "Armor/Helmet_B.glb", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "slot": "cranium", + "sha256": "110362f84da176b9bb24a2c4d929dd114a987767200d119a27e4f7aba14bfbfe", + "glb_female": "Female/Armor/Helmet_B.glb", + "glb_female_sha256": "54797dda442a0437bb66eee40ba8e22379d0f7a5736cecea4fd9b0f7d8f413d4", + "attached": true, + "garment_triangles": 564, + "female_garment_triangles": 564, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.20183, + "exposed_cm2": 944.272, + "area_cm2": 1183.674, + "worst_pose": "rifle_aim@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.33079, + "exposed_cm2": 706.696, + "area_cm2": 1054.218, + "worst_pose": "kneel_loop@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "helmet_c": { + "glb": "Armor/Helmet_C.glb", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "slot": "cranium", + "sha256": "2c71b4be39da29ceed169a31f3e85298e5f88b1997252752ad7a810a35b6f105", + "glb_female": "Female/Armor/Helmet_C.glb", + "glb_female_sha256": "fc5108a822700fdcb834ca28c00ee7301d0a0b0859e957a2c17d1fb648a747c3", + "attached": true, + "garment_triangles": 692, + "female_garment_triangles": 692, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.3393, + "exposed_cm2": 782.382, + "area_cm2": 1183.674, + "worst_pose": "melee_turn_180@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.48445, + "exposed_cm2": 552.282, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "helmet_d": { + "glb": "Armor/Helmet_D.glb", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "slot": "cranium", + "sha256": "bd92a85d8351c820423232cf099e6693107c66e057ae0d9aa1c4b0c18c86669e", + "glb_female": "Female/Armor/Helmet_D.glb", + "glb_female_sha256": "601390f6a1c96bd539d4fa2f5166c4f1ae05a1df296da876bbbf3b1184491d7d", + "attached": true, + "garment_triangles": 694, + "female_garment_triangles": 694, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.29199, + "exposed_cm2": 838.4, + "area_cm2": 1183.674, + "worst_pose": "melee_turn_180@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.39071, + "exposed_cm2": 643.687, + "area_cm2": 1054.218, + "worst_pose": "melee_turn_180@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "helmet_v1port": { + "glb": "Armor/Helmet_V1PORT.glb", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "slot": "cranium", + "sha256": "ba99836a98bc3062c14a7a20a68fe3abba32d811a9e6dda16b1bc0752ec648e7", + "glb_female": "Female/Armor/Helmet_V1PORT.glb", + "glb_female_sha256": "09f201aebc6e5766387b866f833bfab0a9ffd777f3d40ba03b691cd7dccaa4c4", + "attached": true, + "garment_triangles": 430, + "female_garment_triangles": 430, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.21573, + "exposed_cm2": 933.828, + "area_cm2": 1183.674, + "worst_pose": "crouch_idle@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.33262, + "exposed_cm2": 714.921, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "helmet_s1": { + "glb": "Armor/Helmet_S1.glb", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "slot": "cranium", + "sha256": "dc3e6e3b836b64ce25f62cd6ad7f1fbdc6980cd6ee16cad7cdc4cdd1ad7c8289", + "glb_female": "Female/Armor/Helmet_S1.glb", + "glb_female_sha256": "e55e575213528eb4cdaf08d937f28bde11ad8b517f31a1e25cb9a73cd558fe5c", + "attached": true, + "garment_triangles": 1018, + "female_garment_triangles": 1018, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.25495, + "exposed_cm2": 881.951, + "area_cm2": 1183.674, + "worst_pose": "run_f@0.7" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.42916, + "exposed_cm2": 603.065, + "area_cm2": 1054.218, + "worst_pose": "melee_turn_180@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "helmet_s2": { + "glb": "Armor/Helmet_S2.glb", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "slot": "cranium", + "sha256": "1a13f6243c4c1b29c042b6ecb0acd080c148b6a2d03b1e6b8d35a36ddd4f5f9c", + "glb_female": "Female/Armor/Helmet_S2.glb", + "glb_female_sha256": "9e53fd8f8b668494d4932d1b9e98f958647bd2605422a454af3ec2ad4f0f826d", + "attached": true, + "garment_triangles": 2036, + "female_garment_triangles": 2036, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.32264, + "exposed_cm2": 801.396, + "area_cm2": 1183.674, + "worst_pose": "idle@0" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.4795, + "exposed_cm2": 549.881, + "area_cm2": 1054.218, + "worst_pose": "melee_turn_180@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "helmet_s3": { + "glb": "Armor/Helmet_S3.glb", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "slot": "cranium", + "sha256": "ab961cc4a6a56098d52d5ae86c580545c98a55cd3b1251f9ff1199e1e3ec01c4", + "glb_female": "Female/Armor/Helmet_S3.glb", + "glb_female_sha256": "4b6f91dc50c854010a5bef05c67d295f864d4de0b48575db571b7b27f61da09d", + "attached": true, + "garment_triangles": 2136, + "female_garment_triangles": 2136, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.28321, + "exposed_cm2": 848.495, + "area_cm2": 1183.674, + "worst_pose": "run_f@0.7" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.36085, + "exposed_cm2": 675.228, + "area_cm2": 1054.218, + "worst_pose": "melee_turn_180@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hat_warm": { + "glb": "Under/Hat_Warm.glb", + "group": "Headwear \u2014 baseline", + "layer": "Under", + "slot": "cranium", + "sha256": "911eb6ecbb61be32cc79f3a0d56bcb7680362c5399e7325352cf43d7385d5569", + "glb_female": "Female/Under/Hat_Warm.glb", + "glb_female_sha256": "641ee4a279b9c8334fbd078defa94550d580192dbc74f91a699aa2eb3f84b6a9", + "attached": true, + "garment_triangles": 312, + "female_garment_triangles": 312, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.10286, + "exposed_cm2": 1061.3, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.14074, + "exposed_cm2": 907.768, + "area_cm2": 1054.218, + "worst_pose": "melee_turn_180@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hat_field_cap": { + "glb": "../../items/custom/accessories/field_cap.glb", + "group": "Headwear \u2014 baseline", + "layer": "Under", + "slot": "cranium", + "sha256": "3de7e61851e11d4ae62b04c5b3aec654413f8b06f4e612b904f488de89c1a84f", + "glb_female": null, + "glb_female_sha256": null, + "attached": false, + "reason": "no skinned mesh and no rigidAnchorBone: the runtime attaches nothing, so it encloses no skin", + "hide_body_zones": [], + "measurement": {} + }, + "under_tank": { + "glb": "Under/Tank.glb", + "group": "Torso", + "layer": "Under", + "slot": "under_torso", + "sha256": "f5f6e6845beb1a6df8b0a065fc726709b1d52912629868fe7a95e40764d6f987", + "glb_female": "Female/Under/Tank.glb", + "glb_female_sha256": "12037a9ab00367cf14c4f017732c78117c81600f45d74ab165e7506d5618e20b", + "attached": true, + "garment_triangles": 516, + "female_garment_triangles": 516, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.86388, + "exposed_cm2": 514.803, + "area_cm2": 3841.793, + "worst_pose": "crouch_walk@0.3" + }, + "pelvis": { + "covered_fraction": 0.36014, + "exposed_cm2": 1060.928, + "area_cm2": 1526.228, + "worst_pose": "meditate_loop@0.5" + }, + "neck": { + "covered_fraction": 0.45407, + "exposed_cm2": 50.399, + "area_cm2": 92.318, + "worst_pose": "death_f@0.6" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1190.691, + "area_cm2": 1183.674, + "worst_pose": "crouch_idle@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.00371, + "exposed_cm2": 1230.227, + "area_cm2": 1151.607, + "worst_pose": "crouch_idle@0.5" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1201.532, + "area_cm2": 1281.261, + "worst_pose": "crouch_walk@0.3" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.84507, + "exposed_cm2": 479.758, + "area_cm2": 3280.077, + "worst_pose": "crouch_walk@0.3" + }, + "pelvis": { + "covered_fraction": 0.3611, + "exposed_cm2": 1198.732, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.39223, + "exposed_cm2": 39.558, + "area_cm2": 65.087, + "worst_pose": "death_f@0.6" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1066.575, + "area_cm2": 1054.218, + "worst_pose": "crouch_idle@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.00382, + "exposed_cm2": 893.077, + "area_cm2": 843.36, + "worst_pose": "kneel_loop@0.5" + }, + "right_upper_arm": { + "covered_fraction": 0.01324, + "exposed_cm2": 866.552, + "area_cm2": 982.65, + "worst_pose": "meditate_loop@0.5" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "under_shorts": { + "glb": "Under/Shorts.glb", + "group": "Legs", + "layer": "Under", + "slot": "under_legs", + "sha256": "1d8a902ea281d760335044630d37444fbc7b35643a276b06437a0836f15d158a", + "glb_female": "Female/Under/Shorts.glb", + "glb_female_sha256": "6c013321f2c6f7dd743bcf7618df6c842665ad6d26f797c3bbf3a6f294576c95", + "attached": true, + "garment_triangles": 436, + "female_garment_triangles": 436, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.36224, + "exposed_cm2": 1066.024, + "area_cm2": 1526.228, + "worst_pose": "crouch_idle@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.78441, + "exposed_cm2": 385.06, + "area_cm2": 1729.671, + "worst_pose": "kneel_loop@0.5" + }, + "right_thigh": { + "covered_fraction": 0.79284, + "exposed_cm2": 367.337, + "area_cm2": 1716.231, + "worst_pose": "run_f@0.7" + }, + "left_calf": { + "covered_fraction": 0.03277, + "exposed_cm2": 1223.29, + "area_cm2": 1238.169, + "worst_pose": "crouch_walk@0.3" + }, + "right_calf": { + "covered_fraction": 0.01931, + "exposed_cm2": 1245.621, + "area_cm2": 1268.488, + "worst_pose": "melee_turn_180@0.5" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.41544, + "exposed_cm2": 999.772, + "area_cm2": 1715.907, + "worst_pose": "crouch_walk@0.3" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.79582, + "exposed_cm2": 342.512, + "area_cm2": 1616.386, + "worst_pose": "kneel_loop@0.5" + }, + "right_thigh": { + "covered_fraction": 0.80389, + "exposed_cm2": 323.913, + "area_cm2": 1603.885, + "worst_pose": "run_f@0.7" + }, + "left_calf": { + "covered_fraction": 0.0288, + "exposed_cm2": 1012.166, + "area_cm2": 992.048, + "worst_pose": "melee_ready@0.5" + }, + "right_calf": { + "covered_fraction": 0.02187, + "exposed_cm2": 1017.767, + "area_cm2": 1022.802, + "worst_pose": "melee_turn_180@0.5" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_mop": { + "glb": "Under/Hair_Mop.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "c7f32850960d89a53b2ac4fefbf288c5b8e4fc36527886d585275118b5b21508", + "glb_female": "Female/Under/Hair_Mop.glb", + "glb_female_sha256": "221090cd837ff313c6978c7f5ed6d98f5637a0c107195c35709b030ccdba4c8b", + "attached": true, + "garment_triangles": 168, + "female_garment_triangles": 168, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.38921, + "exposed_cm2": 722.64, + "area_cm2": 1183.674, + "worst_pose": "walk_f@0.75" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.55967, + "exposed_cm2": 471.702, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_afro2": { + "glb": "Under/Hair_Afro.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "ce18a7ff31f40b0ed0149ccb4fa7b14b3dd8f343814ecf112ba44ae55b17ef44", + "glb_female": "Female/Under/Hair_Afro.glb", + "glb_female_sha256": "20415a8e276a7af39d0124d921a8576f30dad4e7b9bf8752f8fb6453c4c7c27f", + "attached": true, + "garment_triangles": 144, + "female_garment_triangles": 144, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.08889, + "exposed_cm2": 1078.456, + "area_cm2": 1183.674, + "worst_pose": "death_f@0.6" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.10868, + "exposed_cm2": 941.249, + "area_cm2": 1054.218, + "worst_pose": "kneel_loop@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_crop2": { + "glb": "Under/Hair_Crop.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "725bbd31bcffeb041507d5fee1149502af90ed893b0ef8f4850142f9c2d10722", + "glb_female": "Female/Under/Hair_Crop.glb", + "glb_female_sha256": "7ef20dc9bfe55e590ff870b87cada16ec5fc8d7a36cea4eaa1c97c365a03dd75", + "attached": true, + "garment_triangles": 144, + "female_garment_triangles": 144, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.4274, + "exposed_cm2": 683.674, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.49609, + "exposed_cm2": 539.813, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "under_bodysuit": { + "glb": "Under/under_bodysuit.glb", + "group": "Underlayer", + "layer": "Under", + "slot": "under_full", + "sha256": "24e67fe6ab502546f58a07cb9be24e87880cb2d341f4e2c56488eee37bde2d0b", + "glb_female": "Female/Under/under_bodysuit.glb", + "glb_female_sha256": "a4500baef1a8f9fdab63b472b2b2fc04c31c0ec7a93f84321ae43039e807de75", + "attached": true, + "garment_triangles": 1473, + "female_garment_triangles": 1543, + "measurement": { + "male": { + "torso": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.13042, + "exposed_cm2": 77.931, + "area_cm2": 92.318, + "worst_pose": "idle@0" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.03345, + "exposed_cm2": 464.595, + "area_cm2": 480.673, + "worst_pose": "rifle_aim@0.5" + }, + "right_hand": { + "covered_fraction": 0.03362, + "exposed_cm2": 464.512, + "area_cm2": 480.673, + "worst_pose": "rifle_aim@0.5" + }, + "left_thigh": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.00544, + "exposed_cm2": 749.758, + "area_cm2": 720.719, + "worst_pose": "crouch_idle@0.5" + }, + "right_foot": { + "covered_fraction": 0.00602, + "exposed_cm2": 738.455, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.15634, + "exposed_cm2": 60.465, + "area_cm2": 65.087, + "worst_pose": "crouch_walk@0.3" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.03345, + "exposed_cm2": 464.663, + "area_cm2": 480.741, + "worst_pose": "rifle_aim@0.5" + }, + "right_hand": { + "covered_fraction": 0.03372, + "exposed_cm2": 464.53, + "area_cm2": 480.741, + "worst_pose": "crouch_walk@0.3" + }, + "left_thigh": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 1.0, + "exposed_cm2": 0.0, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.01214, + "exposed_cm2": 591.308, + "area_cm2": 572.023, + "worst_pose": "swing_h1@0.4" + }, + "right_foot": { + "covered_fraction": 0.01229, + "exposed_cm2": 591.968, + "area_cm2": 574.742, + "worst_pose": "melee_turn_180@0.5" + } + } + }, + "hide_body_zones": [ + "torso", + "pelvis", + "left_upper_arm", + "right_upper_arm", + "left_forearm", + "right_forearm", + "left_thigh", + "right_thigh", + "left_calf", + "right_calf" + ] + }, + "trial_helm_a": { + "glb": "Armor/trial_helm_a.glb", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "cranium", + "sha256": "d418e83ebe51177f304cb3ebefd4260a58cd98cb3a43b3094cba0a82d8c421df", + "glb_female": "Female/Armor/trial_helm_a.glb", + "glb_female_sha256": "d1ebbfdf30724bbaddf95c582a5da0d9da5e6c0568433f699fad368bc908b3cb", + "attached": true, + "garment_triangles": 1112, + "female_garment_triangles": 1112, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.72845, + "exposed_cm2": 324.225, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.79115, + "exposed_cm2": 223.734, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "trial_helm_b": { + "glb": "Armor/trial_helm_b.glb", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "cranium", + "sha256": "8c33891fcadff624358b5b90fa3f685fc0e1e088fe9498ee850801e6984aad92", + "glb_female": "Female/Armor/trial_helm_b.glb", + "glb_female_sha256": "8bfb118fe67c2f0420199171c326c23fbad43418f504e058547fdf9fd00d62d3", + "attached": true, + "garment_triangles": 955, + "female_garment_triangles": 955, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 110.996, + "area_cm2": 92.318, + "worst_pose": "crouch_walk@0.3" + }, + "head": { + "covered_fraction": 0.73118, + "exposed_cm2": 320.97, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.73715, + "exposed_cm2": 281.576, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "trial_helm_c": { + "glb": "Armor/trial_helm_c.glb", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "cranium", + "sha256": "58c5e227d409e63cf2f8ee22a7d810cdaf5b1ee93a5147ac143afeba784088cc", + "glb_female": "Female/Armor/trial_helm_c.glb", + "glb_female_sha256": "a224d93c2d67eaff6e2fce7f18a0796cd8b089a623a098b11b7211381239351a", + "attached": true, + "garment_triangles": 728, + "female_garment_triangles": 728, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.08747, + "exposed_cm2": 101.288, + "area_cm2": 92.318, + "worst_pose": "crouch_walk@0.3" + }, + "head": { + "covered_fraction": 0.39879, + "exposed_cm2": 711.637, + "area_cm2": 1183.674, + "worst_pose": "death_f@0.6" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.00705, + "exposed_cm2": 65.876, + "area_cm2": 65.087, + "worst_pose": "crouch_idle@0.5" + }, + "head": { + "covered_fraction": 0.42655, + "exposed_cm2": 614.305, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "trial_helm_d": { + "glb": "Armor/trial_helm_d.glb", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "cranium", + "sha256": "c3989eb44be7d409e49b1c84b9690562f3729f283963000cad196ad9f3cd00ff", + "glb_female": "Female/Armor/trial_helm_d.glb", + "glb_female_sha256": "2d7364e115f4efe9d791d83e66ae09e52e99b0224ea753a5b9976aadfbdf9870", + "attached": true, + "garment_triangles": 1232, + "female_garment_triangles": 1232, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.34545, + "exposed_cm2": 781.518, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.36088, + "exposed_cm2": 684.653, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "trial_hood_a": { + "glb": "Armor/trial_hood_a.glb", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "cranium", + "sha256": "d59f5229bcc41bb23ae03c2c8b45de747c70fdb58cc3158a3bf5603105f49eff", + "glb_female": "Female/Armor/trial_hood_a.glb", + "glb_female_sha256": "fac7e289caa79808fe037c68777a4cef83a055798cf1bc723e15c426fc7ad8f1", + "attached": true, + "garment_triangles": 1536, + "female_garment_triangles": 1536, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.28038, + "exposed_cm2": 859.21, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.26261, + "exposed_cm2": 777.143, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "trial_visor_a": { + "glb": "Armor/trial_visor_a.glb", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "face", + "sha256": "43efdcf0c5dbb1c7643b68d85261df2963daec8c70e248d3f7ef437c95839be7", + "glb_female": "Female/Armor/trial_visor_a.glb", + "glb_female_sha256": "8718fbecc49e18a8fb285ebec090b7777ca3b59444f51425b30af77afac1fbf8", + "attached": true, + "garment_triangles": 796, + "female_garment_triangles": 796, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.17104, + "exposed_cm2": 987.031, + "area_cm2": 1183.674, + "worst_pose": "crouch_idle@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.1702, + "exposed_cm2": 888.919, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "trial_mask_b": { + "glb": "Armor/trial_mask_b.glb", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "face", + "sha256": "af5e259d76ed2d3a3b8aa206f5cae873a886abfae731d82d32b311ca0aee4e62", + "glb_female": "Female/Armor/trial_mask_b.glb", + "glb_female_sha256": "7a871b54359a0892f230ba2f829ab3ebe6c0f36e26e3a58afb318f8afb338e6a", + "attached": true, + "garment_triangles": 274, + "female_garment_triangles": 274, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.05126, + "exposed_cm2": 1123.002, + "area_cm2": 1183.674, + "worst_pose": "death_f@0.6" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.07836, + "exposed_cm2": 972.807, + "area_cm2": 1054.218, + "worst_pose": "run_f@0.2" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "trial_back_a": { + "glb": "Armor/trial_back_a.glb", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "back", + "sha256": "e588875fed99a7b4b8e2d6f1e1fbc1abd1e2318b06f5d74388e9d1f27f4f831b", + "glb_female": "Female/Armor/trial_back_a.glb", + "glb_female_sha256": "b823fd6a0b3678d20a05879a7703ec1dad02decfd0315e2345c089baa0bb5566", + "attached": true, + "garment_triangles": 1984, + "female_garment_triangles": 1984, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0124, + "exposed_cm2": 3795.695, + "area_cm2": 3841.793, + "worst_pose": "crouch_idle@0.5" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.00218, + "exposed_cm2": 3351.446, + "area_cm2": 3280.077, + "worst_pose": "melee_turn_180@0.5" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "trial_back_b": { + "glb": "Armor/trial_back_b.glb", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "back", + "sha256": "27a1f09536585ec93ad7efb8f5f6cdfae97efeb9c90f0a4ddc5fc3dc271c6ad6", + "glb_female": "Female/Armor/trial_back_b.glb", + "glb_female_sha256": "c3d0e6820fe99c89fea325d86555b462872002477ab2274e8345ecb5db4ff456", + "attached": true, + "garment_triangles": 2189, + "female_garment_triangles": 2189, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.02291, + "exposed_cm2": 3753.767, + "area_cm2": 3841.793, + "worst_pose": "death_f@0.6" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.02537, + "exposed_cm2": 3196.86, + "area_cm2": 3280.077, + "worst_pose": "death_f@0.6" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "trial_hip_front_a": { + "glb": "Armor/trial_hip_front_a.glb", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "hip", + "sha256": "2381b7528854c68e73ce94a1ac6c44aaa6e75fda9642c1c335d497412b0b38bb", + "glb_female": "Female/Armor/trial_hip_front_a.glb", + "glb_female_sha256": "e21a4d4dd4f03916c874744677108a8ca2855c67643c2e9e609e16e8985d5b7b", + "attached": true, + "garment_triangles": 1130, + "female_garment_triangles": 1130, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1647.841, + "area_cm2": 1729.671, + "worst_pose": "walk_f@0.75" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1680.579, + "area_cm2": 1716.231, + "worst_pose": "walk_f@0.25" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1655.671, + "area_cm2": 1715.907, + "worst_pose": "crouch_idle@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1592.192, + "area_cm2": 1616.386, + "worst_pose": "walk_f@0.75" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1638.355, + "area_cm2": 1603.885, + "worst_pose": "kneel_loop@0.5" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "trial_hip_back_a": { + "glb": "Armor/trial_hip_back_a.glb", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "hip", + "sha256": "c4a6ddf7e970a2b54ce5fe24c4576548915bfb16ae4177b86e25554ca6ef1e48", + "glb_female": "Female/Armor/trial_hip_back_a.glb", + "glb_female_sha256": "3ade262cda136e9e7cb8a934e0f1bad31a561437f5d0777b99e51a528be79dcb", + "attached": true, + "garment_triangles": 418, + "female_garment_triangles": 418, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1612.899, + "area_cm2": 1526.228, + "worst_pose": "idle@0" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.00451, + "exposed_cm2": 1648.208, + "area_cm2": 1715.907, + "worst_pose": "crouch_idle@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "trial_hip_side_a": { + "glb": "Armor/trial_hip_side_a.glb", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "hip", + "sha256": "6868d5b8ee590a919a7a334e5dfe667d30dc42b3cb60fd5e935b39638e160e2a", + "glb_female": "Female/Armor/trial_hip_side_a.glb", + "glb_female_sha256": "4e2d946e0727589bd2f518dcf4df09686718e104e90403ce1f6b275ca0c868e5", + "attached": true, + "garment_triangles": 1008, + "female_garment_triangles": 1008, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.00861, + "exposed_cm2": 1770.703, + "area_cm2": 1729.671, + "worst_pose": "kneel_loop@0.5" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1773.244, + "area_cm2": 1716.231, + "worst_pose": "run_f@0.7" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.00136, + "exposed_cm2": 1707.972, + "area_cm2": 1715.907, + "worst_pose": "crouch_walk@0.3" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0045, + "exposed_cm2": 1669.956, + "area_cm2": 1616.386, + "worst_pose": "kneel_loop@0.5" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1651.658, + "area_cm2": 1603.885, + "worst_pose": "run_f@0.7" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "trial_hair_a": { + "glb": "Armor/trial_hair_a.glb", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "cranium", + "sha256": "94a0117b701d97fdc6c92703ea878bd33b942a4ab20c50dbc4c45cc1c8b1bdac", + "glb_female": "Female/Armor/trial_hair_a.glb", + "glb_female_sha256": "0003abdd6fdcb49e312f41d7e507dcc56246e833ef805bbaf600dbd51a98f709", + "attached": true, + "garment_triangles": 1172, + "female_garment_triangles": 1172, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.3307, + "exposed_cm2": 796.933, + "area_cm2": 1183.674, + "worst_pose": "crouch_idle@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.36649, + "exposed_cm2": 678.643, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "trial_hair_b": { + "glb": "Armor/trial_hair_b.glb", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "cranium", + "sha256": "8e0732537545185b5926440bf41f0d09a03af4955020e2cd0ea3cc62a135e59e", + "glb_female": "Female/Armor/trial_hair_b.glb", + "glb_female_sha256": "9b519b4d7498c4de5ff9f23dae5837d0972364ca6ea062f1e2c0b0f8f3c8cfd2", + "attached": true, + "garment_triangles": 1442, + "female_garment_triangles": 1442, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.15606, + "exposed_cm2": 1004.866, + "area_cm2": 1183.674, + "worst_pose": "crouch_idle@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.18296, + "exposed_cm2": 875.248, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "trial_hair_c": { + "glb": "Armor/trial_hair_c.glb", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "cranium", + "sha256": "b20a1d22780fe89400bb3ea2eb6d09b5359d595e25cc57965d2f0f72d9386451", + "glb_female": "Female/Armor/trial_hair_c.glb", + "glb_female_sha256": "df61a9791496cbb96695a86e5a45ebc1ee8392b40dcbf13d4c8183aa7424bd84", + "attached": true, + "garment_triangles": 1088, + "female_garment_triangles": 1088, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.2527, + "exposed_cm2": 889.802, + "area_cm2": 1183.674, + "worst_pose": "crouch_idle@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.35005, + "exposed_cm2": 693.223, + "area_cm2": 1054.218, + "worst_pose": "crouch_idle@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "trial_hair_d": { + "glb": "Armor/trial_hair_d.glb", + "group": "SYNTY TRIALS", + "layer": "Armor", + "slot": "cranium", + "sha256": "217faa7a8a259a951cf97137a43dea78696248795170d2138cfa11a364abef12", + "glb_female": "Female/Armor/trial_hair_d.glb", + "glb_female_sha256": "2f7a49687fb549adec77f6eb7f2559b0e5a8002274069c97c6ab2dffc9702062", + "attached": true, + "garment_triangles": 3052, + "female_garment_triangles": 3052, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.16511, + "exposed_cm2": 996.836, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.20841, + "exposed_cm2": 847.978, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "under_scfi_top": { + "glb": "Under/SCFI_Top_Refit.glb", + "group": "SYNTY TRIALS", + "layer": "Under", + "slot": "under_torso", + "sha256": "52e123b9f6443b38646a07543c7c47896f2689f9e40fbd034789a08f789dd6ee", + "glb_female": "Female/Under/SCFI_Top_Refit.glb", + "glb_female_sha256": "193e2204da23605a27d23d7a41b0462f9853302e8d4ea483a357cbfed81231e6", + "attached": true, + "garment_triangles": 4012, + "female_garment_triangles": 4012, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.16849, + "exposed_cm2": 3206.157, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.11691, + "exposed_cm2": 1444.835, + "area_cm2": 1526.228, + "worst_pose": "rifle_aim@0.5" + }, + "neck": { + "covered_fraction": 0.04309, + "exposed_cm2": 88.34, + "area_cm2": 92.318, + "worst_pose": "death_f@0.6" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1183.11, + "area_cm2": 1183.674, + "worst_pose": "idle@0" + }, + "left_upper_arm": { + "covered_fraction": 0.63834, + "exposed_cm2": 479.941, + "area_cm2": 1151.607, + "worst_pose": "swing_h1@0.4" + }, + "right_upper_arm": { + "covered_fraction": 0.65176, + "exposed_cm2": 405.246, + "area_cm2": 1281.261, + "worst_pose": "run_f@0.7" + }, + "left_forearm": { + "covered_fraction": 0.77644, + "exposed_cm2": 115.51, + "area_cm2": 516.688, + "worst_pose": "death_f@0.6" + }, + "right_forearm": { + "covered_fraction": 0.78986, + "exposed_cm2": 108.495, + "area_cm2": 511.829, + "worst_pose": "idle@0" + }, + "left_hand": { + "covered_fraction": 0.05587, + "exposed_cm2": 453.82, + "area_cm2": 480.673, + "worst_pose": "melee_turn_180@0.5" + }, + "right_hand": { + "covered_fraction": 0.04296, + "exposed_cm2": 460.024, + "area_cm2": 480.673, + "worst_pose": "run_f@0.7" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.17493, + "exposed_cm2": 2632.356, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.25726, + "exposed_cm2": 1335.358, + "area_cm2": 1715.907, + "worst_pose": "run_f@0.2" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.487, + "area_cm2": 65.087, + "worst_pose": "walk_f@0.75" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1054.205, + "area_cm2": 1054.218, + "worst_pose": "idle@0" + }, + "left_upper_arm": { + "covered_fraction": 0.61044, + "exposed_cm2": 355.654, + "area_cm2": 843.36, + "worst_pose": "run_f@0.2" + }, + "right_upper_arm": { + "covered_fraction": 0.61208, + "exposed_cm2": 380.715, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.78227, + "exposed_cm2": 99.036, + "area_cm2": 455.29, + "worst_pose": "rifle_aim@0.5" + }, + "right_forearm": { + "covered_fraction": 0.81122, + "exposed_cm2": 86.523, + "area_cm2": 466.982, + "worst_pose": "swing_thrust@0.5" + }, + "left_hand": { + "covered_fraction": 0.0559, + "exposed_cm2": 453.871, + "area_cm2": 480.741, + "worst_pose": "rifle_aim@0.5" + }, + "right_hand": { + "covered_fraction": 0.03275, + "exposed_cm2": 464.997, + "area_cm2": 480.741, + "worst_pose": "run_f@0.7" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "under_scfi_pants": { + "glb": "Under/SCFI_Pants_Refit.glb", + "group": "SYNTY TRIALS", + "layer": "Under", + "slot": "under_legs", + "sha256": "ff6bdf8d8f41f90a5246177f1d31a8058f44b97fbfd2314812c663f4c4f7d6eb", + "glb_female": "Female/Under/SCFI_Pants_Refit.glb", + "glb_female_sha256": "848ed1bb3763817690c69cb602fc2d1b0b85b69c3df0a2418a54ad10c0b58a52", + "attached": true, + "garment_triangles": 2826, + "female_garment_triangles": 2826, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0882, + "exposed_cm2": 1397.074, + "area_cm2": 1526.228, + "worst_pose": "swing_thrust@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.08266, + "exposed_cm2": 1533.392, + "area_cm2": 1729.671, + "worst_pose": "walk_f@0.25" + }, + "right_thigh": { + "covered_fraction": 0.02845, + "exposed_cm2": 1646.089, + "area_cm2": 1716.231, + "worst_pose": "rifle_aim@0.5" + }, + "left_calf": { + "covered_fraction": 0.67211, + "exposed_cm2": 419.032, + "area_cm2": 1238.169, + "worst_pose": "run_f@0.7" + }, + "right_calf": { + "covered_fraction": 0.67022, + "exposed_cm2": 422.343, + "area_cm2": 1268.488, + "worst_pose": "run_f@0.2" + }, + "left_foot": { + "covered_fraction": 0.07981, + "exposed_cm2": 663.198, + "area_cm2": 720.719, + "worst_pose": "death_f@0.6" + }, + "right_foot": { + "covered_fraction": 0.07705, + "exposed_cm2": 666.194, + "area_cm2": 721.805, + "worst_pose": "death_f@0.6" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.32966, + "exposed_cm2": 1107.261, + "area_cm2": 1715.907, + "worst_pose": "melee_turn_180@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.45241, + "exposed_cm2": 903.933, + "area_cm2": 1616.386, + "worst_pose": "crouch_walk@0.3" + }, + "right_thigh": { + "covered_fraction": 0.4848, + "exposed_cm2": 850.935, + "area_cm2": 1603.885, + "worst_pose": "run_f@0.7" + }, + "left_calf": { + "covered_fraction": 0.87779, + "exposed_cm2": 121.242, + "area_cm2": 992.048, + "worst_pose": "death_f@0.6" + }, + "right_calf": { + "covered_fraction": 0.89167, + "exposed_cm2": 112.682, + "area_cm2": 1022.802, + "worst_pose": "run_f@0.2" + }, + "left_foot": { + "covered_fraction": 0.07268, + "exposed_cm2": 530.449, + "area_cm2": 572.023, + "worst_pose": "death_f@0.6" + }, + "right_foot": { + "covered_fraction": 0.06977, + "exposed_cm2": 534.64, + "area_cm2": 574.742, + "worst_pose": "death_f@0.6" + } + } + }, + "hide_body_zones": [] + }, + "top_rigged_tank": { + "glb": "Under/top_rigged_tank.glb", + "group": "Tops", + "layer": "Under", + "slot": "under_torso", + "sha256": "f77061b829a38c539bd40e1029aa1e0fcac3327d0f43962974e4b7bca5f6c2a5", + "glb_female": "Female/Under/top_rigged_tank.glb", + "glb_female_sha256": "5a70dd0dbeb4be3b09321c55a9ae61bfef4619ecfc151885511c8a48b3c59e66", + "attached": true, + "garment_triangles": 1527, + "female_garment_triangles": 1527, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.3449, + "exposed_cm2": 2516.765, + "area_cm2": 3841.793, + "worst_pose": "death_f@0.6" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.00543, + "exposed_cm2": 110.394, + "area_cm2": 92.318, + "worst_pose": "crouch_walk@0.3" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.04693, + "exposed_cm2": 1067.326, + "area_cm2": 1151.607, + "worst_pose": "kneel_loop@0.5" + }, + "right_upper_arm": { + "covered_fraction": 0.03761, + "exposed_cm2": 1195.085, + "area_cm2": 1281.261, + "worst_pose": "swing_thrust@0.5" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.43592, + "exposed_cm2": 1850.21, + "area_cm2": 3280.077, + "worst_pose": "death_f@0.6" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.03916, + "exposed_cm2": 861.391, + "area_cm2": 843.36, + "worst_pose": "kneel_loop@0.5" + }, + "right_upper_arm": { + "covered_fraction": 0.04087, + "exposed_cm2": 839.103, + "area_cm2": 982.65, + "worst_pose": "rifle_aim@0.5" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "top_frayed_tunic": { + "glb": "Under/top_frayed_tunic.glb", + "group": "Tops", + "layer": "Under", + "slot": "under_torso", + "sha256": "089528b03c801a6ee56e286909e983b9f6c13c1517ed90a3ce727bd01f18c0fb", + "glb_female": "Female/Under/top_frayed_tunic.glb", + "glb_female_sha256": "23b09c267ef9fc11b05601c8441521527a3171ed63bdaaa73cdbc682129a317a", + "attached": true, + "garment_triangles": 2001, + "female_garment_triangles": 2001, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.29354, + "exposed_cm2": 2918.23, + "area_cm2": 3841.793, + "worst_pose": "swing_thrust@0.5" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.10348, + "exposed_cm2": 1003.996, + "area_cm2": 1151.607, + "worst_pose": "kneel_loop@0.5" + }, + "right_upper_arm": { + "covered_fraction": 0.11111, + "exposed_cm2": 1000.804, + "area_cm2": 1281.261, + "worst_pose": "meditate_loop@0.5" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.36599, + "exposed_cm2": 2161.823, + "area_cm2": 3280.077, + "worst_pose": "swing_thrust@0.5" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.12699, + "exposed_cm2": 766.664, + "area_cm2": 843.36, + "worst_pose": "meditate_loop@0.5" + }, + "right_upper_arm": { + "covered_fraction": 0.13028, + "exposed_cm2": 763.767, + "area_cm2": 982.65, + "worst_pose": "meditate_loop@0.5" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "top_plated_rig_vest": { + "glb": "Under/top_plated_rig_vest.glb", + "group": "Tops", + "layer": "Under", + "slot": "under_torso", + "sha256": "9f3d4e246a335d589657fd8b5fb907c7e1f6c7e22146a20f4c889acdaceab988", + "glb_female": "Female/Under/top_plated_rig_vest.glb", + "glb_female_sha256": "671e17ced76ffa3aae9c8375d6ff2dcbe5a124382c0def14042ba5c1d016cb26", + "attached": true, + "garment_triangles": 1731, + "female_garment_triangles": 1731, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.09152, + "exposed_cm2": 3752.723, + "area_cm2": 3841.793, + "worst_pose": "swing_thrust@0.5" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.982, + "area_cm2": 92.318, + "worst_pose": "run_f@0.7" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1180.735, + "area_cm2": 1151.607, + "worst_pose": "walk_f@0.25" + }, + "right_upper_arm": { + "covered_fraction": 0.00628, + "exposed_cm2": 1165.358, + "area_cm2": 1281.261, + "worst_pose": "walk_f@0.75" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.13413, + "exposed_cm2": 2952.387, + "area_cm2": 3280.077, + "worst_pose": "swing_thrust@0.5" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.00611, + "exposed_cm2": 891.024, + "area_cm2": 843.36, + "worst_pose": "kneel_loop@0.5" + }, + "right_upper_arm": { + "covered_fraction": 0.00652, + "exposed_cm2": 945.688, + "area_cm2": 982.65, + "worst_pose": "swing_h1@0.4" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "top_padded_leather_vest": { + "glb": "Under/top_padded_leather_vest.glb", + "group": "Tops", + "layer": "Under", + "slot": "under_torso", + "sha256": "e7e7739eff8497da89917d0242301ecd57ce3de90dd0fcae7042dbbd80e7482f", + "glb_female": "Female/Under/top_padded_leather_vest.glb", + "glb_female_sha256": "5410decab7f79b452fdd5712c7da81160847e38958a0bfe76ce8d6775003957b", + "attached": true, + "garment_triangles": 2382, + "female_garment_triangles": 2382, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.13654, + "exposed_cm2": 3566.779, + "area_cm2": 3841.793, + "worst_pose": "swing_thrust@0.5" + }, + "pelvis": { + "covered_fraction": 0.10083, + "exposed_cm2": 1471.141, + "area_cm2": 1526.228, + "worst_pose": "rifle_aim@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.09633, + "exposed_cm2": 1017.447, + "area_cm2": 1151.607, + "worst_pose": "meditate_loop@0.5" + }, + "right_upper_arm": { + "covered_fraction": 0.08355, + "exposed_cm2": 1078.813, + "area_cm2": 1281.261, + "worst_pose": "swing_h1@0.4" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.08603, + "exposed_cm2": 3116.39, + "area_cm2": 3280.077, + "worst_pose": "swing_thrust@0.5" + }, + "pelvis": { + "covered_fraction": 0.18009, + "exposed_cm2": 1492.62, + "area_cm2": 1715.907, + "worst_pose": "walk_f@0.25" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.11048, + "exposed_cm2": 844.541, + "area_cm2": 843.36, + "worst_pose": "crouch_walk@0.3" + }, + "right_upper_arm": { + "covered_fraction": 0.09223, + "exposed_cm2": 876.182, + "area_cm2": 982.65, + "worst_pose": "crouch_idle@0.5" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "top_spiked_leather_vest": { + "glb": "Under/top_spiked_leather_vest.glb", + "group": "Tops", + "layer": "Under", + "slot": "under_torso", + "sha256": "d08c1a6a21f5793c4929c775677a98cb2b892c36192c8069a238fdd52e8cfc4b", + "glb_female": "Female/Under/top_spiked_leather_vest.glb", + "glb_female_sha256": "8e4864467293ca27596f4136bdb767201c607bb6a353bfaa4f25e327b54fccb5", + "attached": true, + "garment_triangles": 1822, + "female_garment_triangles": 1822, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.32363, + "exposed_cm2": 2793.94, + "area_cm2": 3841.793, + "worst_pose": "swing_thrust@0.5" + }, + "pelvis": { + "covered_fraction": 0.20484, + "exposed_cm2": 1213.589, + "area_cm2": 1526.228, + "worst_pose": "death_f@0.6" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 92.318, + "area_cm2": 92.318, + "worst_pose": "death_f@0.6" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.08742, + "exposed_cm2": 1027.488, + "area_cm2": 1151.607, + "worst_pose": "meditate_loop@0.5" + }, + "right_upper_arm": { + "covered_fraction": 0.07176, + "exposed_cm2": 1115.307, + "area_cm2": 1281.261, + "worst_pose": "crouch_walk@0.3" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.3381, + "exposed_cm2": 2171.067, + "area_cm2": 3280.077, + "worst_pose": "death_f@0.6" + }, + "pelvis": { + "covered_fraction": 0.25781, + "exposed_cm2": 1381.271, + "area_cm2": 1715.907, + "worst_pose": "rifle_aim@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.07413, + "exposed_cm2": 830.045, + "area_cm2": 843.36, + "worst_pose": "kneel_loop@0.5" + }, + "right_upper_arm": { + "covered_fraction": 0.08838, + "exposed_cm2": 797.541, + "area_cm2": 982.65, + "worst_pose": "rifle_aim@0.5" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "top_reinforced_crop_vest": { + "glb": "Under/top_reinforced_crop_vest.glb", + "group": "Tops", + "layer": "Under", + "slot": "under_torso", + "sha256": "963eaf7a8c3aa9770a6fed7bc0a7209c8b0bc4b67348b78cb5843b7034eaec07", + "glb_female": "Female/Under/top_reinforced_crop_vest.glb", + "glb_female_sha256": "69580f1aa4e2946a375ab7db6d4304f803716f9dcbce1fdf5b81db6718129f58", + "attached": true, + "garment_triangles": 1257, + "female_garment_triangles": 1257, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.12906, + "exposed_cm2": 3293.768, + "area_cm2": 3841.793, + "worst_pose": "crouch_walk@0.3" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.04681, + "exposed_cm2": 1202.982, + "area_cm2": 1151.607, + "worst_pose": "swing_thrust@0.5" + }, + "right_upper_arm": { + "covered_fraction": 0.02591, + "exposed_cm2": 1170.4, + "area_cm2": 1281.261, + "worst_pose": "crouch_walk@0.3" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.18802, + "exposed_cm2": 2590.587, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.04224, + "exposed_cm2": 1012.767, + "area_cm2": 843.36, + "worst_pose": "swing_h1@0.4" + }, + "right_upper_arm": { + "covered_fraction": 0.04204, + "exposed_cm2": 907.248, + "area_cm2": 982.65, + "worst_pose": "crouch_walk@0.3" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "top_scrap_plate_tunic": { + "glb": "Under/top_scrap_plate_tunic.glb", + "group": "Tops", + "layer": "Under", + "slot": "under_torso", + "sha256": "846ee6fcbe24c2dc78087759c2e2486ee956149a4c408be8aabdd950ff335488", + "glb_female": "Female/Under/top_scrap_plate_tunic.glb", + "glb_female_sha256": "9ba04553f2ed4623c06f8181eeca7726aa351a82666faf18b1592c312290e7e8", + "attached": true, + "garment_triangles": 1968, + "female_garment_triangles": 1968, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.16585, + "exposed_cm2": 3216.345, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0917, + "exposed_cm2": 1461.89, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.05028, + "exposed_cm2": 105.415, + "area_cm2": 92.318, + "worst_pose": "crouch_walk@0.3" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.13328, + "exposed_cm2": 975.854, + "area_cm2": 1151.607, + "worst_pose": "meditate_loop@0.5" + }, + "right_upper_arm": { + "covered_fraction": 0.13479, + "exposed_cm2": 1014.649, + "area_cm2": 1281.261, + "worst_pose": "walk_f@0.75" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.14597, + "exposed_cm2": 2724.734, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.18881, + "exposed_cm2": 1521.994, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0077, + "exposed_cm2": 64.586, + "area_cm2": 65.087, + "worst_pose": "death_f@0.6" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.14607, + "exposed_cm2": 777.735, + "area_cm2": 843.36, + "worst_pose": "run_f@0.7" + }, + "right_upper_arm": { + "covered_fraction": 0.1325, + "exposed_cm2": 784.842, + "area_cm2": 982.65, + "worst_pose": "run_f@0.2" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "top_laced_corset_vest": { + "glb": "Under/top_laced_corset_vest.glb", + "group": "Tops", + "layer": "Under", + "slot": "under_torso", + "sha256": "789bad6e14d8fba7e9a1a1dd81c68b3d0c9caebd532b15e6d18849dbb1380967", + "glb_female": "Female/Under/top_laced_corset_vest.glb", + "glb_female_sha256": "2da263833c592aea0992d2b5f2da4797df8cc98a87d4780f64ecee7b24323ac2", + "attached": true, + "garment_triangles": 2048, + "female_garment_triangles": 2048, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.1543, + "exposed_cm2": 3493.411, + "area_cm2": 3841.793, + "worst_pose": "swing_thrust@0.5" + }, + "pelvis": { + "covered_fraction": 0.12611, + "exposed_cm2": 1406.508, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.04398, + "exposed_cm2": 1154.519, + "area_cm2": 1151.607, + "worst_pose": "crouch_walk@0.3" + }, + "right_upper_arm": { + "covered_fraction": 0.00165, + "exposed_cm2": 1199.548, + "area_cm2": 1281.261, + "worst_pose": "crouch_walk@0.3" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.18904, + "exposed_cm2": 2660.005, + "area_cm2": 3280.077, + "worst_pose": "death_f@0.6" + }, + "pelvis": { + "covered_fraction": 0.23453, + "exposed_cm2": 1384.567, + "area_cm2": 1715.907, + "worst_pose": "run_f@0.7" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.04162, + "exposed_cm2": 859.186, + "area_cm2": 843.36, + "worst_pose": "kneel_loop@0.5" + }, + "right_upper_arm": { + "covered_fraction": 0.01253, + "exposed_cm2": 935.194, + "area_cm2": 982.65, + "worst_pose": "crouch_walk@0.3" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "legs_wrapped_workpants": { + "glb": "Under/legs_wrapped_workpants.glb", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "sha256": "5e257e96e0c0738222f91f9cda2124c22bf0e0ee1a76ad378a5175bc9f99ce67", + "glb_female": "Female/Under/legs_wrapped_workpants.glb", + "glb_female_sha256": "5d6bb546bee4354d7546b1c9f725c32d535e6efe79c49e75867bfc960a4856fc", + "attached": true, + "garment_triangles": 2974, + "female_garment_triangles": 2974, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.13195, + "exposed_cm2": 1330.046, + "area_cm2": 1526.228, + "worst_pose": "swing_thrust@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.19844, + "exposed_cm2": 1338.27, + "area_cm2": 1729.671, + "worst_pose": "run_f@0.7" + }, + "right_thigh": { + "covered_fraction": 0.13027, + "exposed_cm2": 1499.433, + "area_cm2": 1716.231, + "worst_pose": "swing_h1@0.4" + }, + "left_calf": { + "covered_fraction": 0.69142, + "exposed_cm2": 392.568, + "area_cm2": 1238.169, + "worst_pose": "swing_thrust@0.5" + }, + "right_calf": { + "covered_fraction": 0.50945, + "exposed_cm2": 628.886, + "area_cm2": 1268.488, + "worst_pose": "melee_ready@0.5" + }, + "left_foot": { + "covered_fraction": 0.19414, + "exposed_cm2": 580.798, + "area_cm2": 720.719, + "worst_pose": "death_f@0.6" + }, + "right_foot": { + "covered_fraction": 0.09272, + "exposed_cm2": 664.588, + "area_cm2": 721.805, + "worst_pose": "run_f@0.2" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.23378, + "exposed_cm2": 1268.612, + "area_cm2": 1715.907, + "worst_pose": "crouch_idle@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.68341, + "exposed_cm2": 522.928, + "area_cm2": 1616.386, + "worst_pose": "run_f@0.2" + }, + "right_thigh": { + "covered_fraction": 0.63283, + "exposed_cm2": 592.723, + "area_cm2": 1603.885, + "worst_pose": "melee_ready@0.5" + }, + "left_calf": { + "covered_fraction": 0.87083, + "exposed_cm2": 130.955, + "area_cm2": 992.048, + "worst_pose": "kneel_loop@0.5" + }, + "right_calf": { + "covered_fraction": 0.81268, + "exposed_cm2": 193.292, + "area_cm2": 1022.802, + "worst_pose": "crouch_walk@0.3" + }, + "left_foot": { + "covered_fraction": 0.16256, + "exposed_cm2": 479.035, + "area_cm2": 572.023, + "worst_pose": "death_f@0.6" + }, + "right_foot": { + "covered_fraction": 0.08765, + "exposed_cm2": 524.365, + "area_cm2": 574.742, + "worst_pose": "death_f@0.6" + } + } + }, + "hide_body_zones": [] + }, + "legs_reinforced_denim_pants": { + "glb": "Under/legs_reinforced_denim_pants.glb", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "sha256": "ae56e4e4910aefb89878fc4142d60b7e87cee3f0cd894f575185fe36bb6693ee", + "glb_female": "Female/Under/legs_reinforced_denim_pants.glb", + "glb_female_sha256": "92b8eb19e3edff310bef9b3e6563d60825d5a0664815c63c71638deca55aca3d", + "attached": true, + "garment_triangles": 2926, + "female_garment_triangles": 2926, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.21523, + "exposed_cm2": 1226.739, + "area_cm2": 1526.228, + "worst_pose": "melee_turn_180@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.27793, + "exposed_cm2": 1248.944, + "area_cm2": 1729.671, + "worst_pose": "death_f@0.6" + }, + "right_thigh": { + "covered_fraction": 0.24837, + "exposed_cm2": 1277.113, + "area_cm2": 1716.231, + "worst_pose": "walk_f@0.75" + }, + "left_calf": { + "covered_fraction": 0.79486, + "exposed_cm2": 262.541, + "area_cm2": 1238.169, + "worst_pose": "melee_ready@0.5" + }, + "right_calf": { + "covered_fraction": 0.78884, + "exposed_cm2": 270.707, + "area_cm2": 1268.488, + "worst_pose": "melee_ready@0.5" + }, + "left_foot": { + "covered_fraction": 0.19731, + "exposed_cm2": 578.513, + "area_cm2": 720.719, + "worst_pose": "death_f@0.6" + }, + "right_foot": { + "covered_fraction": 0.08473, + "exposed_cm2": 677.008, + "area_cm2": 721.805, + "worst_pose": "melee_turn_180@0.5" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.4085, + "exposed_cm2": 1011.634, + "area_cm2": 1715.907, + "worst_pose": "crouch_walk@0.3" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.73887, + "exposed_cm2": 422.091, + "area_cm2": 1616.386, + "worst_pose": "death_f@0.6" + }, + "right_thigh": { + "covered_fraction": 0.71021, + "exposed_cm2": 464.797, + "area_cm2": 1603.885, + "worst_pose": "death_f@0.6" + }, + "left_calf": { + "covered_fraction": 0.87704, + "exposed_cm2": 124.654, + "area_cm2": 992.048, + "worst_pose": "kneel_loop@0.5" + }, + "right_calf": { + "covered_fraction": 0.88623, + "exposed_cm2": 115.72, + "area_cm2": 1022.802, + "worst_pose": "kneel_loop@0.5" + }, + "left_foot": { + "covered_fraction": 0.17395, + "exposed_cm2": 472.522, + "area_cm2": 572.023, + "worst_pose": "death_f@0.6" + }, + "right_foot": { + "covered_fraction": 0.07975, + "exposed_cm2": 528.904, + "area_cm2": 574.742, + "worst_pose": "death_f@0.6" + } + } + }, + "hide_body_zones": [] + }, + "legs_plated_trousers": { + "glb": "Under/legs_plated_trousers.glb", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "sha256": "1ea63718f63259d93d297e35116d265e9057d12b7a44d57d370f67605495c67a", + "glb_female": "Female/Under/legs_plated_trousers.glb", + "glb_female_sha256": "27d8c5775f1ad50c04d38f29f31e5098dd952b73490e4beb91166d5087861afc", + "attached": true, + "garment_triangles": 2828, + "female_garment_triangles": 2828, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.12167, + "exposed_cm2": 1345.801, + "area_cm2": 1526.228, + "worst_pose": "swing_thrust@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.26061, + "exposed_cm2": 1235.932, + "area_cm2": 1729.671, + "worst_pose": "walk_f@0.25" + }, + "right_thigh": { + "covered_fraction": 0.20666, + "exposed_cm2": 1380.8, + "area_cm2": 1716.231, + "worst_pose": "melee_turn_180@0.5" + }, + "left_calf": { + "covered_fraction": 0.74176, + "exposed_cm2": 319.747, + "area_cm2": 1238.169, + "worst_pose": "death_f@0.6" + }, + "right_calf": { + "covered_fraction": 0.77268, + "exposed_cm2": 288.351, + "area_cm2": 1268.488, + "worst_pose": "death_f@0.6" + }, + "left_foot": { + "covered_fraction": 0.20331, + "exposed_cm2": 583.577, + "area_cm2": 720.719, + "worst_pose": "run_f@0.7" + }, + "right_foot": { + "covered_fraction": 0.08393, + "exposed_cm2": 679.467, + "area_cm2": 721.805, + "worst_pose": "walk_f@0.75" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.29993, + "exposed_cm2": 1159.083, + "area_cm2": 1715.907, + "worst_pose": "crouch_idle@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.68543, + "exposed_cm2": 508.469, + "area_cm2": 1616.386, + "worst_pose": "death_f@0.6" + }, + "right_thigh": { + "covered_fraction": 0.64308, + "exposed_cm2": 589.506, + "area_cm2": 1603.885, + "worst_pose": "run_f@0.7" + }, + "left_calf": { + "covered_fraction": 0.85788, + "exposed_cm2": 144.08, + "area_cm2": 992.048, + "worst_pose": "kneel_loop@0.5" + }, + "right_calf": { + "covered_fraction": 0.91543, + "exposed_cm2": 86.011, + "area_cm2": 1022.802, + "worst_pose": "kneel_loop@0.5" + }, + "left_foot": { + "covered_fraction": 0.17114, + "exposed_cm2": 474.127, + "area_cm2": 572.023, + "worst_pose": "death_f@0.6" + }, + "right_foot": { + "covered_fraction": 0.07078, + "exposed_cm2": 534.064, + "area_cm2": 574.742, + "worst_pose": "death_f@0.6" + } + } + }, + "hide_body_zones": [] + }, + "legs_layered_shorts": { + "glb": "Under/legs_layered_shorts.glb", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "sha256": "cbb79c60671cec8b20a337edc3622390b2e1376d9b6d1e94e5d4e294f424a29e", + "glb_female": "Female/Under/legs_layered_shorts.glb", + "glb_female_sha256": "7d52bc8d7cdaf003a79b18281dedd59e9850a5331d1f4ce1c9d12df33aeb104b", + "attached": true, + "garment_triangles": 3396, + "female_garment_triangles": 3396, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.13531, + "exposed_cm2": 1324.899, + "area_cm2": 1526.228, + "worst_pose": "swing_thrust@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.18822, + "exposed_cm2": 1356.932, + "area_cm2": 1729.671, + "worst_pose": "walk_f@0.25" + }, + "right_thigh": { + "covered_fraction": 0.1626, + "exposed_cm2": 1422.837, + "area_cm2": 1716.231, + "worst_pose": "walk_f@0.75" + }, + "left_calf": { + "covered_fraction": 0.81749, + "exposed_cm2": 225.978, + "area_cm2": 1238.169, + "worst_pose": "death_f@0.6" + }, + "right_calf": { + "covered_fraction": 0.80765, + "exposed_cm2": 243.17, + "area_cm2": 1268.488, + "worst_pose": "crouch_walk@0.3" + }, + "left_foot": { + "covered_fraction": 0.16222, + "exposed_cm2": 623.843, + "area_cm2": 720.719, + "worst_pose": "walk_f@0.75" + }, + "right_foot": { + "covered_fraction": 0.08179, + "exposed_cm2": 682.162, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.26184, + "exposed_cm2": 1222.155, + "area_cm2": 1715.907, + "worst_pose": "crouch_idle@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.5027, + "exposed_cm2": 821.418, + "area_cm2": 1616.386, + "worst_pose": "run_f@0.2" + }, + "right_thigh": { + "covered_fraction": 0.58901, + "exposed_cm2": 650.926, + "area_cm2": 1603.885, + "worst_pose": "walk_f@0.75" + }, + "left_calf": { + "covered_fraction": 0.89533, + "exposed_cm2": 107.936, + "area_cm2": 992.048, + "worst_pose": "crouch_idle@0.5" + }, + "right_calf": { + "covered_fraction": 0.89739, + "exposed_cm2": 104.366, + "area_cm2": 1022.802, + "worst_pose": "kneel_loop@0.5" + }, + "left_foot": { + "covered_fraction": 0.13455, + "exposed_cm2": 538.827, + "area_cm2": 572.023, + "worst_pose": "crouch_idle@0.5" + }, + "right_foot": { + "covered_fraction": 0.07511, + "exposed_cm2": 531.574, + "area_cm2": 574.742, + "worst_pose": "death_f@0.6" + } + } + }, + "hide_body_zones": [] + }, + "legs_strapped_trousers": { + "glb": "Under/legs_strapped_trousers.glb", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "sha256": "414043cda6c0231c9ab4056860d207ee5d600dc3fc05772947329acc08bb507b", + "glb_female": "Female/Under/legs_strapped_trousers.glb", + "glb_female_sha256": "e6ad6879d49d0c528fecf84b29b5d1ce9e55817ec59e9152b001836764ab8da4", + "attached": true, + "garment_triangles": 3402, + "female_garment_triangles": 3402, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.09412, + "exposed_cm2": 1388.012, + "area_cm2": 1526.228, + "worst_pose": "swing_thrust@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.18127, + "exposed_cm2": 1382.835, + "area_cm2": 1729.671, + "worst_pose": "rifle_aim@0.5" + }, + "right_thigh": { + "covered_fraction": 0.25084, + "exposed_cm2": 1272.909, + "area_cm2": 1716.231, + "worst_pose": "walk_f@0.75" + }, + "left_calf": { + "covered_fraction": 0.82058, + "exposed_cm2": 225.505, + "area_cm2": 1238.169, + "worst_pose": "kneel_loop@0.5" + }, + "right_calf": { + "covered_fraction": 0.80733, + "exposed_cm2": 241.657, + "area_cm2": 1268.488, + "worst_pose": "run_f@0.7" + }, + "left_foot": { + "covered_fraction": 0.20056, + "exposed_cm2": 576.168, + "area_cm2": 720.719, + "worst_pose": "death_f@0.6" + }, + "right_foot": { + "covered_fraction": 0.10289, + "exposed_cm2": 647.535, + "area_cm2": 721.805, + "worst_pose": "death_f@0.6" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.31465, + "exposed_cm2": 1134.714, + "area_cm2": 1715.907, + "worst_pose": "crouch_idle@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.68034, + "exposed_cm2": 516.69, + "area_cm2": 1616.386, + "worst_pose": "death_f@0.6" + }, + "right_thigh": { + "covered_fraction": 0.74949, + "exposed_cm2": 406.842, + "area_cm2": 1603.885, + "worst_pose": "melee_turn_180@0.5" + }, + "left_calf": { + "covered_fraction": 0.83382, + "exposed_cm2": 168.469, + "area_cm2": 992.048, + "worst_pose": "kneel_loop@0.5" + }, + "right_calf": { + "covered_fraction": 0.84021, + "exposed_cm2": 162.52, + "area_cm2": 1022.802, + "worst_pose": "kneel_loop@0.5" + }, + "left_foot": { + "covered_fraction": 0.17364, + "exposed_cm2": 472.696, + "area_cm2": 572.023, + "worst_pose": "death_f@0.6" + }, + "right_foot": { + "covered_fraction": 0.09185, + "exposed_cm2": 521.949, + "area_cm2": 574.742, + "worst_pose": "death_f@0.6" + } + } + }, + "hide_body_zones": [] + }, + "legs_skirted_workpants": { + "glb": "Under/legs_skirted_workpants.glb", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "sha256": "24371513c72469daa5574d4218339110e70a35e1199296c7d963f8c30680e6e1", + "glb_female": "Female/Under/legs_skirted_workpants.glb", + "glb_female_sha256": "7a8ca2d6aad62a38ed77a6f4b549028e9a7d72a9d82f1db3daa10d55101eb38f", + "attached": true, + "garment_triangles": 2736, + "female_garment_triangles": 2736, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.1248, + "exposed_cm2": 1341.004, + "area_cm2": 1526.228, + "worst_pose": "swing_thrust@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.13574, + "exposed_cm2": 1444.656, + "area_cm2": 1729.671, + "worst_pose": "walk_f@0.25" + }, + "right_thigh": { + "covered_fraction": 0.13375, + "exposed_cm2": 1453.268, + "area_cm2": 1716.231, + "worst_pose": "idle@0" + }, + "left_calf": { + "covered_fraction": 0.78768, + "exposed_cm2": 268.445, + "area_cm2": 1238.169, + "worst_pose": "melee_turn_180@0.5" + }, + "right_calf": { + "covered_fraction": 0.78187, + "exposed_cm2": 277.064, + "area_cm2": 1268.488, + "worst_pose": "melee_turn_180@0.5" + }, + "left_foot": { + "covered_fraction": 0.18484, + "exposed_cm2": 614.516, + "area_cm2": 720.719, + "worst_pose": "crouch_idle@0.5" + }, + "right_foot": { + "covered_fraction": 0.08393, + "exposed_cm2": 679.467, + "area_cm2": 721.805, + "worst_pose": "walk_f@0.75" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.28547, + "exposed_cm2": 1183.03, + "area_cm2": 1715.907, + "worst_pose": "crouch_idle@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.63709, + "exposed_cm2": 586.597, + "area_cm2": 1616.386, + "worst_pose": "death_f@0.6" + }, + "right_thigh": { + "covered_fraction": 0.7158, + "exposed_cm2": 461.564, + "area_cm2": 1603.885, + "worst_pose": "melee_turn_180@0.5" + }, + "left_calf": { + "covered_fraction": 0.81874, + "exposed_cm2": 183.758, + "area_cm2": 992.048, + "worst_pose": "kneel_loop@0.5" + }, + "right_calf": { + "covered_fraction": 0.81915, + "exposed_cm2": 188.174, + "area_cm2": 1022.802, + "worst_pose": "melee_turn_180@0.5" + }, + "left_foot": { + "covered_fraction": 0.15786, + "exposed_cm2": 481.727, + "area_cm2": 572.023, + "worst_pose": "death_f@0.6" + }, + "right_foot": { + "covered_fraction": 0.07283, + "exposed_cm2": 532.886, + "area_cm2": 574.742, + "worst_pose": "death_f@0.6" + } + } + }, + "hide_body_zones": [] + }, + "legs_gaitered_cargo_pants": { + "glb": "Under/legs_gaitered_cargo_pants.glb", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "sha256": "f97e97f3f95fc02eef6c3b3ca4a273e84aee6de516a6c9cd640710ecd1e88008", + "glb_female": "Female/Under/legs_gaitered_cargo_pants.glb", + "glb_female_sha256": "a867c6b839451a3e53c68afd0dac2cbe0c143899683bfac3f3caba1f2a89b797", + "attached": true, + "garment_triangles": 2912, + "female_garment_triangles": 2912, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.13395, + "exposed_cm2": 1326.975, + "area_cm2": 1526.228, + "worst_pose": "swing_thrust@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.18449, + "exposed_cm2": 1363.175, + "area_cm2": 1729.671, + "worst_pose": "walk_f@0.25" + }, + "right_thigh": { + "covered_fraction": 0.1813, + "exposed_cm2": 1391.072, + "area_cm2": 1716.231, + "worst_pose": "walk_f@0.75" + }, + "left_calf": { + "covered_fraction": 0.79915, + "exposed_cm2": 253.106, + "area_cm2": 1238.169, + "worst_pose": "crouch_idle@0.5" + }, + "right_calf": { + "covered_fraction": 0.77937, + "exposed_cm2": 278.036, + "area_cm2": 1268.488, + "worst_pose": "kneel_loop@0.5" + }, + "left_foot": { + "covered_fraction": 0.20008, + "exposed_cm2": 576.514, + "area_cm2": 720.719, + "worst_pose": "death_f@0.6" + }, + "right_foot": { + "covered_fraction": 0.08873, + "exposed_cm2": 657.756, + "area_cm2": 721.805, + "worst_pose": "death_f@0.6" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.34999, + "exposed_cm2": 1076.203, + "area_cm2": 1715.907, + "worst_pose": "crouch_idle@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.59247, + "exposed_cm2": 658.718, + "area_cm2": 1616.386, + "worst_pose": "death_f@0.6" + }, + "right_thigh": { + "covered_fraction": 0.65063, + "exposed_cm2": 567.396, + "area_cm2": 1603.885, + "worst_pose": "melee_turn_180@0.5" + }, + "left_calf": { + "covered_fraction": 0.871, + "exposed_cm2": 130.778, + "area_cm2": 992.048, + "worst_pose": "kneel_loop@0.5" + }, + "right_calf": { + "covered_fraction": 0.8626, + "exposed_cm2": 139.753, + "area_cm2": 1022.802, + "worst_pose": "kneel_loop@0.5" + }, + "left_foot": { + "covered_fraction": 0.17382, + "exposed_cm2": 472.594, + "area_cm2": 572.023, + "worst_pose": "death_f@0.6" + }, + "right_foot": { + "covered_fraction": 0.07021, + "exposed_cm2": 534.389, + "area_cm2": 574.742, + "worst_pose": "death_f@0.6" + } + } + }, + "hide_body_zones": [] + }, + "legs_padded_canvas_trousers": { + "glb": "Under/legs_padded_canvas_trousers.glb", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "sha256": "cc436e9e7b780968b42efcd795be8be0ad5608efba3d91535425ee8c67be130f", + "glb_female": "Female/Under/legs_padded_canvas_trousers.glb", + "glb_female_sha256": "d0db94d4d1e2da0764dedb602179e0931edbaa82688cc415be7157d555bfea6f", + "attached": true, + "garment_triangles": 2750, + "female_garment_triangles": 2750, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.12889, + "exposed_cm2": 1334.733, + "area_cm2": 1526.228, + "worst_pose": "swing_thrust@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.15942, + "exposed_cm2": 1405.08, + "area_cm2": 1729.671, + "worst_pose": "walk_f@0.25" + }, + "right_thigh": { + "covered_fraction": 0.18305, + "exposed_cm2": 1388.096, + "area_cm2": 1716.231, + "worst_pose": "walk_f@0.75" + }, + "left_calf": { + "covered_fraction": 0.79506, + "exposed_cm2": 257.575, + "area_cm2": 1238.169, + "worst_pose": "kneel_loop@0.5" + }, + "right_calf": { + "covered_fraction": 0.80389, + "exposed_cm2": 247.2, + "area_cm2": 1268.488, + "worst_pose": "crouch_idle@0.5" + }, + "left_foot": { + "covered_fraction": 0.19675, + "exposed_cm2": 578.916, + "area_cm2": 720.719, + "worst_pose": "death_f@0.6" + }, + "right_foot": { + "covered_fraction": 0.15201, + "exposed_cm2": 612.081, + "area_cm2": 721.805, + "worst_pose": "death_f@0.6" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.32055, + "exposed_cm2": 1162.064, + "area_cm2": 1715.907, + "worst_pose": "crouch_walk@0.3" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.66529, + "exposed_cm2": 541.028, + "area_cm2": 1616.386, + "worst_pose": "death_f@0.6" + }, + "right_thigh": { + "covered_fraction": 0.67068, + "exposed_cm2": 534.845, + "area_cm2": 1603.885, + "worst_pose": "melee_turn_180@0.5" + }, + "left_calf": { + "covered_fraction": 0.87325, + "exposed_cm2": 125.747, + "area_cm2": 992.048, + "worst_pose": "death_f@0.6" + }, + "right_calf": { + "covered_fraction": 0.88475, + "exposed_cm2": 117.217, + "area_cm2": 1022.802, + "worst_pose": "kneel_loop@0.5" + }, + "left_foot": { + "covered_fraction": 0.16393, + "exposed_cm2": 478.251, + "area_cm2": 572.023, + "worst_pose": "death_f@0.6" + }, + "right_foot": { + "covered_fraction": 0.14211, + "exposed_cm2": 493.066, + "area_cm2": 574.742, + "worst_pose": "death_f@0.6" + } + } + }, + "hide_body_zones": [] + }, + "legs_sashed_patrol_pants": { + "glb": "Under/legs_sashed_patrol_pants.glb", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "sha256": "467f2a7cf1bfc2b57929ff5b453cd5f4ddf0b6d0abedbd0a66c5fe04c2cbabe8", + "glb_female": "Female/Under/legs_sashed_patrol_pants.glb", + "glb_female_sha256": "7fd35fe70c2dd6828840fcf44925490803a833a0b274f3a8b0a4939ef93ddf7d", + "attached": true, + "garment_triangles": 3226, + "female_garment_triangles": 3226, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.12169, + "exposed_cm2": 1345.774, + "area_cm2": 1526.228, + "worst_pose": "swing_thrust@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.16442, + "exposed_cm2": 1411.294, + "area_cm2": 1729.671, + "worst_pose": "rifle_aim@0.5" + }, + "right_thigh": { + "covered_fraction": 0.16404, + "exposed_cm2": 1420.392, + "area_cm2": 1716.231, + "worst_pose": "walk_f@0.75" + }, + "left_calf": { + "covered_fraction": 0.83461, + "exposed_cm2": 207.868, + "area_cm2": 1238.169, + "worst_pose": "kneel_loop@0.5" + }, + "right_calf": { + "covered_fraction": 0.81208, + "exposed_cm2": 236.821, + "area_cm2": 1268.488, + "worst_pose": "kneel_loop@0.5" + }, + "left_foot": { + "covered_fraction": 0.20556, + "exposed_cm2": 591.575, + "area_cm2": 720.719, + "worst_pose": "walk_f@0.75" + }, + "right_foot": { + "covered_fraction": 0.08393, + "exposed_cm2": 679.467, + "area_cm2": 721.805, + "worst_pose": "walk_f@0.75" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.36362, + "exposed_cm2": 1088.401, + "area_cm2": 1715.907, + "worst_pose": "crouch_walk@0.3" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.59976, + "exposed_cm2": 646.936, + "area_cm2": 1616.386, + "worst_pose": "death_f@0.6" + }, + "right_thigh": { + "covered_fraction": 0.63181, + "exposed_cm2": 608.124, + "area_cm2": 1603.885, + "worst_pose": "run_f@0.7" + }, + "left_calf": { + "covered_fraction": 0.91606, + "exposed_cm2": 85.092, + "area_cm2": 992.048, + "worst_pose": "kneel_loop@0.5" + }, + "right_calf": { + "covered_fraction": 0.86413, + "exposed_cm2": 138.188, + "area_cm2": 1022.802, + "worst_pose": "kneel_loop@0.5" + }, + "left_foot": { + "covered_fraction": 0.14096, + "exposed_cm2": 530.876, + "area_cm2": 572.023, + "worst_pose": "melee_turn_180@0.5" + }, + "right_foot": { + "covered_fraction": 0.06977, + "exposed_cm2": 534.64, + "area_cm2": 574.742, + "worst_pose": "death_f@0.6" + } + } + }, + "hide_body_zones": [] + }, + "legs_layered_wrap_skirt": { + "glb": "Under/legs_layered_wrap_skirt.glb", + "group": "Bottoms", + "layer": "Under", + "slot": "under_legs", + "sha256": "2cc84068c65fc832e3d61a7129b69667f431a80163527eef79272d707fd4f41e", + "glb_female": "Female/Under/legs_layered_wrap_skirt.glb", + "glb_female_sha256": "7b7a3b13bc32e3133a9b36bd7eb9b80caf2f5e6481937a0d52b5da4b8cd2f504", + "attached": true, + "garment_triangles": 2834, + "female_garment_triangles": 2834, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.14259, + "exposed_cm2": 1308.602, + "area_cm2": 1526.228, + "worst_pose": "death_f@0.6" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.14119, + "exposed_cm2": 1415.666, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.11897, + "exposed_cm2": 1476.687, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.72118, + "exposed_cm2": 356.824, + "area_cm2": 1238.169, + "worst_pose": "melee_ready@0.5" + }, + "right_calf": { + "covered_fraction": 0.82373, + "exposed_cm2": 225.748, + "area_cm2": 1268.488, + "worst_pose": "run_f@0.2" + }, + "left_foot": { + "covered_fraction": 0.19723, + "exposed_cm2": 578.568, + "area_cm2": 720.719, + "worst_pose": "death_f@0.6" + }, + "right_foot": { + "covered_fraction": 0.08393, + "exposed_cm2": 679.467, + "area_cm2": 721.805, + "worst_pose": "walk_f@0.75" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.32241, + "exposed_cm2": 1121.874, + "area_cm2": 1715.907, + "worst_pose": "crouch_idle@0.5" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.38656, + "exposed_cm2": 1002.03, + "area_cm2": 1616.386, + "worst_pose": "rifle_aim@0.5" + }, + "right_thigh": { + "covered_fraction": 0.23397, + "exposed_cm2": 1244.086, + "area_cm2": 1603.885, + "worst_pose": "melee_turn_180@0.5" + }, + "left_calf": { + "covered_fraction": 0.81615, + "exposed_cm2": 189.754, + "area_cm2": 992.048, + "worst_pose": "crouch_walk@0.3" + }, + "right_calf": { + "covered_fraction": 0.86605, + "exposed_cm2": 138.218, + "area_cm2": 1022.802, + "worst_pose": "crouch_walk@0.3" + }, + "left_foot": { + "covered_fraction": 0.16196, + "exposed_cm2": 479.38, + "area_cm2": 572.023, + "worst_pose": "death_f@0.6" + }, + "right_foot": { + "covered_fraction": 0.07323, + "exposed_cm2": 532.654, + "area_cm2": 574.742, + "worst_pose": "death_f@0.6" + } + } + }, + "hide_body_zones": [] + }, + "boots_canvas_ankle": { + "glb": "Under/boots_canvas_ankle.glb", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "sha256": "5980ff35c965522e2e88a03b63abdea9a75f49982c5e90fbd4a6c54f3f461cd6", + "glb_female": "Female/Under/boots_canvas_ankle.glb", + "glb_female_sha256": "24fb7c94ad564ded849884cb8e750e9898e85a8703a86012369404c845e93a51", + "attached": true, + "garment_triangles": 304, + "female_garment_triangles": 354, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.2164, + "exposed_cm2": 995.427, + "area_cm2": 1238.169, + "worst_pose": "meditate_loop@0.5" + }, + "right_calf": { + "covered_fraction": 0.2164, + "exposed_cm2": 995.428, + "area_cm2": 1268.488, + "worst_pose": "meditate_loop@0.5" + }, + "left_foot": { + "covered_fraction": 0.99609, + "exposed_cm2": 2.752, + "area_cm2": 720.719, + "worst_pose": "meditate_loop@0.5" + }, + "right_foot": { + "covered_fraction": 0.99679, + "exposed_cm2": 2.422, + "area_cm2": 721.805, + "worst_pose": "crouch_walk@0.3" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.19328, + "exposed_cm2": 810.623, + "area_cm2": 992.048, + "worst_pose": "meditate_loop@0.5" + }, + "right_calf": { + "covered_fraction": 0.19328, + "exposed_cm2": 810.623, + "area_cm2": 1022.802, + "worst_pose": "meditate_loop@0.5" + }, + "left_foot": { + "covered_fraction": 0.99555, + "exposed_cm2": 2.472, + "area_cm2": 572.023, + "worst_pose": "meditate_loop@0.5" + }, + "right_foot": { + "covered_fraction": 0.99401, + "exposed_cm2": 3.759, + "area_cm2": 574.742, + "worst_pose": "crouch_walk@0.3" + } + } + }, + "hide_body_zones": [ + "left_foot", + "right_foot" + ] + }, + "boots_layered_sneakers": { + "glb": "Under/boots_layered_sneakers.glb", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "sha256": "da6615015193fd6f2aea75fa9924b42029cc35f07ca5c9f9b1774f549cc0f811", + "glb_female": "Female/Under/boots_layered_sneakers.glb", + "glb_female_sha256": "26b3b3b3599fe6ef20a03bd6492f2e9c8321a7c2102d4577fe6baf20817898e2", + "attached": true, + "garment_triangles": 550, + "female_garment_triangles": 550, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.00058, + "exposed_cm2": 1263.604, + "area_cm2": 1238.169, + "worst_pose": "melee_turn_180@0.5" + }, + "right_calf": { + "covered_fraction": 0.00059, + "exposed_cm2": 1281.682, + "area_cm2": 1268.488, + "worst_pose": "rifle_aim@0.5" + }, + "left_foot": { + "covered_fraction": 0.6289, + "exposed_cm2": 275.7, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.58083, + "exposed_cm2": 312.813, + "area_cm2": 721.805, + "worst_pose": "crouch_idle@0.5" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 992.048, + "area_cm2": 992.048, + "worst_pose": "death_f@0.6" + }, + "right_calf": { + "covered_fraction": 0.00068, + "exposed_cm2": 1048.303, + "area_cm2": 1022.802, + "worst_pose": "melee_ready@0.5" + }, + "left_foot": { + "covered_fraction": 0.64978, + "exposed_cm2": 216.433, + "area_cm2": 572.023, + "worst_pose": "melee_turn_180@0.5" + }, + "right_foot": { + "covered_fraction": 0.65058, + "exposed_cm2": 216.641, + "area_cm2": 574.742, + "worst_pose": "rifle_aim@0.5" + } + } + }, + "hide_body_zones": [] + }, + "boots_split_toe": { + "glb": "Under/boots_split_toe.glb", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "sha256": "65fc2884690fb135e001ab7982e7bf4f3e2f4461fc3ea320aace328a3493b834", + "glb_female": "Female/Under/boots_split_toe.glb", + "glb_female_sha256": "c6f49a6b4e5d032618da37300f3d4772a4230a71f909115330e412483f10ec4b", + "attached": true, + "garment_triangles": 752, + "female_garment_triangles": 752, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.00739, + "exposed_cm2": 1254.986, + "area_cm2": 1238.169, + "worst_pose": "melee_turn_180@0.5" + }, + "right_calf": { + "covered_fraction": 0.00758, + "exposed_cm2": 1272.724, + "area_cm2": 1268.488, + "worst_pose": "rifle_aim@0.5" + }, + "left_foot": { + "covered_fraction": 0.67038, + "exposed_cm2": 247.199, + "area_cm2": 720.719, + "worst_pose": "melee_turn_180@0.5" + }, + "right_foot": { + "covered_fraction": 0.67387, + "exposed_cm2": 242.907, + "area_cm2": 721.805, + "worst_pose": "run_f@0.7" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.00153, + "exposed_cm2": 1048.328, + "area_cm2": 992.048, + "worst_pose": "walk_f@0.25" + }, + "right_calf": { + "covered_fraction": 0.00153, + "exposed_cm2": 1048.422, + "area_cm2": 1022.802, + "worst_pose": "walk_f@0.75" + }, + "left_foot": { + "covered_fraction": 0.64834, + "exposed_cm2": 218.945, + "area_cm2": 572.023, + "worst_pose": "crouch_idle@0.5" + }, + "right_foot": { + "covered_fraction": 0.64537, + "exposed_cm2": 217.928, + "area_cm2": 574.742, + "worst_pose": "melee_ready@0.5" + } + } + }, + "hide_body_zones": [] + }, + "boots_trail_shoes": { + "glb": "Under/boots_trail_shoes.glb", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "sha256": "53e6757a88c9f2de42003b00a5e9de2f9c235363d4b1684e80544667db8e903b", + "glb_female": "Female/Under/boots_trail_shoes.glb", + "glb_female_sha256": "4b11796a3562da66670baba2823a5909b008bcc1aa725bbe1a395d35cb5df4a4", + "attached": true, + "garment_triangles": 756, + "female_garment_triangles": 756, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.57615, + "exposed_cm2": 315.616, + "area_cm2": 720.719, + "worst_pose": "walk_f@0.75" + }, + "right_foot": { + "covered_fraction": 0.56622, + "exposed_cm2": 323.09, + "area_cm2": 721.805, + "worst_pose": "run_f@0.7" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.61209, + "exposed_cm2": 234.418, + "area_cm2": 572.023, + "worst_pose": "kneel_loop@0.5" + }, + "right_foot": { + "covered_fraction": 0.61101, + "exposed_cm2": 235.816, + "area_cm2": 574.742, + "worst_pose": "idle@0" + } + } + }, + "hide_body_zones": [] + }, + "boots_double_strap": { + "glb": "Under/boots_double_strap.glb", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "sha256": "2854cc8fbf384bd2ddb12fb726857a660d88a45f738b8235e54604538a34b0d8", + "glb_female": "Female/Under/boots_double_strap.glb", + "glb_female_sha256": "e00ec720c0c5f00af6680a83499a48b8a32807400ca3237864966528e0f54835", + "attached": true, + "garment_triangles": 1040, + "female_garment_triangles": 1040, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1260.205, + "area_cm2": 1238.169, + "worst_pose": "crouch_idle@0.5" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1275.507, + "area_cm2": 1268.488, + "worst_pose": "swing_h1@0.4" + }, + "left_foot": { + "covered_fraction": 0.72398, + "exposed_cm2": 198.935, + "area_cm2": 720.719, + "worst_pose": "death_f@0.6" + }, + "right_foot": { + "covered_fraction": 0.70084, + "exposed_cm2": 210.665, + "area_cm2": 721.805, + "worst_pose": "meditate_loop@0.5" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1049.929, + "area_cm2": 992.048, + "worst_pose": "walk_f@0.25" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.69904, + "exposed_cm2": 177.504, + "area_cm2": 572.023, + "worst_pose": "run_f@0.7" + }, + "right_foot": { + "covered_fraction": 0.70506, + "exposed_cm2": 177.655, + "area_cm2": 574.742, + "worst_pose": "walk_f@0.75" + } + } + }, + "hide_body_zones": [] + }, + "boots_rubberized_canvas": { + "glb": "Under/boots_rubberized_canvas.glb", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "sha256": "1297f17fa95fead6c099a72de7f8a4512c1c883959ee8149acacdf35e1787ee4", + "glb_female": "Female/Under/boots_rubberized_canvas.glb", + "glb_female_sha256": "0245dc1cef9e6587a63e3216092bacec37e8339e93cfb898d83f7d51a12c667b", + "attached": true, + "garment_triangles": 1084, + "female_garment_triangles": 1084, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1264.332, + "area_cm2": 1238.169, + "worst_pose": "melee_turn_180@0.5" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1282.439, + "area_cm2": 1268.488, + "worst_pose": "rifle_aim@0.5" + }, + "left_foot": { + "covered_fraction": 0.67102, + "exposed_cm2": 243.649, + "area_cm2": 720.719, + "worst_pose": "rifle_aim@0.5" + }, + "right_foot": { + "covered_fraction": 0.67033, + "exposed_cm2": 244.111, + "area_cm2": 721.805, + "worst_pose": "swing_h1@0.4" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 992.048, + "area_cm2": 992.048, + "worst_pose": "death_f@0.6" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1040.519, + "area_cm2": 1022.802, + "worst_pose": "melee_turn_180@0.5" + }, + "left_foot": { + "covered_fraction": 0.66537, + "exposed_cm2": 204.64, + "area_cm2": 572.023, + "worst_pose": "walk_f@0.75" + }, + "right_foot": { + "covered_fraction": 0.6555, + "exposed_cm2": 213.588, + "area_cm2": 574.742, + "worst_pose": "rifle_aim@0.5" + } + } + }, + "hide_body_zones": [] + }, + "boots_toe_capped": { + "glb": "Under/boots_toe_capped.glb", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "sha256": "ed3967d438467d1c249426563c78355bbd7bb134e7190fe4c9ad2798bb46e17d", + "glb_female": "Female/Under/boots_toe_capped.glb", + "glb_female_sha256": "6eab43391eb0ff3d1901fd511d253255c884b48697c5154cfca57c7accfaf917", + "attached": true, + "garment_triangles": 544, + "female_garment_triangles": 544, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1286.983, + "area_cm2": 1238.169, + "worst_pose": "idle@0" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1290.375, + "area_cm2": 1268.488, + "worst_pose": "idle@0" + }, + "left_foot": { + "covered_fraction": 0.59683, + "exposed_cm2": 298.66, + "area_cm2": 720.719, + "worst_pose": "melee_ready@0.5" + }, + "right_foot": { + "covered_fraction": 0.60451, + "exposed_cm2": 294.957, + "area_cm2": 721.805, + "worst_pose": "kneel_loop@0.5" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.62168, + "exposed_cm2": 231.358, + "area_cm2": 572.023, + "worst_pose": "walk_f@0.75" + }, + "right_foot": { + "covered_fraction": 0.62431, + "exposed_cm2": 231.026, + "area_cm2": 574.742, + "worst_pose": "walk_f@0.25" + } + } + }, + "hide_body_zones": [] + }, + "boots_combat_sneakers": { + "glb": "Under/boots_combat_sneakers.glb", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "sha256": "2e79aa8ada888a5d0e55647330b0dd1bde00728bc7af08a449b9d64df8d9dd5f", + "glb_female": "Female/Under/boots_combat_sneakers.glb", + "glb_female_sha256": "40b1f316ccba557d7105ea9890b427034cb0997b735ecf75c34f545cde0675b0", + "attached": true, + "garment_triangles": 724, + "female_garment_triangles": 724, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.00058, + "exposed_cm2": 1263.604, + "area_cm2": 1238.169, + "worst_pose": "melee_turn_180@0.5" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1282.439, + "area_cm2": 1268.488, + "worst_pose": "rifle_aim@0.5" + }, + "left_foot": { + "covered_fraction": 0.64989, + "exposed_cm2": 260.283, + "area_cm2": 720.719, + "worst_pose": "kneel_loop@0.5" + }, + "right_foot": { + "covered_fraction": 0.65166, + "exposed_cm2": 259.449, + "area_cm2": 721.805, + "worst_pose": "run_f@0.7" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1049.929, + "area_cm2": 992.048, + "worst_pose": "walk_f@0.25" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.033, + "area_cm2": 1022.802, + "worst_pose": "walk_f@0.75" + }, + "left_foot": { + "covered_fraction": 0.66287, + "exposed_cm2": 204.572, + "area_cm2": 572.023, + "worst_pose": "run_f@0.2" + }, + "right_foot": { + "covered_fraction": 0.65898, + "exposed_cm2": 207.285, + "area_cm2": 574.742, + "worst_pose": "run_f@0.7" + } + } + }, + "hide_body_zones": [] + }, + "boots_cuffed_runners": { + "glb": "Under/boots_cuffed_runners.glb", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "sha256": "9b0ea8a76b09bb04da560b24f9c528193a564c39e2ccc4828879fe298161ca34", + "glb_female": "Female/Under/boots_cuffed_runners.glb", + "glb_female_sha256": "cf70bcf65d23305d03d2cfc6a92809d183d874e35abff485496a84f4cdcfe9ff", + "attached": true, + "garment_triangles": 732, + "female_garment_triangles": 732, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1260.205, + "area_cm2": 1238.169, + "worst_pose": "crouch_idle@0.5" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1254.271, + "area_cm2": 1268.488, + "worst_pose": "run_f@0.7" + }, + "left_foot": { + "covered_fraction": 0.6825, + "exposed_cm2": 236.421, + "area_cm2": 720.719, + "worst_pose": "walk_f@0.75" + }, + "right_foot": { + "covered_fraction": 0.68584, + "exposed_cm2": 234.447, + "area_cm2": 721.805, + "worst_pose": "crouch_idle@0.5" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.00065, + "exposed_cm2": 1062.385, + "area_cm2": 992.048, + "worst_pose": "walk_f@0.75" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1042.812, + "area_cm2": 1022.802, + "worst_pose": "swing_h1@0.4" + }, + "left_foot": { + "covered_fraction": 0.6826, + "exposed_cm2": 197.613, + "area_cm2": 572.023, + "worst_pose": "crouch_idle@0.5" + }, + "right_foot": { + "covered_fraction": 0.6858, + "exposed_cm2": 194.799, + "area_cm2": 574.742, + "worst_pose": "rifle_aim@0.5" + } + } + }, + "hide_body_zones": [] + }, + "boots_reinforced_street": { + "glb": "Under/boots_reinforced_street.glb", + "group": "Footwear", + "layer": "Under", + "slot": "under_feet", + "sha256": "c70e667130be62226b4e98fac381658fa2ebe51fd5ca746679dcd10b1efb327d", + "glb_female": "Female/Under/boots_reinforced_street.glb", + "glb_female_sha256": "bc70685489254e4bcbe6fc8593da1b2cfc1457db0d022269c66b12fd19336b5c", + "attached": true, + "garment_triangles": 832, + "female_garment_triangles": 832, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1260.205, + "area_cm2": 1238.169, + "worst_pose": "crouch_idle@0.5" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1299.194, + "area_cm2": 1268.488, + "worst_pose": "walk_f@0.25" + }, + "left_foot": { + "covered_fraction": 0.75305, + "exposed_cm2": 180.895, + "area_cm2": 720.719, + "worst_pose": "run_f@0.7" + }, + "right_foot": { + "covered_fraction": 0.77228, + "exposed_cm2": 171.864, + "area_cm2": 721.805, + "worst_pose": "crouch_walk@0.3" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.00065, + "exposed_cm2": 1062.385, + "area_cm2": 992.048, + "worst_pose": "walk_f@0.75" + }, + "right_calf": { + "covered_fraction": 0.00065, + "exposed_cm2": 1060.557, + "area_cm2": 1022.802, + "worst_pose": "walk_f@0.25" + }, + "left_foot": { + "covered_fraction": 0.75309, + "exposed_cm2": 149.825, + "area_cm2": 572.023, + "worst_pose": "run_f@0.2" + }, + "right_foot": { + "covered_fraction": 0.7578, + "exposed_cm2": 146.829, + "area_cm2": 574.742, + "worst_pose": "idle@0" + } + } + }, + "hide_body_zones": [] + }, + "gloves_knuckled_half": { + "glb": "Under/gloves_knuckled_half.glb", + "group": "Gloves", + "layer": "Under", + "slot": "under_hands", + "sha256": "fd60ddaf6f51b96f820b394460c4afed179f90a5143f0d7b0e68a8d2e1fc4437", + "glb_female": "Female/Under/gloves_knuckled_half.glb", + "glb_female_sha256": "82dc4642a2007d8dcd2c275cbf165ed8ffc0f94cfecf987850bed12cb5bbcebd", + "attached": true, + "garment_triangles": 422, + "female_garment_triangles": 422, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.00068, + "exposed_cm2": 518.534, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.00082, + "exposed_cm2": 518.462, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.26587, + "exposed_cm2": 352.877, + "area_cm2": 480.673, + "worst_pose": "crouch_idle@0.5" + }, + "right_hand": { + "covered_fraction": 0.24662, + "exposed_cm2": 362.13, + "area_cm2": 480.673, + "worst_pose": "kneel_loop@0.5" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.00074, + "exposed_cm2": 477.018, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.00089, + "exposed_cm2": 476.946, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.26581, + "exposed_cm2": 352.955, + "area_cm2": 480.741, + "worst_pose": "crouch_idle@0.5" + }, + "right_hand": { + "covered_fraction": 0.24661, + "exposed_cm2": 362.187, + "area_cm2": 480.741, + "worst_pose": "kneel_loop@0.5" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "gloves_guarded_leather": { + "glb": "Under/gloves_guarded_leather.glb", + "group": "Gloves", + "layer": "Under", + "slot": "under_hands", + "sha256": "b30715b22e2931cb8f22eb060c12f17ee39311b4d94729ec46cdae8c2bc2dbf4", + "glb_female": "Female/Under/gloves_guarded_leather.glb", + "glb_female_sha256": "4f7cfca859f4da855dca1d85eea37f19386e60c9d6cd4295a4540d35e9e0db5d", + "attached": true, + "garment_triangles": 940, + "female_garment_triangles": 940, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.06722, + "exposed_cm2": 473.44, + "area_cm2": 516.688, + "worst_pose": "rifle_aim@0.5" + }, + "right_forearm": { + "covered_fraction": 0.06599, + "exposed_cm2": 498.722, + "area_cm2": 511.829, + "worst_pose": "run_f@0.7" + }, + "left_hand": { + "covered_fraction": 0.87198, + "exposed_cm2": 61.538, + "area_cm2": 480.673, + "worst_pose": "meditate_loop@0.5" + }, + "right_hand": { + "covered_fraction": 0.8665, + "exposed_cm2": 64.169, + "area_cm2": 480.673, + "worst_pose": "meditate_loop@0.5" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.06565, + "exposed_cm2": 425.006, + "area_cm2": 455.29, + "worst_pose": "rifle_aim@0.5" + }, + "right_forearm": { + "covered_fraction": 0.06617, + "exposed_cm2": 436.081, + "area_cm2": 466.982, + "worst_pose": "death_f@0.6" + }, + "left_hand": { + "covered_fraction": 0.87194, + "exposed_cm2": 61.564, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.86836, + "exposed_cm2": 63.284, + "area_cm2": 480.741, + "worst_pose": "meditate_loop@0.5" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "gloves_plain_work": { + "glb": "Under/gloves_plain_work.glb", + "group": "Gloves", + "layer": "Under", + "slot": "under_hands", + "sha256": "16db1c2c83a7965a179fbebe1ccf36d7dbac02a83bc55ee41bfa0e8cc6cf89b9", + "glb_female": "Female/Under/gloves_plain_work.glb", + "glb_female_sha256": "816b28bb67f4b65cdf0727b99930902764014a96b30f655b44818fd1900d964d", + "attached": true, + "garment_triangles": 472, + "female_garment_triangles": 472, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.03672, + "exposed_cm2": 499.836, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.03809, + "exposed_cm2": 492.082, + "area_cm2": 511.829, + "worst_pose": "meditate_loop@0.5" + }, + "left_hand": { + "covered_fraction": 0.50894, + "exposed_cm2": 236.038, + "area_cm2": 480.673, + "worst_pose": "kneel_loop@0.5" + }, + "right_hand": { + "covered_fraction": 0.34142, + "exposed_cm2": 316.561, + "area_cm2": 480.673, + "worst_pose": "meditate_loop@0.5" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0374, + "exposed_cm2": 459.518, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.04462, + "exposed_cm2": 456.07, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.50891, + "exposed_cm2": 236.087, + "area_cm2": 480.741, + "worst_pose": "kneel_loop@0.5" + }, + "right_hand": { + "covered_fraction": 0.34032, + "exposed_cm2": 317.135, + "area_cm2": 480.741, + "worst_pose": "meditate_loop@0.5" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "gloves_reinforced_knit": { + "glb": "Under/gloves_reinforced_knit.glb", + "group": "Gloves", + "layer": "Under", + "slot": "under_hands", + "sha256": "bbf327d07de9d39024964404a3f03c95c18baaa02c350f1aecf82b91bc40dd38", + "glb_female": "Female/Under/gloves_reinforced_knit.glb", + "glb_female_sha256": "af10354463c35504d5155e4480346c7b0de2708912c00a55874c038041815476", + "attached": true, + "garment_triangles": 453, + "female_garment_triangles": 453, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0007, + "exposed_cm2": 518.525, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.00047, + "exposed_cm2": 518.644, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.21183, + "exposed_cm2": 378.851, + "area_cm2": 480.673, + "worst_pose": "kneel_loop@0.5" + }, + "right_hand": { + "covered_fraction": 0.24516, + "exposed_cm2": 362.832, + "area_cm2": 480.673, + "worst_pose": "kneel_loop@0.5" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.00196, + "exposed_cm2": 476.437, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.00061, + "exposed_cm2": 477.081, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.22338, + "exposed_cm2": 373.353, + "area_cm2": 480.741, + "worst_pose": "kneel_loop@0.5" + }, + "right_hand": { + "covered_fraction": 0.24986, + "exposed_cm2": 360.623, + "area_cm2": 480.741, + "worst_pose": "kneel_loop@0.5" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "gloves_light_utility": { + "glb": "Under/gloves_light_utility.glb", + "group": "Gloves", + "layer": "Under", + "slot": "under_hands", + "sha256": "1d266af174b0dcae56cfafef9a11901b4cf233496474ef6e6877d9b2eef13218", + "glb_female": "Female/Under/gloves_light_utility.glb", + "glb_female_sha256": "d8c13d15cb2ddad823f87b952c855b07151bc256fc2f36c2dd0025dff5737de1", + "attached": true, + "garment_triangles": 606, + "female_garment_triangles": 606, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.03769, + "exposed_cm2": 499.329, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.02494, + "exposed_cm2": 498.879, + "area_cm2": 511.829, + "worst_pose": "melee_turn_180@0.5" + }, + "left_hand": { + "covered_fraction": 0.87771, + "exposed_cm2": 58.78, + "area_cm2": 480.673, + "worst_pose": "kneel_loop@0.5" + }, + "right_hand": { + "covered_fraction": 0.87622, + "exposed_cm2": 59.5, + "area_cm2": 480.673, + "worst_pose": "meditate_loop@0.5" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.03955, + "exposed_cm2": 458.49, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.02692, + "exposed_cm2": 461.399, + "area_cm2": 466.982, + "worst_pose": "walk_f@0.75" + }, + "left_hand": { + "covered_fraction": 0.87764, + "exposed_cm2": 58.822, + "area_cm2": 480.741, + "worst_pose": "kneel_loop@0.5" + }, + "right_hand": { + "covered_fraction": 0.87655, + "exposed_cm2": 59.349, + "area_cm2": 480.741, + "worst_pose": "meditate_loop@0.5" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "gloves_tipped_work": { + "glb": "Under/gloves_tipped_work.glb", + "group": "Gloves", + "layer": "Under", + "slot": "under_hands", + "sha256": "042a2c177295afce9d1ae322a54bfc2567846975013840adf69bd66e61e2a803", + "glb_female": "Female/Under/gloves_tipped_work.glb", + "glb_female_sha256": "666cf7dbd00905e1143b3e49f368c55317fbb6b8fc087af70d1a167f0a3d891d", + "attached": true, + "garment_triangles": 1164, + "female_garment_triangles": 1164, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0714, + "exposed_cm2": 482.138, + "area_cm2": 516.688, + "worst_pose": "crouch_idle@0.5" + }, + "right_forearm": { + "covered_fraction": 0.0778, + "exposed_cm2": 472.008, + "area_cm2": 511.829, + "worst_pose": "death_f@0.6" + }, + "left_hand": { + "covered_fraction": 0.87222, + "exposed_cm2": 61.421, + "area_cm2": 480.673, + "worst_pose": "kneel_loop@0.5" + }, + "right_hand": { + "covered_fraction": 0.877, + "exposed_cm2": 59.124, + "area_cm2": 480.673, + "worst_pose": "meditate_loop@0.5" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.06555, + "exposed_cm2": 422.278, + "area_cm2": 455.29, + "worst_pose": "meditate_loop@0.5" + }, + "right_forearm": { + "covered_fraction": 0.07191, + "exposed_cm2": 419.406, + "area_cm2": 466.982, + "worst_pose": "meditate_loop@0.5" + }, + "left_hand": { + "covered_fraction": 0.87228, + "exposed_cm2": 61.401, + "area_cm2": 480.741, + "worst_pose": "kneel_loop@0.5" + }, + "right_hand": { + "covered_fraction": 0.87689, + "exposed_cm2": 59.183, + "area_cm2": 480.741, + "worst_pose": "meditate_loop@0.5" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "gloves_plated_handwraps": { + "glb": "Under/gloves_plated_handwraps.glb", + "group": "Gloves", + "layer": "Under", + "slot": "under_hands", + "sha256": "64cce264f147acfc00f79de986176a811898ce7c4e018b30ee0fe19751cd4fff", + "glb_female": "Female/Under/gloves_plated_handwraps.glb", + "glb_female_sha256": "b99b16c3f6366fd0212396254ef7b7759e989b0cef8203ebadb1e53837deacae", + "attached": true, + "garment_triangles": 712, + "female_garment_triangles": 712, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1182.986, + "area_cm2": 1183.674, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.02734, + "exposed_cm2": 504.701, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.03085, + "exposed_cm2": 517.485, + "area_cm2": 511.829, + "worst_pose": "run_f@0.7" + }, + "left_hand": { + "covered_fraction": 0.14143, + "exposed_cm2": 412.69, + "area_cm2": 480.673, + "worst_pose": "melee_ready@0.5" + }, + "right_hand": { + "covered_fraction": 0.14933, + "exposed_cm2": 408.897, + "area_cm2": 480.673, + "worst_pose": "run_f@0.2" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.0, + "exposed_cm2": 1053.916, + "area_cm2": 1054.218, + "worst_pose": "bind" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.02896, + "exposed_cm2": 463.546, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.03205, + "exposed_cm2": 458.97, + "area_cm2": 466.982, + "worst_pose": "walk_f@0.75" + }, + "left_hand": { + "covered_fraction": 0.14109, + "exposed_cm2": 412.915, + "area_cm2": 480.741, + "worst_pose": "melee_ready@0.5" + }, + "right_hand": { + "covered_fraction": 0.14266, + "exposed_cm2": 412.161, + "area_cm2": 480.741, + "worst_pose": "run_f@0.7" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_banded_mohawk": { + "glb": "Under/hair_banded_mohawk.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "27cdd4fe31b15360a8e69ba60748fc69c9eb3d5159af09303b4530dcb5b8e22a", + "glb_female": "Female/Under/hair_banded_mohawk.glb", + "glb_female_sha256": "3d1367502d47ed1b46d49d11bfd59a04cdf5cbaec146cc204f47b02e217783aa", + "attached": true, + "garment_triangles": 864, + "female_garment_triangles": 864, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.11735, + "exposed_cm2": 1053.857, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.13468, + "exposed_cm2": 926.961, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_spiked_topknot": { + "glb": "Under/hair_spiked_topknot.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "77665c29ba83f0e31a712c690a4f2517bc279903e068a8e6989dac1d06074ce6", + "glb_female": "Female/Under/hair_spiked_topknot.glb", + "glb_female_sha256": "68e6fb86bf1a26baa77f7ca2e7023d2c307c427c22e6d37285a1a00a21d42b5d", + "attached": true, + "garment_triangles": 1056, + "female_garment_triangles": 1056, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.15804, + "exposed_cm2": 1005.273, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.17776, + "exposed_cm2": 880.812, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_twin_pigtails": { + "glb": "Under/hair_twin_pigtails.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "e86e8877ac0af6f1e17a10426c4c6a0a83ecfaa09bafce4d6b8bbe2abd7e1266", + "glb_female": "Female/Under/hair_twin_pigtails.glb", + "glb_female_sha256": "c8c7a85139a13d60520fa5fc188b515c41211d2bdd2029acbfadab0fbeb5b05b", + "attached": true, + "garment_triangles": 1902, + "female_garment_triangles": 1902, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3821.597, + "area_cm2": 3841.793, + "worst_pose": "walk_f@0.75" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 101.77, + "area_cm2": 92.318, + "worst_pose": "crouch_idle@0.5" + }, + "head": { + "covered_fraction": 0.44876, + "exposed_cm2": 658.165, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.49165, + "exposed_cm2": 544.561, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_swept_spikes": { + "glb": "Under/hair_swept_spikes.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "18ffb36ec2782a328b0bc15bc364c93d522b7a18fd2f9cb193e107da72376b32", + "glb_female": "Female/Under/hair_swept_spikes.glb", + "glb_female_sha256": "6c914a76b9cfc2fbdfc60e6b8a971bec68cd4bcd2e3e2486f209e4dd52af4996", + "attached": true, + "garment_triangles": 1233, + "female_garment_triangles": 1233, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3976.936, + "area_cm2": 3841.793, + "worst_pose": "meditate_loop@0.5" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.982, + "area_cm2": 92.318, + "worst_pose": "run_f@0.7" + }, + "head": { + "covered_fraction": 0.21166, + "exposed_cm2": 933.526, + "area_cm2": 1183.674, + "worst_pose": "melee_turn_180@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.21473, + "exposed_cm2": 837.553, + "area_cm2": 1054.218, + "worst_pose": "crouch_idle@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_short_ponytail": { + "glb": "Under/hair_short_ponytail.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "b2ee66523c98b592b2fe260aadb4c1acbe4db233d55f3a17d9c2912c4413f2ed", + "glb_female": "Female/Under/hair_short_ponytail.glb", + "glb_female_sha256": "ff7400251e99018b37a4035ee942c1a04176fb8047bfcb432118be4034be649f", + "attached": true, + "garment_triangles": 1694, + "female_garment_triangles": 1694, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.22937, + "exposed_cm2": 920.108, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.25832, + "exposed_cm2": 794.514, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_spiked_mohawk": { + "glb": "Under/hair_spiked_mohawk.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "2e399dc712bd179d5de5de8ae95c8e25f4bf7e6ffe98d3124bac20f86799bb93", + "glb_female": "Female/Under/hair_spiked_mohawk.glb", + "glb_female_sha256": "c145f4e629ab625499e360b342a29239bf79a7c3e64a6d0e0ab723f8c0b2b075", + "attached": true, + "garment_triangles": 1428, + "female_garment_triangles": 1428, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.12576, + "exposed_cm2": 1035.242, + "area_cm2": 1183.674, + "worst_pose": "melee_turn_180@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.15471, + "exposed_cm2": 901.563, + "area_cm2": 1054.218, + "worst_pose": "crouch_idle@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_flared_wings": { + "glb": "Under/hair_flared_wings.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "6fd705c4faec1125f39aca1352780bb0c5102187db811f02d387ec7987ce5f52", + "glb_female": "Female/Under/hair_flared_wings.glb", + "glb_female_sha256": "ec897a91bcc7516cd8c1b1608d8fda725d8a6d30cc3e6355dcc9d29b53c860fd", + "attached": true, + "garment_triangles": 640, + "female_garment_triangles": 640, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.08998, + "exposed_cm2": 1083.547, + "area_cm2": 1183.674, + "worst_pose": "crouch_idle@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.10592, + "exposed_cm2": 944.551, + "area_cm2": 1054.218, + "worst_pose": "melee_turn_180@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_low_tufts": { + "glb": "Under/hair_low_tufts.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "4d0bb206001e0556edf3a06e2260621678cce7e2fccf3c68712dfceb6822a381", + "glb_female": "Female/Under/hair_low_tufts.glb", + "glb_female_sha256": "f3190e8946803d88faa3decb518b8384a1b0049fac526ccee4aec1136460ac56", + "attached": true, + "garment_triangles": 744, + "female_garment_triangles": 744, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.10716, + "exposed_cm2": 1056.9, + "area_cm2": 1183.674, + "worst_pose": "run_f@0.7" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.11723, + "exposed_cm2": 941.539, + "area_cm2": 1054.218, + "worst_pose": "crouch_idle@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_swept_bob": { + "glb": "Under/hair_swept_bob.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "f0dbe1af613a6a79309e8171bd8bc751acba3217a4fe5bffe31f247c65880e0a", + "glb_female": "Female/Under/hair_swept_bob.glb", + "glb_female_sha256": "6448df045f9fb1af9886ab1d3d15d7d636ad3338def198bb6a812e57ce12f847", + "attached": true, + "garment_triangles": 1410, + "female_garment_triangles": 1410, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.01978, + "exposed_cm2": 3707.026, + "area_cm2": 3841.793, + "worst_pose": "crouch_walk@0.3" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.00106, + "exposed_cm2": 84.351, + "area_cm2": 92.318, + "worst_pose": "melee_turn_180@0.5" + }, + "head": { + "covered_fraction": 0.33899, + "exposed_cm2": 789.233, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.01311, + "exposed_cm2": 3056.057, + "area_cm2": 3280.077, + "worst_pose": "crouch_walk@0.3" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 66.343, + "area_cm2": 65.087, + "worst_pose": "crouch_idle@0.5" + }, + "head": { + "covered_fraction": 0.36704, + "exposed_cm2": 678.049, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_braided_crown": { + "glb": "Under/hair_braided_crown.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "c5cff7249e4862ce276fb4d68f639de36bf815f65b30741f58cfa7a4c6059445", + "glb_female": "Female/Under/hair_braided_crown.glb", + "glb_female_sha256": "a391a95ae7963c35b85da8bf80641815f623b8f3cbd1c71bd9ae44ea3d76f7d8", + "attached": true, + "garment_triangles": 3052, + "female_garment_triangles": 3052, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.706, + "area_cm2": 92.318, + "worst_pose": "run_f@0.2" + }, + "head": { + "covered_fraction": 0.29579, + "exposed_cm2": 833.897, + "area_cm2": 1183.674, + "worst_pose": "melee_turn_180@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1206.883, + "area_cm2": 1151.607, + "worst_pose": "idle@0" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1206.797, + "area_cm2": 1281.261, + "worst_pose": "idle@0" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.35278, + "exposed_cm2": 683.481, + "area_cm2": 1054.218, + "worst_pose": "kneel_loop@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_ridge_mohawk": { + "glb": "Under/hair_ridge_mohawk.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "c125ceb5895d5f4d530aaea7efc8b5149e99bfdc228f4c70729531a7ebd35cee", + "glb_female": "Female/Under/hair_ridge_mohawk.glb", + "glb_female_sha256": "7713355fbaa50afbf2f9950f670b265347fd557e506e971f9e6737e88b585254", + "attached": true, + "garment_triangles": 272, + "female_garment_triangles": 272, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.11779, + "exposed_cm2": 1044.291, + "area_cm2": 1183.674, + "worst_pose": "run_f@0.2" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.13762, + "exposed_cm2": 923.812, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_side_ponytail": { + "glb": "Under/hair_side_ponytail.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "fb67f9ff6a87730b86952b24505eda736fe727af8dab5b2e760c2749abd6554c", + "glb_female": "Female/Under/hair_side_ponytail.glb", + "glb_female_sha256": "a681147bb58c4a8f7aac5c8c9fd8f6bf119025fea3d7b53f3725d71bb38eee1d", + "attached": true, + "garment_triangles": 2433, + "female_garment_triangles": 2433, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.16637, + "exposed_cm2": 987.157, + "area_cm2": 1183.674, + "worst_pose": "melee_turn_180@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.1999, + "exposed_cm2": 857.097, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_split_mohawk": { + "glb": "Under/hair_split_mohawk.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "a9c25fa690062f4997de172ba48ac0f7343335d4aca9cfd78ee5e83251fbb8df", + "glb_female": "Female/Under/hair_split_mohawk.glb", + "glb_female_sha256": "ac9a62123470bb55c67f32a6bede4c880385e46d5dc31d97af1b1961f7575b53", + "attached": true, + "garment_triangles": 1358, + "female_garment_triangles": 1358, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.43437, + "exposed_cm2": 673.491, + "area_cm2": 1183.674, + "worst_pose": "crouch_idle@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.46391, + "exposed_cm2": 574.278, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_fauxhawk": { + "glb": "Under/hair_fauxhawk.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "e031817cc6af60e25a4905591c402be73702a928e2feae14d6c281138208481e", + "glb_female": "Female/Under/hair_fauxhawk.glb", + "glb_female_sha256": "a8dad284948ef7729a9972d719960f1c1c5113dbe3c66ebded604f624f345072", + "attached": true, + "garment_triangles": 1350, + "female_garment_triangles": 1350, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.15006, + "exposed_cm2": 1014.805, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.15801, + "exposed_cm2": 901.978, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_slicked_crop": { + "glb": "Under/hair_slicked_crop.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "74f11589238a01d98426fb978cd9c94dc703cff3aadfeb77f40aba483f4f3330", + "glb_female": "Female/Under/hair_slicked_crop.glb", + "glb_female_sha256": "ce0d62c1e64befdf0abca520bd88c0a1b474b7694280b0bdc3b8f9bbee107ab6", + "attached": true, + "garment_triangles": 825, + "female_garment_triangles": 825, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.43557, + "exposed_cm2": 673.919, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.47822, + "exposed_cm2": 558.947, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_side_sweep": { + "glb": "Under/hair_side_sweep.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "70ec019b681390bb120761199d6f68d89227f5983b6df86ef3581f32f79434b2", + "glb_female": "Female/Under/hair_side_sweep.glb", + "glb_female_sha256": "1ca0ee05f3b62fc19900897266036a93418fbd23cddac825242526f5570bcb1f", + "attached": true, + "garment_triangles": 947, + "female_garment_triangles": 947, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.44902, + "exposed_cm2": 652.45, + "area_cm2": 1183.674, + "worst_pose": "melee_turn_180@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.46952, + "exposed_cm2": 568.267, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_flared_shag": { + "glb": "Under/hair_flared_shag.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "f0ead5fa46a311342a529388bb2c76e7cc60fd9a7b4967e655bd3a39fbbf0d2d", + "glb_female": "Female/Under/hair_flared_shag.glb", + "glb_female_sha256": "e62cddfcdb7b07a6fbd9e88e552640da68e29b503556c0f44b97129546d54e17", + "attached": true, + "garment_triangles": 1170, + "female_garment_triangles": 1170, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.26239, + "exposed_cm2": 873.446, + "area_cm2": 1183.674, + "worst_pose": "melee_turn_180@0.5" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.29794, + "exposed_cm2": 752.079, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_cropped_buzz": { + "glb": "Under/hair_cropped_buzz.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "23743304b951906298f41082d1ac8dce5405fc9c1ad76c51e57ce8896a6fee73", + "glb_female": "Female/Under/hair_cropped_buzz.glb", + "glb_female_sha256": "4eab51b641b8ed98cef15755622fb8cc6ebca232dc3449212d72a195f6fe39c6", + "attached": true, + "garment_triangles": 1134, + "female_garment_triangles": 1134, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.38333, + "exposed_cm2": 736.29, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.43175, + "exposed_cm2": 608.728, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_messy_crop": { + "glb": "Under/hair_messy_crop.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "6b0fb5805244ab504dae872de50f22632a8ff2db3fb494019236dec6e06e5669", + "glb_female": "Female/Under/hair_messy_crop.glb", + "glb_female_sha256": "b26ec0cd1345593933a2ffed18cc3d64320fd4638a1c7409d32c0d1e7e0fe1ca", + "attached": true, + "garment_triangles": 1088, + "female_garment_triangles": 1088, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.37319, + "exposed_cm2": 748.391, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.41072, + "exposed_cm2": 631.259, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_textured_crop": { + "glb": "Under/hair_textured_crop.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "c1171d17bdef052ae4163f04e1104aaa03b787dd5a95061a9692963e796f0c04", + "glb_female": "Female/Under/hair_textured_crop.glb", + "glb_female_sha256": "84a5b79962fecf784b99b1288145d728cd0b3a9d61af6c2f6463d2c3ad209faa", + "attached": true, + "garment_triangles": 1288, + "female_garment_triangles": 1288, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.4016, + "exposed_cm2": 714.478, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.45399, + "exposed_cm2": 584.907, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_shoulder_shag": { + "glb": "Under/hair_shoulder_shag.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "1b2dd243ea74319a89e2f7ab5c9164a817eba82406ba63d4de1001bd377ff768", + "glb_female": "Female/Under/hair_shoulder_shag.glb", + "glb_female_sha256": "3c23cab55b9d38cdc741666a58880443a707fbf123df7ae5dae4828d32822975", + "attached": true, + "garment_triangles": 1266, + "female_garment_triangles": 1266, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.00052, + "exposed_cm2": 3860.597, + "area_cm2": 3841.793, + "worst_pose": "rifle_aim@0.5" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 110.996, + "area_cm2": 92.318, + "worst_pose": "crouch_walk@0.3" + }, + "head": { + "covered_fraction": 0.52295, + "exposed_cm2": 569.582, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.56053, + "exposed_cm2": 470.775, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_messy_side_part": { + "glb": "Under/hair_messy_side_part.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "5851f781fdd308a35db26f734eaf76e858c5ef705123101e709a90717bff3b06", + "glb_female": "Female/Under/hair_messy_side_part.glb", + "glb_female_sha256": "250408e83f0721cdcef46d7965977b6aeac7fed2835c3d34bfa0926d7fa6741d", + "attached": true, + "garment_triangles": 1346, + "female_garment_triangles": 1346, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.26697, + "exposed_cm2": 875.213, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.30085, + "exposed_cm2": 748.957, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_flat_top": { + "glb": "Under/hair_flat_top.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "ff7599cd17bcb2cb903ce3703b37ffaa62f1a8099cb052d267dff481904d1ed5", + "glb_female": "Female/Under/hair_flat_top.glb", + "glb_female_sha256": "d4c8434f7d48344ed4dc27bfe0b11734e545ab4b670ff1ad54246ae021d72bd9", + "attached": true, + "garment_triangles": 1276, + "female_garment_triangles": 1276, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.27779, + "exposed_cm2": 862.302, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.31747, + "exposed_cm2": 731.155, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + }, + "hair_top_patch": { + "glb": "Under/hair_top_patch.glb", + "group": "Hair", + "layer": "Under", + "slot": "cranium", + "sha256": "1c92bc6ab7eeb0315368b21a9865c9cf77d9f42c44b08288d54a0795fa8d95dc", + "glb_female": "Female/Under/hair_top_patch.glb", + "glb_female_sha256": "a0127d217e942c86ec597e0012a4430099a61ca5ea48741f4e58961cd7ef0aa1", + "attached": true, + "garment_triangles": 306, + "female_garment_triangles": 306, + "measurement": { + "male": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3855.826, + "area_cm2": 3841.793, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1609.484, + "area_cm2": 1526.228, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 87.306, + "area_cm2": 92.318, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.11381, + "exposed_cm2": 1058.09, + "area_cm2": 1183.674, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1151.607, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 1212.401, + "area_cm2": 1281.261, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 516.688, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 518.889, + "area_cm2": 511.829, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.673, + "area_cm2": 480.673, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1648.397, + "area_cm2": 1729.671, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1676.093, + "area_cm2": 1716.231, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1238.169, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1293.324, + "area_cm2": 1268.488, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.925, + "area_cm2": 720.719, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 742.924, + "area_cm2": 721.805, + "worst_pose": "bind" + } + }, + "female": { + "torso": { + "covered_fraction": 0.0, + "exposed_cm2": 3190.455, + "area_cm2": 3280.077, + "worst_pose": "bind" + }, + "pelvis": { + "covered_fraction": 0.0, + "exposed_cm2": 1876.243, + "area_cm2": 1715.907, + "worst_pose": "bind" + }, + "neck": { + "covered_fraction": 0.0, + "exposed_cm2": 63.203, + "area_cm2": 65.087, + "worst_pose": "bind" + }, + "head": { + "covered_fraction": 0.12692, + "exposed_cm2": 935.281, + "area_cm2": 1054.218, + "worst_pose": "crouch_walk@0.3" + }, + "left_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 843.36, + "worst_pose": "bind" + }, + "right_upper_arm": { + "covered_fraction": 0.0, + "exposed_cm2": 981.422, + "area_cm2": 982.65, + "worst_pose": "bind" + }, + "left_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 455.29, + "worst_pose": "bind" + }, + "right_forearm": { + "covered_fraction": 0.0, + "exposed_cm2": 477.372, + "area_cm2": 466.982, + "worst_pose": "bind" + }, + "left_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "right_hand": { + "covered_fraction": 0.0, + "exposed_cm2": 480.741, + "area_cm2": 480.741, + "worst_pose": "bind" + }, + "left_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1616.386, + "worst_pose": "bind" + }, + "right_thigh": { + "covered_fraction": 0.0, + "exposed_cm2": 1580.901, + "area_cm2": 1603.885, + "worst_pose": "bind" + }, + "left_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 992.048, + "worst_pose": "bind" + }, + "right_calf": { + "covered_fraction": 0.0, + "exposed_cm2": 1050.905, + "area_cm2": 1022.802, + "worst_pose": "bind" + }, + "left_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 572.023, + "worst_pose": "bind" + }, + "right_foot": { + "covered_fraction": 0.0, + "exposed_cm2": 603.509, + "area_cm2": 574.742, + "worst_pose": "bind" + } + } + }, + "hide_body_zones": [] + } + }, + "manifest": { + "path": "client-3d/public/assets/pawn-pack/equipment/manifest.json", + "sha256": "459e3a566ba60c9de993ab4ac42fc233f89b38ee6ef7382381957c07b5c9c156", + "managed_items": [ + "armor_harness", + "armor_nape_reinforcement", + "armor_reinforcement", + "armor_gorget", + "armor_bicep_l", + "armor_bicep_r", + "helmet_a", + "helmet_b", + "helmet_c", + "helmet_d", + "helmet_v1port", + "helmet_s1", + "helmet_s2", + "helmet_s3", + "hat_warm", + "under_tank", + "under_shorts", + "hair_mop", + "hair_afro2", + "hair_crop2", + "under_bodysuit", + "trial_helm_a", + "trial_helm_b", + "trial_helm_c", + "trial_helm_d", + "trial_hood_a", + "trial_visor_a", + "trial_mask_b", + "trial_back_a", + "trial_back_b", + "trial_hip_front_a", + "trial_hip_back_a", + "trial_hip_side_a", + "trial_hair_a", + "trial_hair_b", + "trial_hair_c", + "trial_hair_d", + "under_scfi_top", + "under_scfi_pants", + "top_rigged_tank", + "top_frayed_tunic", + "top_plated_rig_vest", + "top_padded_leather_vest", + "top_spiked_leather_vest", + "top_reinforced_crop_vest", + "top_scrap_plate_tunic", + "top_laced_corset_vest", + "legs_wrapped_workpants", + "legs_reinforced_denim_pants", + "legs_plated_trousers", + "legs_layered_shorts", + "legs_strapped_trousers", + "legs_skirted_workpants", + "legs_gaitered_cargo_pants", + "legs_padded_canvas_trousers", + "legs_sashed_patrol_pants", + "legs_layered_wrap_skirt", + "boots_canvas_ankle", + "boots_layered_sneakers", + "boots_split_toe", + "boots_trail_shoes", + "boots_double_strap", + "boots_rubberized_canvas", + "boots_toe_capped", + "boots_combat_sneakers", + "boots_cuffed_runners", + "boots_reinforced_street", + "gloves_knuckled_half", + "gloves_guarded_leather", + "gloves_plain_work", + "gloves_reinforced_knit", + "gloves_light_utility", + "gloves_tipped_work", + "gloves_plated_handwraps", + "hair_banded_mohawk", + "hair_spiked_topknot", + "hair_twin_pigtails", + "hair_swept_spikes", + "hair_short_ponytail", + "hair_spiked_mohawk", + "hair_flared_wings", + "hair_low_tufts", + "hair_swept_bob", + "hair_braided_crown", + "hair_ridge_mohawk", + "hair_side_ponytail", + "hair_split_mohawk", + "hair_fauxhawk", + "hair_slicked_crop", + "hair_side_sweep", + "hair_flared_shag", + "hair_cropped_buzz", + "hair_messy_crop", + "hair_textured_crop", + "hair_shoulder_shag", + "hair_messy_side_part", + "hair_flat_top", + "hair_top_patch" + ], + "items_with_coverage": 2, + "items_with_female_variant": 98 + } +} diff --git a/content-pipeline/labs/humanoid-runtime-refit/reports/body_zone_verification.json b/content-pipeline/labs/humanoid-runtime-refit/reports/body_zone_verification.json new file mode 100644 index 00000000..85a06cb4 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/reports/body_zone_verification.json @@ -0,0 +1,115 @@ +{ + "generator": "successor content-pipeline/labs/humanoid-runtime-refit/verify_body_zones.py", + "checks": { + "vocabulary": { + "typescript": [ + "torso", + "pelvis", + "neck", + "head", + "left_upper_arm", + "right_upper_arm", + "left_forearm", + "right_forearm", + "left_hand", + "right_hand", + "left_thigh", + "right_thigh", + "left_calf", + "right_calf", + "left_foot", + "right_foot" + ], + "lab": [ + "torso", + "pelvis", + "neck", + "head", + "left_upper_arm", + "right_upper_arm", + "left_forearm", + "right_forearm", + "left_hand", + "right_hand", + "left_thigh", + "right_thigh", + "left_calf", + "right_calf", + "left_foot", + "right_foot" + ], + "match": true + }, + "rust_vocabulary": { + "missing": [] + }, + "pawn_male_bare.glb": { + "matches_male": true + }, + "manifest": { + "items": 99, + "with_hide_body_zones": 2, + "with_glb_female": 98, + "unknown_zones": [], + "non_canonical_order": [], + "missing_female_files": [] + } + }, + "bodies": { + "male": { + "path": "client-3d/public/assets/pawn-pack/pawn_male.glb", + "sha256": "a865e80f3fe740c135633c2c03b94afcfb0d10def3756cb7ca43fec5e51e7f9c", + "materials_exact": true, + "joints_match_shell": true, + "clips_match_shell": true, + "inverse_bind_max_delta": 0.0, + "index_accessor_types": [ + "SCALAR" + ], + "skin_material_variants": 1, + "welded_points_match_hybrid_sources": true, + "canonical_hands_exact": { + "left_hand": true, + "right_hand": true + }, + "source_geometry_triangles": 5818, + "expected_hybrid_triangles": 5818, + "refined_source_triangles": 5818, + "triangles": 5874, + "face_overlay_count": 1, + "face_overlay_alpha_blend": true, + "face_overlay_omits_vertex_color": true, + "face_overlay_inflate_max_delta_m": 5.922466517205294e-08, + "stature_m": 1.7525, + "expected_stature_m": 1.7525 + }, + "female": { + "path": "client-3d/public/assets/pawn-pack/pawn_female.glb", + "sha256": "9eb7242ce49fcd7d7afa53a898c35e5b7b5fd0d5d99d7109bfc82e33e4f095e9", + "materials_exact": true, + "joints_match_shell": true, + "clips_match_shell": true, + "inverse_bind_max_delta": 0.0, + "index_accessor_types": [ + "SCALAR" + ], + "skin_material_variants": 1, + "welded_points_match_hybrid_sources": true, + "canonical_hands_exact": { + "left_hand": true, + "right_hand": true + }, + "source_geometry_triangles": 5882, + "expected_hybrid_triangles": 5882, + "refined_source_triangles": 5882, + "triangles": 5938, + "face_overlay_count": 1, + "face_overlay_alpha_blend": true, + "face_overlay_omits_vertex_color": true, + "face_overlay_inflate_max_delta_m": 5.948543546629992e-08, + "stature_m": 1.7525, + "expected_stature_m": 1.7525 + } + }, + "failures": [] +} diff --git a/content-pipeline/labs/humanoid-runtime-refit/reports/coverage_triage.json b/content-pipeline/labs/humanoid-runtime-refit/reports/coverage_triage.json new file mode 100644 index 00000000..90cb119d --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/reports/coverage_triage.json @@ -0,0 +1,636 @@ +{ + "generator": "successor content-pipeline/labs/humanoid-runtime-refit/triage_coverage.py", + "near_miss_floor": 0.9, + "counts": { + "masked": 2, + "unattached": 1, + "exposes_by_design": 94, + "near_miss": 2 + }, + "masked": [ + { + "id": "under_bodysuit", + "zones": [ + "torso", + "pelvis", + "left_upper_arm", + "right_upper_arm", + "left_forearm", + "right_forearm", + "left_thigh", + "right_thigh", + "left_calf", + "right_calf" + ] + }, + { + "id": "boots_canvas_ankle", + "zones": [ + "left_foot", + "right_foot" + ] + } + ], + "unattached": [ + { + "id": "hat_field_cap", + "reason": "no skinned mesh and no rigidAnchorBone: the runtime attaches nothing, so it encloses no skin" + } + ], + "exposes_by_design": [ + { + "id": "armor_harness", + "group": "Torso", + "layer": "Armor", + "best_zone_coverage": 0.41646 + }, + { + "id": "armor_nape_reinforcement", + "group": "Torso", + "layer": "Armor", + "best_zone_coverage": 0.01855 + }, + { + "id": "armor_reinforcement", + "group": "Torso", + "layer": "Armor", + "best_zone_coverage": 0.07284 + }, + { + "id": "armor_gorget", + "group": "Torso", + "layer": "Armor", + "best_zone_coverage": 0.0 + }, + { + "id": "armor_bicep_l", + "group": "Bicep_L", + "layer": "Armor", + "best_zone_coverage": 0.36363 + }, + { + "id": "armor_bicep_r", + "group": "Bicep_R", + "layer": "Armor", + "best_zone_coverage": 0.42089 + }, + { + "id": "helmet_a", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "best_zone_coverage": 0.57856 + }, + { + "id": "helmet_b", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "best_zone_coverage": 0.39884 + }, + { + "id": "helmet_c", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "best_zone_coverage": 0.60606 + }, + { + "id": "helmet_d", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "best_zone_coverage": 0.52923 + }, + { + "id": "helmet_v1port", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "best_zone_coverage": 0.39119 + }, + { + "id": "helmet_s1", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "best_zone_coverage": 0.50511 + }, + { + "id": "helmet_s2", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "best_zone_coverage": 0.59435 + }, + { + "id": "helmet_s3", + "group": "Helmet \u2014 bake-off", + "layer": "Armor", + "best_zone_coverage": 0.48028 + }, + { + "id": "hat_warm", + "group": "Headwear \u2014 baseline", + "layer": "Under", + "best_zone_coverage": 0.21586 + }, + { + "id": "under_tank", + "group": "Torso", + "layer": "Under", + "best_zone_coverage": 0.85401 + }, + { + "id": "under_shorts", + "group": "Legs", + "layer": "Under", + "best_zone_coverage": 0.8086 + }, + { + "id": "hair_mop", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.67816 + }, + { + "id": "hair_afro2", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.15808 + }, + { + "id": "hair_crop2", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.6246 + }, + { + "id": "trial_helm_a", + "group": "SYNTY TRIALS", + "layer": "Armor", + "best_zone_coverage": 0.89747 + }, + { + "id": "trial_helm_b", + "group": "SYNTY TRIALS", + "layer": "Armor", + "best_zone_coverage": 0.79243 + }, + { + "id": "trial_helm_c", + "group": "SYNTY TRIALS", + "layer": "Armor", + "best_zone_coverage": 0.45382 + }, + { + "id": "trial_helm_d", + "group": "SYNTY TRIALS", + "layer": "Armor", + "best_zone_coverage": 0.37646 + }, + { + "id": "trial_hood_a", + "group": "SYNTY TRIALS", + "layer": "Armor", + "best_zone_coverage": 0.33601 + }, + { + "id": "trial_visor_a", + "group": "SYNTY TRIALS", + "layer": "Armor", + "best_zone_coverage": 0.12845 + }, + { + "id": "trial_mask_b", + "group": "SYNTY TRIALS", + "layer": "Armor", + "best_zone_coverage": 0.0907 + }, + { + "id": "trial_back_a", + "group": "SYNTY TRIALS", + "layer": "Armor", + "best_zone_coverage": 0.01403 + }, + { + "id": "trial_back_b", + "group": "SYNTY TRIALS", + "layer": "Armor", + "best_zone_coverage": 0.02594 + }, + { + "id": "trial_hip_front_a", + "group": "SYNTY TRIALS", + "layer": "Armor", + "best_zone_coverage": 0.0 + }, + { + "id": "trial_hip_back_a", + "group": "SYNTY TRIALS", + "layer": "Armor", + "best_zone_coverage": 0.00819 + }, + { + "id": "trial_hip_side_a", + "group": "SYNTY TRIALS", + "layer": "Armor", + "best_zone_coverage": 0.01099 + }, + { + "id": "trial_hair_a", + "group": "SYNTY TRIALS", + "layer": "Armor", + "best_zone_coverage": 0.4258 + }, + { + "id": "trial_hair_b", + "group": "SYNTY TRIALS", + "layer": "Armor", + "best_zone_coverage": 0.22165 + }, + { + "id": "trial_hair_c", + "group": "SYNTY TRIALS", + "layer": "Armor", + "best_zone_coverage": 0.41925 + }, + { + "id": "trial_hair_d", + "group": "SYNTY TRIALS", + "layer": "Armor", + "best_zone_coverage": 0.25512 + }, + { + "id": "under_scfi_top", + "group": "SYNTY TRIALS", + "layer": "Under", + "best_zone_coverage": 0.79847 + }, + { + "id": "under_scfi_pants", + "group": "SYNTY TRIALS", + "layer": "Under", + "best_zone_coverage": 0.89167 + }, + { + "id": "top_rigged_tank", + "group": "Tops", + "layer": "Under", + "best_zone_coverage": 0.43507 + }, + { + "id": "top_frayed_tunic", + "group": "Tops", + "layer": "Under", + "best_zone_coverage": 0.3815 + }, + { + "id": "top_plated_rig_vest", + "group": "Tops", + "layer": "Under", + "best_zone_coverage": 0.14574 + }, + { + "id": "top_padded_leather_vest", + "group": "Tops", + "layer": "Under", + "best_zone_coverage": 0.18073 + }, + { + "id": "top_spiked_leather_vest", + "group": "Tops", + "layer": "Under", + "best_zone_coverage": 0.34784 + }, + { + "id": "top_reinforced_crop_vest", + "group": "Tops", + "layer": "Under", + "best_zone_coverage": 0.19133 + }, + { + "id": "top_scrap_plate_tunic", + "group": "Tops", + "layer": "Under", + "best_zone_coverage": 0.18624 + }, + { + "id": "top_laced_corset_vest", + "group": "Tops", + "layer": "Under", + "best_zone_coverage": 0.24031 + }, + { + "id": "legs_wrapped_workpants", + "group": "Bottoms", + "layer": "Under", + "best_zone_coverage": 0.87083 + }, + { + "id": "legs_reinforced_denim_pants", + "group": "Bottoms", + "layer": "Under", + "best_zone_coverage": 0.88623 + }, + { + "id": "legs_layered_shorts", + "group": "Bottoms", + "layer": "Under", + "best_zone_coverage": 0.89739 + }, + { + "id": "legs_strapped_trousers", + "group": "Bottoms", + "layer": "Under", + "best_zone_coverage": 0.84021 + }, + { + "id": "legs_skirted_workpants", + "group": "Bottoms", + "layer": "Under", + "best_zone_coverage": 0.81915 + }, + { + "id": "legs_gaitered_cargo_pants", + "group": "Bottoms", + "layer": "Under", + "best_zone_coverage": 0.871 + }, + { + "id": "legs_padded_canvas_trousers", + "group": "Bottoms", + "layer": "Under", + "best_zone_coverage": 0.88475 + }, + { + "id": "legs_layered_wrap_skirt", + "group": "Bottoms", + "layer": "Under", + "best_zone_coverage": 0.86605 + }, + { + "id": "boots_layered_sneakers", + "group": "Footwear", + "layer": "Under", + "best_zone_coverage": 0.65058 + }, + { + "id": "boots_split_toe", + "group": "Footwear", + "layer": "Under", + "best_zone_coverage": 0.6806 + }, + { + "id": "boots_trail_shoes", + "group": "Footwear", + "layer": "Under", + "best_zone_coverage": 0.61209 + }, + { + "id": "boots_double_strap", + "group": "Footwear", + "layer": "Under", + "best_zone_coverage": 0.72363 + }, + { + "id": "boots_rubberized_canvas", + "group": "Footwear", + "layer": "Under", + "best_zone_coverage": 0.6835 + }, + { + "id": "boots_toe_capped", + "group": "Footwear", + "layer": "Under", + "best_zone_coverage": 0.62431 + }, + { + "id": "boots_combat_sneakers", + "group": "Footwear", + "layer": "Under", + "best_zone_coverage": 0.66287 + }, + { + "id": "boots_cuffed_runners", + "group": "Footwear", + "layer": "Under", + "best_zone_coverage": 0.69841 + }, + { + "id": "boots_reinforced_street", + "group": "Footwear", + "layer": "Under", + "best_zone_coverage": 0.78495 + }, + { + "id": "gloves_knuckled_half", + "group": "Gloves", + "layer": "Under", + "best_zone_coverage": 0.321 + }, + { + "id": "gloves_guarded_leather", + "group": "Gloves", + "layer": "Under", + "best_zone_coverage": 0.81513 + }, + { + "id": "gloves_plain_work", + "group": "Gloves", + "layer": "Under", + "best_zone_coverage": 0.44319 + }, + { + "id": "gloves_reinforced_knit", + "group": "Gloves", + "layer": "Under", + "best_zone_coverage": 0.24727 + }, + { + "id": "gloves_light_utility", + "group": "Gloves", + "layer": "Under", + "best_zone_coverage": 0.81759 + }, + { + "id": "gloves_tipped_work", + "group": "Gloves", + "layer": "Under", + "best_zone_coverage": 0.83141 + }, + { + "id": "gloves_plated_handwraps", + "group": "Gloves", + "layer": "Under", + "best_zone_coverage": 0.232 + }, + { + "id": "hair_banded_mohawk", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.1592 + }, + { + "id": "hair_spiked_topknot", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.16837 + }, + { + "id": "hair_twin_pigtails", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.6193 + }, + { + "id": "hair_swept_spikes", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.25049 + }, + { + "id": "hair_short_ponytail", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.29356 + }, + { + "id": "hair_spiked_mohawk", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.18162 + }, + { + "id": "hair_flared_wings", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.15128 + }, + { + "id": "hair_low_tufts", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.1993 + }, + { + "id": "hair_swept_bob", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.456 + }, + { + "id": "hair_braided_crown", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.4629 + }, + { + "id": "hair_ridge_mohawk", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.15063 + }, + { + "id": "hair_side_ponytail", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.23273 + }, + { + "id": "hair_split_mohawk", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.57844 + }, + { + "id": "hair_fauxhawk", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.18325 + }, + { + "id": "hair_slicked_crop", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.61126 + }, + { + "id": "hair_side_sweep", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.60785 + }, + { + "id": "hair_flared_shag", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.33632 + }, + { + "id": "hair_cropped_buzz", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.55135 + }, + { + "id": "hair_messy_crop", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.51945 + }, + { + "id": "hair_textured_crop", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.56255 + }, + { + "id": "hair_shoulder_shag", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.69978 + }, + { + "id": "hair_messy_side_part", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.34989 + }, + { + "id": "hair_flat_top", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.43673 + }, + { + "id": "hair_top_patch", + "group": "Hair", + "layer": "Under", + "best_zone_coverage": 0.1463 + } + ], + "near_miss": [ + { + "id": "legs_plated_trousers", + "group": "Bottoms", + "layer": "Under", + "near_zones": { + "right_calf": { + "female": [ + 0.91543, + 86.011, + "kneel_loop@0.5" + ] + } + } + }, + { + "id": "legs_sashed_patrol_pants", + "group": "Bottoms", + "layer": "Under", + "near_zones": { + "left_calf": { + "female": [ + 0.91606, + 85.092, + "kneel_loop@0.5" + ] + } + } + } + ] +} diff --git a/content-pipeline/labs/humanoid-runtime-refit/reports/female_posterior.json b/content-pipeline/labs/humanoid-runtime-refit/reports/female_posterior.json new file mode 100644 index 00000000..2e9e4fc2 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/reports/female_posterior.json @@ -0,0 +1,217 @@ +{ + "generator": "successor content-pipeline/labs/humanoid-runtime-refit/reduce_female_posterior.py", + "source": "content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v3_anim.glb", + "source_sha256": "8c1aefa701b6f6560cd9cb87f979c25a3bb4227fd9da6cde9f29f464f5da1650", + "output": "content-pipeline/labs/humanoid-runtime-refit/build/female_posterior/rbf_v3_reduced.glb", + "output_sha256": "a2d270b11ce9e22b3813dd96cbf45739496af50948e7f32afbd8f6761a2eaab7", + "bytes": 2622236, + "gates": { + "peak_m": 0.015, + "height_in": [ + 0.845, + 0.9 + ], + "height_out": [ + 1.0, + 1.06 + ], + "lateral_hold_m": 0.075, + "lateral_out_m": 0.115, + "depth_in": [ + -0.045, + -0.07 + ] + }, + "metrics": { + "glute_band_y_m": [ + 0.86, + 1.02 + ], + "rear_projection_before_mm": -93.767, + "rear_projection_after_mm": -78.767, + "reduction_mm": 15.0, + "audit_bands_mm": [ + { + "y_m": [ + 0.85, + 0.95 + ], + "before": -89.94, + "after": -77.94, + "reduction": 12.0 + }, + { + "y_m": [ + 0.95, + 1.05 + ], + "before": -93.767, + "after": -78.767, + "reduction": 15.0 + } + ], + "vertices_moved": 31, + "vertices_total": 1219, + "max_move_mm": 15.0, + "mean_move_moved_mm": 4.854, + "height_span_moved_m": [ + 0.8549, + 1.0524 + ], + "abs_x_span_moved_m": [ + 0.0, + 0.1121 + ], + "stature_delta_mm": 0.0, + "hip_width_delta_mm": 0.0, + "front_silhouette_delta_mm": 0.0 + }, + "rear_profile_before_mm": [ + [ + 0.73, + -57.07 + ], + [ + 0.74, + -52.23 + ], + [ + 0.85, + -53.75 + ], + [ + 0.86, + -69.31 + ], + [ + 0.87, + -65.38 + ], + [ + 0.88, + -57.85 + ], + [ + 0.9, + -89.94 + ], + [ + 0.92, + -15.67 + ], + [ + 0.95, + -66.27 + ], + [ + 0.96, + -92.93 + ], + [ + 0.97, + -93.77 + ], + [ + 0.99, + -15.01 + ], + [ + 1.0, + -78.87 + ], + [ + 1.05, + -57.39 + ], + [ + 1.09, + -34.32 + ], + [ + 1.1, + -27.83 + ], + [ + 1.12, + -35.44 + ], + [ + 1.13, + -37.45 + ] + ], + "rear_profile_after_mm": [ + [ + 0.73, + -57.07 + ], + [ + 0.74, + -52.23 + ], + [ + 0.85, + -53.63 + ], + [ + 0.86, + -66.19 + ], + [ + 0.87, + -61.92 + ], + [ + 0.88, + -54.85 + ], + [ + 0.9, + -77.94 + ], + [ + 0.92, + -15.67 + ], + [ + 0.95, + -66.27 + ], + [ + 0.96, + -77.93 + ], + [ + 0.97, + -78.77 + ], + [ + 0.99, + -15.01 + ], + [ + 1.0, + -63.88 + ], + [ + 1.05, + -57.16 + ], + [ + 1.09, + -34.32 + ], + [ + 1.1, + -27.83 + ], + [ + 1.12, + -35.44 + ], + [ + 1.13, + -37.45 + ] + ] +} \ No newline at end of file diff --git a/content-pipeline/labs/humanoid-runtime-refit/reports/female_source_comparison.json b/content-pipeline/labs/humanoid-runtime-refit/reports/female_source_comparison.json new file mode 100644 index 00000000..420bf828 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/reports/female_source_comparison.json @@ -0,0 +1,551 @@ +{ + "generator": "successor content-pipeline/labs/humanoid-runtime-refit/compare_female_sources.py", + "bands_y_m": [ + [ + 0.85, + 0.95 + ], + [ + 0.95, + 1.05 + ], + [ + 0.86, + 1.02 + ], + [ + 0.78, + 0.86 + ], + [ + 1.02, + 1.12 + ] + ], + "note": "face is +z, so rear projection is min z; more negative is deeper", + "bodies": { + "branch_base_shipped_female": { + "path": "content-pipeline/labs/humanoid-runtime-refit/build/reference/female.glb", + "sha256": "16d8c879aabc58b5ee176edcfc0b0dc43fc695e7d59f4a2ef6d6a15890e7d000", + "stature_m": 1.7626, + "hip_half_width_m": 0.992, + "primitive_vertices": { + "skin": 2752 + }, + "rear_mm": { + "y0.85-0.95": -67.005, + "y0.95-1.05": -62.547, + "y0.86-1.02": -67.005, + "y0.78-0.86": -69.506, + "y1.02-1.12": -62.547 + }, + "rear_profile_mm": [ + [ + 0.76, + -80.0 + ], + [ + 0.8, + -42.97 + ], + [ + 0.82, + -69.51 + ], + [ + 0.83, + -64.28 + ], + [ + 0.84, + -30.5 + ], + [ + 0.9, + -67.0 + ], + [ + 1.02, + -62.55 + ] + ] + }, + "humanoid_lab_female_latest": { + "path": "content-pipeline/labs/humanoid-runtime-refit/build/scratch/pawn_female_latest.glb", + "sha256": "bd99fdb32fc40291780d92b1494f07a428c439204b306a2a6d32d2bc403c0c8b", + "stature_m": 1.7525, + "hip_half_width_m": 0.992, + "primitive_vertices": { + "hum_f_body": 790, + "hum_f_head": 12396, + "RB_Face": 168 + }, + "rear_mm": { + "y0.85-0.95": -77.6, + "y0.95-1.05": -89.281, + "y0.86-1.02": -89.281, + "y0.78-0.86": -68.88, + "y1.02-1.12": -57.389 + }, + "rear_profile_mm": [ + [ + 0.73, + -56.73 + ], + [ + 0.74, + -48.78 + ], + [ + 0.85, + -68.88 + ], + [ + 0.86, + -72.26 + ], + [ + 0.87, + -63.59 + ], + [ + 0.88, + -51.46 + ], + [ + 0.9, + -77.6 + ], + [ + 0.92, + -13.39 + ], + [ + 0.95, + -55.15 + ], + [ + 0.96, + -82.71 + ], + [ + 0.97, + -89.28 + ], + [ + 0.99, + -15.79 + ], + [ + 1.0, + -80.64 + ], + [ + 1.05, + -57.39 + ], + [ + 1.09, + -34.32 + ], + [ + 1.1, + -27.83 + ], + [ + 1.12, + -35.44 + ], + [ + 1.13, + -37.45 + ] + ] + }, + "rbf_v2_source": { + "path": "content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v2_anim.glb", + "sha256": "deb4ee8cbacebfee13e3e57cae3f6b7e44aff0ce39bed582fb83f6adcd6f4799", + "stature_m": 1.7525, + "hip_half_width_m": 0.9834, + "primitive_vertices": { + "hum_f_body": 840, + "hum_f_head": 309, + "RB_Face": 70 + }, + "rear_mm": { + "y0.85-0.95": -89.94, + "y0.95-1.05": -93.767, + "y0.86-1.02": -93.767, + "y0.78-0.86": -53.755, + "y1.02-1.12": -57.389 + }, + "rear_profile_mm": [ + [ + 0.73, + -57.07 + ], + [ + 0.74, + -52.23 + ], + [ + 0.85, + -53.75 + ], + [ + 0.86, + -69.31 + ], + [ + 0.87, + -65.38 + ], + [ + 0.88, + -57.85 + ], + [ + 0.9, + -89.94 + ], + [ + 0.92, + -15.67 + ], + [ + 0.95, + -66.27 + ], + [ + 0.96, + -92.93 + ], + [ + 0.97, + -93.77 + ], + [ + 0.99, + -15.01 + ], + [ + 1.0, + -78.87 + ], + [ + 1.05, + -57.39 + ], + [ + 1.09, + -34.32 + ], + [ + 1.1, + -27.83 + ], + [ + 1.12, + -35.44 + ], + [ + 1.13, + -37.45 + ] + ] + }, + "rbf_v3_source": { + "path": "content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v3_anim.glb", + "sha256": "8c1aefa701b6f6560cd9cb87f979c25a3bb4227fd9da6cde9f29f464f5da1650", + "stature_m": 1.7525, + "hip_half_width_m": 0.9834, + "primitive_vertices": { + "hum_f_body": 838, + "hum_f_head": 309, + "RB_Face": 72 + }, + "rear_mm": { + "y0.85-0.95": -89.94, + "y0.95-1.05": -93.767, + "y0.86-1.02": -93.767, + "y0.78-0.86": -53.755, + "y1.02-1.12": -57.389 + }, + "rear_profile_mm": [ + [ + 0.73, + -57.07 + ], + [ + 0.74, + -52.23 + ], + [ + 0.85, + -53.75 + ], + [ + 0.86, + -69.31 + ], + [ + 0.87, + -65.38 + ], + [ + 0.88, + -57.85 + ], + [ + 0.9, + -89.94 + ], + [ + 0.92, + -15.67 + ], + [ + 0.95, + -66.27 + ], + [ + 0.96, + -92.93 + ], + [ + 0.97, + -93.77 + ], + [ + 0.99, + -15.01 + ], + [ + 1.0, + -78.87 + ], + [ + 1.05, + -57.39 + ], + [ + 1.09, + -34.32 + ], + [ + 1.1, + -27.83 + ], + [ + 1.12, + -35.44 + ], + [ + 1.13, + -37.45 + ] + ] + }, + "rbf_v3_reduced": { + "path": "content-pipeline/labs/humanoid-runtime-refit/build/female_posterior/rbf_v3_reduced.glb", + "sha256": "a2d270b11ce9e22b3813dd96cbf45739496af50948e7f32afbd8f6761a2eaab7", + "stature_m": 1.7525, + "hip_half_width_m": 0.9834, + "primitive_vertices": { + "hum_f_body": 838, + "hum_f_head": 309, + "RB_Face": 72 + }, + "rear_mm": { + "y0.85-0.95": -77.94, + "y0.95-1.05": -78.767, + "y0.86-1.02": -78.767, + "y0.78-0.86": -53.629, + "y1.02-1.12": -57.159 + }, + "rear_profile_mm": [ + [ + 0.73, + -57.07 + ], + [ + 0.74, + -52.23 + ], + [ + 0.85, + -53.63 + ], + [ + 0.86, + -66.19 + ], + [ + 0.87, + -61.92 + ], + [ + 0.88, + -54.85 + ], + [ + 0.9, + -77.94 + ], + [ + 0.92, + -15.67 + ], + [ + 0.95, + -66.27 + ], + [ + 0.96, + -77.93 + ], + [ + 0.97, + -78.77 + ], + [ + 0.99, + -15.01 + ], + [ + 1.0, + -63.88 + ], + [ + 1.05, + -57.16 + ], + [ + 1.09, + -34.32 + ], + [ + 1.1, + -27.83 + ], + [ + 1.12, + -35.44 + ], + [ + 1.13, + -37.45 + ] + ] + }, + "rb_v4_male_source": { + "path": "content-pipeline/labs/humanoid-runtime-refit/source/male/rb_v4_anim.glb", + "sha256": "dd6d99baa5de4cbe585fc7075eb9e5bb3cc0006d809ca2655095a533a43f8be5", + "stature_m": 1.7525, + "hip_half_width_m": 0.9851, + "primitive_vertices": { + "hum_m_body": 797, + "hum_m_head": 257, + "RB_Face": 45 + }, + "rear_mm": { + "y0.85-0.95": -96.595, + "y0.95-1.05": -83.396, + "y0.86-1.02": -96.595, + "y0.78-0.86": -69.692, + "y1.02-1.12": -54.923 + }, + "rear_profile_mm": [ + [ + 0.72, + -66.57 + ], + [ + 0.73, + -34.13 + ], + [ + 0.79, + -69.16 + ], + [ + 0.8, + -69.69 + ], + [ + 0.84, + -68.71 + ], + [ + 0.85, + -65.26 + ], + [ + 0.87, + -86.1 + ], + [ + 0.88, + -57.41 + ], + [ + 0.89, + -21.78 + ], + [ + 0.91, + -96.59 + ], + [ + 0.94, + -18.64 + ], + [ + 0.97, + -71.05 + ], + [ + 0.98, + -83.4 + ], + [ + 0.99, + -12.53 + ], + [ + 1.04, + -41.24 + ], + [ + 1.05, + -54.92 + ], + [ + 1.08, + -48.13 + ], + [ + 1.09, + -39.71 + ], + [ + 1.13, + -52.64 + ], + [ + 1.14, + -50.75 + ] + ] + } + }, + "missing": [], + "v3_minus_v2_mm": { + "y0.85-0.95": 0.0, + "y0.95-1.05": 0.0, + "y0.86-1.02": 0.0, + "y0.78-0.86": 0.0, + "y1.02-1.12": 0.0 + }, + "v3_reduced_posterior": false +} \ No newline at end of file diff --git a/content-pipeline/labs/humanoid-runtime-refit/reports/hair_fit.json b/content-pipeline/labs/humanoid-runtime-refit/reports/hair_fit.json new file mode 100644 index 00000000..0f071de6 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/reports/hair_fit.json @@ -0,0 +1,1591 @@ +{ + "generator": "successor content-pipeline/labs/humanoid-runtime-refit/verify_hair_fit.py", + "contact_band_mm": 2.0, + "float_limit_mm": 3.0, + "buried_limit_mm": 60.0, + "variant_delta_limit_mm": 25.0, + "bodies": { + "male": { + "skull_top_m": 1.7525, + "items": { + "Hair_Afro": { + "nearest_surface_mm": -40.103, + "max_depth_mm": 40.103, + "contact_fraction": 0.012, + "crown_clearance_mm": 53.99, + "hair": true, + "vertices": 332 + }, + "Hair_Crop": { + "nearest_surface_mm": -42.751, + "max_depth_mm": 42.751, + "contact_fraction": 0.0572, + "crown_clearance_mm": 14.04, + "hair": true, + "vertices": 332 + }, + "Hair_Mop": { + "nearest_surface_mm": -42.813, + "max_depth_mm": 42.813, + "contact_fraction": 0.0319, + "crown_clearance_mm": 12.65, + "hair": true, + "vertices": 376 + }, + "Hat_Warm": { + "nearest_surface_mm": -12.243, + "max_depth_mm": 12.243, + "contact_fraction": 0.0102, + "crown_clearance_mm": 123.83, + "hair": false, + "vertices": 293 + }, + "SCFI_Pants_Refit": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0031, + "crown_clearance_mm": -694.57, + "hair": false, + "vertices": 3265 + }, + "SCFI_Top_Refit": { + "nearest_surface_mm": -5.253, + "max_depth_mm": 5.253, + "contact_fraction": 0.0079, + "crown_clearance_mm": -150.44, + "hair": false, + "vertices": 4810 + }, + "Shorts": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.047, + "crown_clearance_mm": -761.29, + "hair": false, + "vertices": 447 + }, + "Tank": { + "nearest_surface_mm": -7.443, + "max_depth_mm": 7.443, + "contact_fraction": 0.1298, + "crown_clearance_mm": -224.54, + "hair": false, + "vertices": 524 + }, + "boots_canvas_ankle": { + "nearest_surface_mm": 1.798, + "max_depth_mm": 0.0, + "contact_fraction": 0.1256, + "crown_clearance_mm": -1537.01, + "hair": false, + "vertices": 223 + }, + "boots_combat_sneakers": { + "nearest_surface_mm": -19.01, + "max_depth_mm": 19.01, + "contact_fraction": 0.0179, + "crown_clearance_mm": -1621.8, + "hair": false, + "vertices": 838 + }, + "boots_cuffed_runners": { + "nearest_surface_mm": -15.627, + "max_depth_mm": 15.627, + "contact_fraction": 0.0138, + "crown_clearance_mm": -1623.5, + "hair": false, + "vertices": 868 + }, + "boots_double_strap": { + "nearest_surface_mm": -17.269, + "max_depth_mm": 17.269, + "contact_fraction": 0.0214, + "crown_clearance_mm": -1623.94, + "hair": false, + "vertices": 1214 + }, + "boots_layered_sneakers": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0029, + "crown_clearance_mm": -1621.12, + "hair": false, + "vertices": 682 + }, + "boots_reinforced_street": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.072, + "crown_clearance_mm": -1627.63, + "hair": false, + "vertices": 1028 + }, + "boots_rubberized_canvas": { + "nearest_surface_mm": -8.715, + "max_depth_mm": 8.715, + "contact_fraction": 0.0196, + "crown_clearance_mm": -1623.85, + "hair": false, + "vertices": 1428 + }, + "boots_split_toe": { + "nearest_surface_mm": -73.015, + "max_depth_mm": 73.015, + "contact_fraction": 0.043, + "crown_clearance_mm": -1606.44, + "hair": false, + "vertices": 860 + }, + "boots_toe_capped": { + "nearest_surface_mm": 1.479, + "max_depth_mm": 0.0, + "contact_fraction": 0.0714, + "crown_clearance_mm": -1623.75, + "hair": false, + "vertices": 518 + }, + "boots_trail_shoes": { + "nearest_surface_mm": 1.499, + "max_depth_mm": 0.0, + "contact_fraction": 0.0596, + "crown_clearance_mm": -1626.45, + "hair": false, + "vertices": 906 + }, + "gloves_guarded_leather": { + "nearest_surface_mm": 4.416, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -263.01, + "hair": false, + "vertices": 1125 + }, + "gloves_knuckled_half": { + "nearest_surface_mm": 5.094, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -282.55, + "hair": false, + "vertices": 468 + }, + "gloves_light_utility": { + "nearest_surface_mm": 4.843, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -289.4, + "hair": false, + "vertices": 763 + }, + "gloves_plain_work": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0017, + "crown_clearance_mm": -265.24, + "hair": false, + "vertices": 575 + }, + "gloves_plated_handwraps": { + "nearest_surface_mm": -13.86, + "max_depth_mm": 13.86, + "contact_fraction": 0.0, + "crown_clearance_mm": -207.34, + "hair": false, + "vertices": 795 + }, + "gloves_reinforced_knit": { + "nearest_surface_mm": 5.221, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -288.4, + "hair": false, + "vertices": 551 + }, + "gloves_tipped_work": { + "nearest_surface_mm": 3.595, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -270.34, + "hair": false, + "vertices": 1178 + }, + "hair_banded_mohawk": { + "nearest_surface_mm": -19.606, + "max_depth_mm": 19.606, + "contact_fraction": 0.0422, + "crown_clearance_mm": 106.15, + "hair": true, + "vertices": 1161 + }, + "hair_braided_crown": { + "nearest_surface_mm": -18.247, + "max_depth_mm": 18.247, + "contact_fraction": 0.0161, + "crown_clearance_mm": 14.04, + "hair": true, + "vertices": 4480 + }, + "hair_cropped_buzz": { + "nearest_surface_mm": -17.679, + "max_depth_mm": 17.679, + "contact_fraction": 0.0552, + "crown_clearance_mm": 43.13, + "hair": true, + "vertices": 1521 + }, + "hair_fauxhawk": { + "nearest_surface_mm": -21.17, + "max_depth_mm": 21.17, + "contact_fraction": 0.0144, + "crown_clearance_mm": 119.63, + "hair": true, + "vertices": 1662 + }, + "hair_flared_shag": { + "nearest_surface_mm": -24.688, + "max_depth_mm": 24.688, + "contact_fraction": 0.0297, + "crown_clearance_mm": 56.78, + "hair": true, + "vertices": 1617 + }, + "hair_flared_wings": { + "nearest_surface_mm": -41.262, + "max_depth_mm": 41.262, + "contact_fraction": 0.0435, + "crown_clearance_mm": 79.75, + "hair": true, + "vertices": 897 + }, + "hair_flat_top": { + "nearest_surface_mm": -41.86, + "max_depth_mm": 41.86, + "contact_fraction": 0.0605, + "crown_clearance_mm": 31.01, + "hair": true, + "vertices": 1602 + }, + "hair_low_tufts": { + "nearest_surface_mm": -32.153, + "max_depth_mm": 32.153, + "contact_fraction": 0.04, + "crown_clearance_mm": 13.55, + "hair": true, + "vertices": 1151 + }, + "hair_messy_crop": { + "nearest_surface_mm": -37.409, + "max_depth_mm": 37.409, + "contact_fraction": 0.022, + "crown_clearance_mm": 35.5, + "hair": true, + "vertices": 1320 + }, + "hair_messy_side_part": { + "nearest_surface_mm": -33.974, + "max_depth_mm": 33.974, + "contact_fraction": 0.0384, + "crown_clearance_mm": 59.95, + "hair": true, + "vertices": 1850 + }, + "hair_ridge_mohawk": { + "nearest_surface_mm": -18.561, + "max_depth_mm": 18.561, + "contact_fraction": 0.0687, + "crown_clearance_mm": 81.5, + "hair": true, + "vertices": 291 + }, + "hair_short_ponytail": { + "nearest_surface_mm": -25.223, + "max_depth_mm": 25.223, + "contact_fraction": 0.0421, + "crown_clearance_mm": 39.22, + "hair": true, + "vertices": 2090 + }, + "hair_shoulder_shag": { + "nearest_surface_mm": -24.69, + "max_depth_mm": 24.69, + "contact_fraction": 0.0472, + "crown_clearance_mm": 20.73, + "hair": true, + "vertices": 1780 + }, + "hair_side_ponytail": { + "nearest_surface_mm": -19.18, + "max_depth_mm": 19.18, + "contact_fraction": 0.0183, + "crown_clearance_mm": 46.64, + "hair": true, + "vertices": 2737 + }, + "hair_side_sweep": { + "nearest_surface_mm": -29.451, + "max_depth_mm": 29.451, + "contact_fraction": 0.0197, + "crown_clearance_mm": 85.41, + "hair": true, + "vertices": 1270 + }, + "hair_slicked_crop": { + "nearest_surface_mm": -13.732, + "max_depth_mm": 13.732, + "contact_fraction": 0.0262, + "crown_clearance_mm": 32.84, + "hair": true, + "vertices": 954 + }, + "hair_spiked_mohawk": { + "nearest_surface_mm": -28.252, + "max_depth_mm": 28.252, + "contact_fraction": 0.0476, + "crown_clearance_mm": 166.11, + "hair": true, + "vertices": 1722 + }, + "hair_spiked_topknot": { + "nearest_surface_mm": -29.656, + "max_depth_mm": 29.656, + "contact_fraction": 0.0556, + "crown_clearance_mm": 81.08, + "hair": true, + "vertices": 1402 + }, + "hair_split_mohawk": { + "nearest_surface_mm": -10.969, + "max_depth_mm": 10.969, + "contact_fraction": 0.0211, + "crown_clearance_mm": 116.37, + "hair": true, + "vertices": 1900 + }, + "hair_swept_bob": { + "nearest_surface_mm": -34.155, + "max_depth_mm": 34.155, + "contact_fraction": 0.0359, + "crown_clearance_mm": 33.83, + "hair": true, + "vertices": 1558 + }, + "hair_swept_spikes": { + "nearest_surface_mm": -24.335, + "max_depth_mm": 24.335, + "contact_fraction": 0.0308, + "crown_clearance_mm": 89.55, + "hair": true, + "vertices": 1524 + }, + "hair_textured_crop": { + "nearest_surface_mm": -35.501, + "max_depth_mm": 35.501, + "contact_fraction": 0.0294, + "crown_clearance_mm": 45.33, + "hair": true, + "vertices": 1563 + }, + "hair_top_patch": { + "nearest_surface_mm": -19.034, + "max_depth_mm": 19.034, + "contact_fraction": 0.0495, + "crown_clearance_mm": 27.25, + "hair": true, + "vertices": 748 + }, + "hair_twin_pigtails": { + "nearest_surface_mm": -27.924, + "max_depth_mm": 27.924, + "contact_fraction": 0.0503, + "crown_clearance_mm": 56.97, + "hair": true, + "vertices": 2523 + }, + "legs_gaitered_cargo_pants": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0313, + "crown_clearance_mm": -723.71, + "hair": false, + "vertices": 3517 + }, + "legs_layered_shorts": { + "nearest_surface_mm": 1.463, + "max_depth_mm": 0.0, + "contact_fraction": 0.0822, + "crown_clearance_mm": -723.58, + "hair": false, + "vertices": 4100 + }, + "legs_layered_wrap_skirt": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0176, + "crown_clearance_mm": -705.97, + "hair": false, + "vertices": 3237 + }, + "legs_padded_canvas_trousers": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0435, + "crown_clearance_mm": -723.72, + "hair": false, + "vertices": 3222 + }, + "legs_plated_trousers": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0333, + "crown_clearance_mm": -723.71, + "hair": false, + "vertices": 3002 + }, + "legs_reinforced_denim_pants": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0527, + "crown_clearance_mm": -723.56, + "hair": false, + "vertices": 3508 + }, + "legs_sashed_patrol_pants": { + "nearest_surface_mm": 1.46, + "max_depth_mm": 0.0, + "contact_fraction": 0.0348, + "crown_clearance_mm": -723.51, + "hair": false, + "vertices": 3537 + }, + "legs_skirted_workpants": { + "nearest_surface_mm": 1.452, + "max_depth_mm": 0.0, + "contact_fraction": 0.0354, + "crown_clearance_mm": -723.65, + "hair": false, + "vertices": 3188 + }, + "legs_strapped_trousers": { + "nearest_surface_mm": 1.473, + "max_depth_mm": 0.0, + "contact_fraction": 0.0622, + "crown_clearance_mm": -723.58, + "hair": false, + "vertices": 4035 + }, + "legs_wrapped_workpants": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.008, + "crown_clearance_mm": -723.72, + "hair": false, + "vertices": 3379 + }, + "top_frayed_tunic": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0493, + "crown_clearance_mm": -223.12, + "hair": false, + "vertices": 2495 + }, + "top_laced_corset_vest": { + "nearest_surface_mm": 4.537, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -229.18, + "hair": false, + "vertices": 2570 + }, + "top_padded_leather_vest": { + "nearest_surface_mm": 6.462, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -212.71, + "hair": false, + "vertices": 2339 + }, + "top_plated_rig_vest": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0314, + "crown_clearance_mm": -212.99, + "hair": false, + "vertices": 2390 + }, + "top_reinforced_crop_vest": { + "nearest_surface_mm": 4.695, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -224.6, + "hair": false, + "vertices": 1548 + }, + "top_rigged_tank": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0197, + "crown_clearance_mm": -228.36, + "hair": false, + "vertices": 1675 + }, + "top_scrap_plate_tunic": { + "nearest_surface_mm": 1.459, + "max_depth_mm": 0.0, + "contact_fraction": 0.0164, + "crown_clearance_mm": -206.38, + "hair": false, + "vertices": 2320 + }, + "top_spiked_leather_vest": { + "nearest_surface_mm": 1.473, + "max_depth_mm": 0.0, + "contact_fraction": 0.0033, + "crown_clearance_mm": -197.6, + "hair": false, + "vertices": 2149 + }, + "under_bodysuit": { + "nearest_surface_mm": -0.091, + "max_depth_mm": 0.091, + "contact_fraction": 1.0, + "crown_clearance_mm": -223.68, + "hair": false, + "vertices": 782 + }, + "Bicep_L": { + "nearest_surface_mm": 1.07, + "max_depth_mm": 0.0, + "contact_fraction": 0.0208, + "crown_clearance_mm": -245.36, + "hair": false, + "vertices": 144 + }, + "Bicep_R": { + "nearest_surface_mm": 1.467, + "max_depth_mm": 0.0, + "contact_fraction": 0.0208, + "crown_clearance_mm": -245.37, + "hair": false, + "vertices": 144 + }, + "Gorget": { + "nearest_surface_mm": 2.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -150.2, + "hair": false, + "vertices": 320 + }, + "Harness": { + "nearest_surface_mm": -25.2, + "max_depth_mm": 25.2, + "contact_fraction": 0.0395, + "crown_clearance_mm": -199.01, + "hair": false, + "vertices": 988 + }, + "Helmet_A": { + "nearest_surface_mm": -18.928, + "max_depth_mm": 18.928, + "contact_fraction": 0.007, + "crown_clearance_mm": 32.87, + "hair": false, + "vertices": 1136 + }, + "Helmet_B": { + "nearest_surface_mm": -18.28, + "max_depth_mm": 18.28, + "contact_fraction": 0.0079, + "crown_clearance_mm": 34.8, + "hair": false, + "vertices": 1258 + }, + "Helmet_C": { + "nearest_surface_mm": -6.387, + "max_depth_mm": 6.387, + "contact_fraction": 0.0278, + "crown_clearance_mm": 30.72, + "hair": false, + "vertices": 1368 + }, + "Helmet_D": { + "nearest_surface_mm": -9.65, + "max_depth_mm": 9.65, + "contact_fraction": 0.0028, + "crown_clearance_mm": 83.5, + "hair": false, + "vertices": 1446 + }, + "Helmet_S1": { + "nearest_surface_mm": -14.071, + "max_depth_mm": 14.071, + "contact_fraction": 0.0189, + "crown_clearance_mm": 40.72, + "hair": false, + "vertices": 900 + }, + "Helmet_S2": { + "nearest_surface_mm": -19.317, + "max_depth_mm": 19.317, + "contact_fraction": 0.0138, + "crown_clearance_mm": 32.02, + "hair": false, + "vertices": 1446 + }, + "Helmet_S3": { + "nearest_surface_mm": -12.863, + "max_depth_mm": 12.863, + "contact_fraction": 0.0249, + "crown_clearance_mm": 43.53, + "hair": false, + "vertices": 1528 + }, + "Helmet_V1PORT": { + "nearest_surface_mm": -16.336, + "max_depth_mm": 16.336, + "contact_fraction": 0.0348, + "crown_clearance_mm": 40.85, + "hair": false, + "vertices": 834 + }, + "Nape_Reinforcement": { + "nearest_surface_mm": 4.645, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -122.22, + "hair": false, + "vertices": 1268 + }, + "Reinforcement": { + "nearest_surface_mm": 4.825, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -156.45, + "hair": false, + "vertices": 160 + }, + "trial_back_a": { + "nearest_surface_mm": 19.741, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -183.71, + "hair": false, + "vertices": 1014 + }, + "trial_back_b": { + "nearest_surface_mm": 5.528, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -254.1, + "hair": false, + "vertices": 1139 + }, + "trial_hair_a": { + "nearest_surface_mm": -34.296, + "max_depth_mm": 34.296, + "contact_fraction": 0.0973, + "crown_clearance_mm": 25.08, + "hair": true, + "vertices": 586 + }, + "trial_hair_b": { + "nearest_surface_mm": -45.291, + "max_depth_mm": 45.291, + "contact_fraction": 0.0303, + "crown_clearance_mm": 34.36, + "hair": true, + "vertices": 727 + }, + "trial_hair_c": { + "nearest_surface_mm": -17.075, + "max_depth_mm": 17.075, + "contact_fraction": 0.0092, + "crown_clearance_mm": 39.74, + "hair": true, + "vertices": 542 + }, + "trial_hair_d": { + "nearest_surface_mm": -35.217, + "max_depth_mm": 35.217, + "contact_fraction": 0.0538, + "crown_clearance_mm": 48.47, + "hair": true, + "vertices": 1544 + }, + "trial_helm_a": { + "nearest_surface_mm": -67.63, + "max_depth_mm": 67.63, + "contact_fraction": 0.1169, + "crown_clearance_mm": 6.68, + "hair": false, + "vertices": 556 + }, + "trial_helm_b": { + "nearest_surface_mm": -61.266, + "max_depth_mm": 61.266, + "contact_fraction": 0.0528, + "crown_clearance_mm": 43.97, + "hair": false, + "vertices": 587 + }, + "trial_helm_c": { + "nearest_surface_mm": -31.319, + "max_depth_mm": 31.319, + "contact_fraction": 0.0489, + "crown_clearance_mm": 16.27, + "hair": false, + "vertices": 368 + }, + "trial_helm_d": { + "nearest_surface_mm": -62.813, + "max_depth_mm": 62.813, + "contact_fraction": 0.0777, + "crown_clearance_mm": 21.44, + "hair": false, + "vertices": 618 + }, + "trial_hip_back_a": { + "nearest_surface_mm": 10.921, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -786.73, + "hair": false, + "vertices": 234 + }, + "trial_hip_front_a": { + "nearest_surface_mm": 4.122, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -723.97, + "hair": false, + "vertices": 581 + }, + "trial_hip_side_a": { + "nearest_surface_mm": 2.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -777.9, + "hair": false, + "vertices": 512 + }, + "trial_hood_a": { + "nearest_surface_mm": -66.247, + "max_depth_mm": 66.247, + "contact_fraction": 0.0328, + "crown_clearance_mm": 50.96, + "hair": false, + "vertices": 793 + }, + "trial_mask_b": { + "nearest_surface_mm": -42.413, + "max_depth_mm": 42.413, + "contact_fraction": 0.0365, + "crown_clearance_mm": 16.91, + "hair": false, + "vertices": 137 + }, + "trial_visor_a": { + "nearest_surface_mm": -31.799, + "max_depth_mm": 31.799, + "contact_fraction": 0.0352, + "crown_clearance_mm": -109.93, + "hair": false, + "vertices": 398 + } + } + }, + "female": { + "skull_top_m": 1.7525, + "items": { + "Hair_Afro": { + "nearest_surface_mm": -32.487, + "max_depth_mm": 32.487, + "contact_fraction": 0.0482, + "crown_clearance_mm": 49.06, + "hair": true, + "vertices": 332 + }, + "Hair_Crop": { + "nearest_surface_mm": -33.074, + "max_depth_mm": 33.074, + "contact_fraction": 0.0602, + "crown_clearance_mm": 11.43, + "hair": true, + "vertices": 332 + }, + "Hair_Mop": { + "nearest_surface_mm": -33.024, + "max_depth_mm": 33.024, + "contact_fraction": 0.0266, + "crown_clearance_mm": 10.15, + "hair": true, + "vertices": 376 + }, + "Hat_Warm": { + "nearest_surface_mm": -12.333, + "max_depth_mm": 12.333, + "contact_fraction": 0.0137, + "crown_clearance_mm": 114.88, + "hair": false, + "vertices": 293 + }, + "SCFI_Pants_Refit": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0413, + "crown_clearance_mm": -694.57, + "hair": false, + "vertices": 3265 + }, + "SCFI_Top_Refit": { + "nearest_surface_mm": 1.46, + "max_depth_mm": 0.0, + "contact_fraction": 0.0067, + "crown_clearance_mm": -154.01, + "hair": false, + "vertices": 4810 + }, + "Shorts": { + "nearest_surface_mm": -5.018, + "max_depth_mm": 5.018, + "contact_fraction": 0.3736, + "crown_clearance_mm": -761.36, + "hair": false, + "vertices": 447 + }, + "Tank": { + "nearest_surface_mm": -8.605, + "max_depth_mm": 8.605, + "contact_fraction": 0.1469, + "crown_clearance_mm": -223.05, + "hair": false, + "vertices": 524 + }, + "boots_canvas_ankle": { + "nearest_surface_mm": 1.748, + "max_depth_mm": 0.0, + "contact_fraction": 0.1134, + "crown_clearance_mm": -1537.58, + "hair": false, + "vertices": 247 + }, + "boots_combat_sneakers": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0024, + "crown_clearance_mm": -1622.3, + "hair": false, + "vertices": 838 + }, + "boots_cuffed_runners": { + "nearest_surface_mm": 1.488, + "max_depth_mm": 0.0, + "contact_fraction": 0.0276, + "crown_clearance_mm": -1624.3, + "hair": false, + "vertices": 868 + }, + "boots_double_strap": { + "nearest_surface_mm": 1.452, + "max_depth_mm": 0.0, + "contact_fraction": 0.0214, + "crown_clearance_mm": -1624.61, + "hair": false, + "vertices": 1214 + }, + "boots_layered_sneakers": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0161, + "crown_clearance_mm": -1621.75, + "hair": false, + "vertices": 682 + }, + "boots_reinforced_street": { + "nearest_surface_mm": 1.464, + "max_depth_mm": 0.0, + "contact_fraction": 0.0467, + "crown_clearance_mm": -1628.84, + "hair": false, + "vertices": 1028 + }, + "boots_rubberized_canvas": { + "nearest_surface_mm": -5.148, + "max_depth_mm": 5.148, + "contact_fraction": 0.021, + "crown_clearance_mm": -1624.75, + "hair": false, + "vertices": 1428 + }, + "boots_split_toe": { + "nearest_surface_mm": -3.658, + "max_depth_mm": 3.658, + "contact_fraction": 0.0209, + "crown_clearance_mm": -1607.19, + "hair": false, + "vertices": 860 + }, + "boots_toe_capped": { + "nearest_surface_mm": -12.795, + "max_depth_mm": 12.795, + "contact_fraction": 0.0463, + "crown_clearance_mm": -1624.21, + "hair": false, + "vertices": 518 + }, + "boots_trail_shoes": { + "nearest_surface_mm": 1.462, + "max_depth_mm": 0.0, + "contact_fraction": 0.0342, + "crown_clearance_mm": -1627.07, + "hair": false, + "vertices": 906 + }, + "gloves_guarded_leather": { + "nearest_surface_mm": 4.421, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -259.82, + "hair": false, + "vertices": 1125 + }, + "gloves_knuckled_half": { + "nearest_surface_mm": 5.116, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -282.55, + "hair": false, + "vertices": 468 + }, + "gloves_light_utility": { + "nearest_surface_mm": -6.967, + "max_depth_mm": 6.967, + "contact_fraction": 0.0, + "crown_clearance_mm": -289.51, + "hair": false, + "vertices": 763 + }, + "gloves_plain_work": { + "nearest_surface_mm": 0.47, + "max_depth_mm": 0.0, + "contact_fraction": 0.0052, + "crown_clearance_mm": -267.69, + "hair": false, + "vertices": 575 + }, + "gloves_plated_handwraps": { + "nearest_surface_mm": -41.351, + "max_depth_mm": 41.351, + "contact_fraction": 0.0, + "crown_clearance_mm": -207.98, + "hair": false, + "vertices": 795 + }, + "gloves_reinforced_knit": { + "nearest_surface_mm": -0.086, + "max_depth_mm": 0.086, + "contact_fraction": 0.0672, + "crown_clearance_mm": -288.79, + "hair": false, + "vertices": 551 + }, + "gloves_tipped_work": { + "nearest_surface_mm": -14.576, + "max_depth_mm": 14.576, + "contact_fraction": 0.0, + "crown_clearance_mm": -272.86, + "hair": false, + "vertices": 1178 + }, + "hair_banded_mohawk": { + "nearest_surface_mm": -19.356, + "max_depth_mm": 19.356, + "contact_fraction": 0.0517, + "crown_clearance_mm": 98.61, + "hair": true, + "vertices": 1161 + }, + "hair_braided_crown": { + "nearest_surface_mm": -21.266, + "max_depth_mm": 21.266, + "contact_fraction": 0.0257, + "crown_clearance_mm": 11.53, + "hair": true, + "vertices": 4480 + }, + "hair_cropped_buzz": { + "nearest_surface_mm": -27.18, + "max_depth_mm": 27.18, + "contact_fraction": 0.075, + "crown_clearance_mm": 38.87, + "hair": true, + "vertices": 1521 + }, + "hair_fauxhawk": { + "nearest_surface_mm": -21.663, + "max_depth_mm": 21.663, + "contact_fraction": 0.0181, + "crown_clearance_mm": 111.18, + "hair": true, + "vertices": 1662 + }, + "hair_flared_shag": { + "nearest_surface_mm": -30.464, + "max_depth_mm": 30.464, + "contact_fraction": 0.0297, + "crown_clearance_mm": 51.87, + "hair": true, + "vertices": 1617 + }, + "hair_flared_wings": { + "nearest_surface_mm": -44.843, + "max_depth_mm": 44.843, + "contact_fraction": 0.0212, + "crown_clearance_mm": 73.42, + "hair": true, + "vertices": 897 + }, + "hair_flat_top": { + "nearest_surface_mm": -45.603, + "max_depth_mm": 45.603, + "contact_fraction": 0.0581, + "crown_clearance_mm": 27.56, + "hair": true, + "vertices": 1602 + }, + "hair_low_tufts": { + "nearest_surface_mm": -36.555, + "max_depth_mm": 36.555, + "contact_fraction": 0.0634, + "crown_clearance_mm": 11.08, + "hair": true, + "vertices": 1151 + }, + "hair_messy_crop": { + "nearest_surface_mm": -34.701, + "max_depth_mm": 34.701, + "contact_fraction": 0.0326, + "crown_clearance_mm": 31.63, + "hair": true, + "vertices": 1320 + }, + "hair_messy_side_part": { + "nearest_surface_mm": -33.008, + "max_depth_mm": 33.008, + "contact_fraction": 0.0676, + "crown_clearance_mm": 54.85, + "hair": true, + "vertices": 1850 + }, + "hair_ridge_mohawk": { + "nearest_surface_mm": -18.896, + "max_depth_mm": 18.896, + "contact_fraction": 0.1615, + "crown_clearance_mm": 75.03, + "hair": true, + "vertices": 291 + }, + "hair_short_ponytail": { + "nearest_surface_mm": -24.322, + "max_depth_mm": 24.322, + "contact_fraction": 0.0488, + "crown_clearance_mm": 35.24, + "hair": true, + "vertices": 2090 + }, + "hair_shoulder_shag": { + "nearest_surface_mm": -22.813, + "max_depth_mm": 22.813, + "contact_fraction": 0.0663, + "crown_clearance_mm": 17.87, + "hair": true, + "vertices": 1780 + }, + "hair_side_ponytail": { + "nearest_surface_mm": -19.756, + "max_depth_mm": 19.756, + "contact_fraction": 0.0216, + "crown_clearance_mm": 42.2, + "hair": true, + "vertices": 2737 + }, + "hair_side_sweep": { + "nearest_surface_mm": -26.682, + "max_depth_mm": 26.682, + "contact_fraction": 0.015, + "crown_clearance_mm": 78.98, + "hair": true, + "vertices": 1270 + }, + "hair_slicked_crop": { + "nearest_surface_mm": -20.104, + "max_depth_mm": 20.104, + "contact_fraction": 0.0818, + "crown_clearance_mm": 29.38, + "hair": true, + "vertices": 954 + }, + "hair_spiked_mohawk": { + "nearest_surface_mm": -27.97, + "max_depth_mm": 27.97, + "contact_fraction": 0.0383, + "crown_clearance_mm": 154.74, + "hair": true, + "vertices": 1722 + }, + "hair_spiked_topknot": { + "nearest_surface_mm": -29.652, + "max_depth_mm": 29.652, + "contact_fraction": 0.0378, + "crown_clearance_mm": 74.77, + "hair": true, + "vertices": 1402 + }, + "hair_split_mohawk": { + "nearest_surface_mm": -12.113, + "max_depth_mm": 12.113, + "contact_fraction": 0.0363, + "crown_clearance_mm": 108.2, + "hair": true, + "vertices": 1900 + }, + "hair_swept_bob": { + "nearest_surface_mm": -25.035, + "max_depth_mm": 25.035, + "contact_fraction": 0.0372, + "crown_clearance_mm": 30.2, + "hair": true, + "vertices": 1558 + }, + "hair_swept_spikes": { + "nearest_surface_mm": -30.704, + "max_depth_mm": 30.704, + "contact_fraction": 0.0302, + "crown_clearance_mm": 82.75, + "hair": true, + "vertices": 1524 + }, + "hair_textured_crop": { + "nearest_surface_mm": -32.959, + "max_depth_mm": 32.959, + "contact_fraction": 0.055, + "crown_clearance_mm": 40.93, + "hair": true, + "vertices": 1563 + }, + "hair_top_patch": { + "nearest_surface_mm": -19.722, + "max_depth_mm": 19.722, + "contact_fraction": 0.0762, + "crown_clearance_mm": 24.02, + "hair": true, + "vertices": 748 + }, + "hair_twin_pigtails": { + "nearest_surface_mm": -23.413, + "max_depth_mm": 23.413, + "contact_fraction": 0.0555, + "crown_clearance_mm": 51.93, + "hair": true, + "vertices": 2523 + }, + "legs_gaitered_cargo_pants": { + "nearest_surface_mm": 1.485, + "max_depth_mm": 0.0, + "contact_fraction": 0.0819, + "crown_clearance_mm": -723.71, + "hair": false, + "vertices": 3517 + }, + "legs_layered_shorts": { + "nearest_surface_mm": 0.485, + "max_depth_mm": 0.0, + "contact_fraction": 0.191, + "crown_clearance_mm": -723.58, + "hair": false, + "vertices": 4100 + }, + "legs_layered_wrap_skirt": { + "nearest_surface_mm": 1.489, + "max_depth_mm": 0.0, + "contact_fraction": 0.0831, + "crown_clearance_mm": -705.97, + "hair": false, + "vertices": 3237 + }, + "legs_padded_canvas_trousers": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0947, + "crown_clearance_mm": -723.72, + "hair": false, + "vertices": 3222 + }, + "legs_plated_trousers": { + "nearest_surface_mm": 1.33, + "max_depth_mm": 0.0, + "contact_fraction": 0.1099, + "crown_clearance_mm": -723.71, + "hair": false, + "vertices": 3002 + }, + "legs_reinforced_denim_pants": { + "nearest_surface_mm": -14.441, + "max_depth_mm": 14.441, + "contact_fraction": 0.0773, + "crown_clearance_mm": -723.56, + "hair": false, + "vertices": 3508 + }, + "legs_sashed_patrol_pants": { + "nearest_surface_mm": 1.478, + "max_depth_mm": 0.0, + "contact_fraction": 0.0882, + "crown_clearance_mm": -723.51, + "hair": false, + "vertices": 3537 + }, + "legs_skirted_workpants": { + "nearest_surface_mm": 1.476, + "max_depth_mm": 0.0, + "contact_fraction": 0.0787, + "crown_clearance_mm": -723.65, + "hair": false, + "vertices": 3188 + }, + "legs_strapped_trousers": { + "nearest_surface_mm": -0.583, + "max_depth_mm": 0.583, + "contact_fraction": 0.115, + "crown_clearance_mm": -723.58, + "hair": false, + "vertices": 4035 + }, + "legs_wrapped_workpants": { + "nearest_surface_mm": 1.478, + "max_depth_mm": 0.0, + "contact_fraction": 0.0361, + "crown_clearance_mm": -723.72, + "hair": false, + "vertices": 3379 + }, + "top_frayed_tunic": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0052, + "crown_clearance_mm": -247.07, + "hair": false, + "vertices": 2495 + }, + "top_laced_corset_vest": { + "nearest_surface_mm": 4.574, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -241.92, + "hair": false, + "vertices": 2570 + }, + "top_padded_leather_vest": { + "nearest_surface_mm": 5.866, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -231.1, + "hair": false, + "vertices": 2339 + }, + "top_plated_rig_vest": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0033, + "crown_clearance_mm": -212.99, + "hair": false, + "vertices": 2390 + }, + "top_reinforced_crop_vest": { + "nearest_surface_mm": 3.8, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -245.34, + "hair": false, + "vertices": 1548 + }, + "top_rigged_tank": { + "nearest_surface_mm": 1.479, + "max_depth_mm": 0.0, + "contact_fraction": 0.0382, + "crown_clearance_mm": -243.36, + "hair": false, + "vertices": 1675 + }, + "top_scrap_plate_tunic": { + "nearest_surface_mm": 1.364, + "max_depth_mm": 0.0, + "contact_fraction": 0.0099, + "crown_clearance_mm": -225.42, + "hair": false, + "vertices": 2320 + }, + "top_spiked_leather_vest": { + "nearest_surface_mm": 1.462, + "max_depth_mm": 0.0, + "contact_fraction": 0.0023, + "crown_clearance_mm": -226.48, + "hair": false, + "vertices": 2149 + }, + "under_bodysuit": { + "nearest_surface_mm": -0.091, + "max_depth_mm": 0.091, + "contact_fraction": 1.0, + "crown_clearance_mm": -224.03, + "hair": false, + "vertices": 817 + }, + "Bicep_L": { + "nearest_surface_mm": 9.74, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -259.52, + "hair": false, + "vertices": 144 + }, + "Bicep_R": { + "nearest_surface_mm": 10.091, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -259.32, + "hair": false, + "vertices": 144 + }, + "Gorget": { + "nearest_surface_mm": 14.002, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -156.8, + "hair": false, + "vertices": 320 + }, + "Harness": { + "nearest_surface_mm": -25.999, + "max_depth_mm": 25.999, + "contact_fraction": 0.0648, + "crown_clearance_mm": -213.04, + "hair": false, + "vertices": 988 + }, + "Helmet_A": { + "nearest_surface_mm": -13.51, + "max_depth_mm": 13.51, + "contact_fraction": 0.0352, + "crown_clearance_mm": 29.24, + "hair": false, + "vertices": 1136 + }, + "Helmet_B": { + "nearest_surface_mm": -12.129, + "max_depth_mm": 12.129, + "contact_fraction": 0.0072, + "crown_clearance_mm": 31.03, + "hair": false, + "vertices": 1258 + }, + "Helmet_C": { + "nearest_surface_mm": -1.554, + "max_depth_mm": 1.554, + "contact_fraction": 0.0117, + "crown_clearance_mm": 27.21, + "hair": false, + "vertices": 1368 + }, + "Helmet_D": { + "nearest_surface_mm": -8.87, + "max_depth_mm": 8.87, + "contact_fraction": 0.0138, + "crown_clearance_mm": 77.01, + "hair": false, + "vertices": 1446 + }, + "Helmet_S1": { + "nearest_surface_mm": -9.92, + "max_depth_mm": 9.92, + "contact_fraction": 0.0167, + "crown_clearance_mm": 36.67, + "hair": false, + "vertices": 900 + }, + "Helmet_S2": { + "nearest_surface_mm": -13.295, + "max_depth_mm": 13.295, + "contact_fraction": 0.0228, + "crown_clearance_mm": 28.45, + "hair": false, + "vertices": 1446 + }, + "Helmet_S3": { + "nearest_surface_mm": -9.024, + "max_depth_mm": 9.024, + "contact_fraction": 0.0072, + "crown_clearance_mm": 39.31, + "hair": false, + "vertices": 1528 + }, + "Helmet_V1PORT": { + "nearest_surface_mm": -11.487, + "max_depth_mm": 11.487, + "contact_fraction": 0.0312, + "crown_clearance_mm": 36.75, + "hair": false, + "vertices": 834 + }, + "Nape_Reinforcement": { + "nearest_surface_mm": 9.93, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -130.79, + "hair": false, + "vertices": 1268 + }, + "Reinforcement": { + "nearest_surface_mm": 18.069, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -159.61, + "hair": false, + "vertices": 160 + }, + "trial_back_a": { + "nearest_surface_mm": 22.771, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -174.19, + "hair": false, + "vertices": 1014 + }, + "trial_back_b": { + "nearest_surface_mm": 4.907, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -257.1, + "hair": false, + "vertices": 1139 + }, + "trial_hair_a": { + "nearest_surface_mm": -31.885, + "max_depth_mm": 31.885, + "contact_fraction": 0.1126, + "crown_clearance_mm": 22.03, + "hair": true, + "vertices": 586 + }, + "trial_hair_b": { + "nearest_surface_mm": -45.492, + "max_depth_mm": 45.492, + "contact_fraction": 0.0399, + "crown_clearance_mm": 30.79, + "hair": true, + "vertices": 727 + }, + "trial_hair_c": { + "nearest_surface_mm": -23.117, + "max_depth_mm": 23.117, + "contact_fraction": 0.024, + "crown_clearance_mm": 35.64, + "hair": true, + "vertices": 542 + }, + "trial_hair_d": { + "nearest_surface_mm": -33.221, + "max_depth_mm": 33.221, + "contact_fraction": 0.0583, + "crown_clearance_mm": 44.04, + "hair": true, + "vertices": 1544 + }, + "trial_helm_a": { + "nearest_surface_mm": -66.321, + "max_depth_mm": 66.321, + "contact_fraction": 0.1888, + "crown_clearance_mm": 4.62, + "hair": false, + "vertices": 556 + }, + "trial_helm_b": { + "nearest_surface_mm": -63.783, + "max_depth_mm": 63.783, + "contact_fraction": 0.0375, + "crown_clearance_mm": 39.75, + "hair": false, + "vertices": 587 + }, + "trial_helm_c": { + "nearest_surface_mm": -31.253, + "max_depth_mm": 31.253, + "contact_fraction": 0.0326, + "crown_clearance_mm": 13.57, + "hair": false, + "vertices": 368 + }, + "trial_helm_d": { + "nearest_surface_mm": -53.504, + "max_depth_mm": 53.504, + "contact_fraction": 0.0987, + "crown_clearance_mm": 18.3, + "hair": false, + "vertices": 618 + }, + "trial_hip_back_a": { + "nearest_surface_mm": 6.363, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -787.78, + "hair": false, + "vertices": 234 + }, + "trial_hip_front_a": { + "nearest_surface_mm": 4.469, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -725.77, + "hair": false, + "vertices": 581 + }, + "trial_hip_side_a": { + "nearest_surface_mm": -7.286, + "max_depth_mm": 7.286, + "contact_fraction": 0.0039, + "crown_clearance_mm": -774.75, + "hair": false, + "vertices": 512 + }, + "trial_hood_a": { + "nearest_surface_mm": -62.38, + "max_depth_mm": 62.38, + "contact_fraction": 0.0252, + "crown_clearance_mm": 46.33, + "hair": false, + "vertices": 793 + }, + "trial_mask_b": { + "nearest_surface_mm": -32.144, + "max_depth_mm": 32.144, + "contact_fraction": 0.0438, + "crown_clearance_mm": 14.21, + "hair": false, + "vertices": 137 + }, + "trial_visor_a": { + "nearest_surface_mm": -22.919, + "max_depth_mm": 22.919, + "contact_fraction": 0.0603, + "crown_clearance_mm": -105.32, + "hair": false, + "vertices": 398 + } + } + } + }, + "failures": [ + "boots_split_toe: male and female seat 69.4 mm apart (73.0 vs 3.7)", + "gloves_plated_handwraps: male and female seat 27.5 mm apart (13.9 vs 41.4)" + ] +} diff --git a/content-pipeline/labs/humanoid-runtime-refit/reports/promotion.json b/content-pipeline/labs/humanoid-runtime-refit/reports/promotion.json new file mode 100644 index 00000000..6cc8999b --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/reports/promotion.json @@ -0,0 +1,1039 @@ +{ + "generator": "successor content-pipeline/labs/humanoid-runtime-refit/promote_bodies.py", + "shell_sha256": "6eaac9062bcfbc5efdf40c64f9e3236402a0d81832f1e0e8f4ce9a0a4dacac42", + "shell_animations": [ + "block_hold", + "cast_aoe", + "cast_directed_loop", + "cast_directed_windup", + "crouch_idle", + "crouch_walk", + "death_b", + "death_f", + "deflect_c", + "deflect_l", + "deflect_r", + "gun_grip_trigger_discipline", + "idle", + "kneel_loop", + "meditate_loop", + "melee_draw", + "melee_draw_alt", + "melee_flinch", + "melee_grip", + "melee_idle", + "melee_ready", + "melee_run_f", + "melee_run_v2", + "melee_sheath", + "melee_strafe_l", + "melee_strafe_r", + "melee_turn_180", + "melee_turn_90", + "melee_walk_f", + "melee_walk_v2", + "reload", + "rifle_aim", + "rifle_fire", + "rifle_idle", + "rifle_run_f", + "rifle_walk_f", + "run_f", + "stim_inject", + "strafe_l", + "strafe_r", + "swing_h1", + "swing_h2", + "swing_h3", + "swing_spin_aoe", + "swing_thrust", + "walk_b", + "walk_f" + ], + "approved_sources": { + "male": { + "path": "content-pipeline/labs/humanoid-runtime-refit/source/male/pawn_male_refined.glb", + "sha256": "d5cb14b321a5f6024820e6c49eaa7f4200dca303336c95758792847f34211401", + "promotes_directly": true + }, + "female": { + "path": "content-pipeline/labs/humanoid-runtime-refit/source/female/pawn_female_refined.glb", + "sha256": "bd99fdb32fc40291780d92b1494f07a428c439204b306a2a6d32d2bc403c0c8b", + "promotes_directly": true + } + }, + "female": { + "output": "client-3d/public/assets/pawn-pack/pawn_female.glb", + "aliases": [], + "bytes": 2870468, + "sha256": "9eb7242ce49fcd7d7afa53a898c35e5b7b5fd0d5d99d7109bfc82e33e4f095e9", + "source": "content-pipeline/labs/humanoid-runtime-refit/source/female/pawn_female_refined.glb", + "source_sha256": "bd99fdb32fc40291780d92b1494f07a428c439204b306a2a6d32d2bc403c0c8b", + "source_sidecar": { + "body": "pawn_female_refined.glb", + "source_repository": "ssh://bunker/home/lycaon/git/successor.git", + "source_branch": "body-refine-20260802", + "source_commit": "d3fdfa2831bf938c0d9c8bf06f8796a00a935af5", + "source_sha256": "bd99fdb32fc40291780d92b1494f07a428c439204b306a2a6d32d2bc403c0c8b", + "clips_from": "hash-pinned runtime shell during promotion", + "actions": 0, + "runtime_actions": 47, + "verts": 3262, + "tris": 5882, + "height": 1.7525, + "vertex_groups": 50, + "head": { + "method": "reviewed shared-topology 10-ring, 16-column faceted head and neck graft", + "face_panel_faces": 56, + "art_gates": { + "depth_over_height": true, + "rear_fill": true, + "occiput_peak": true + } + }, + "hands": { + "source": "verified legacy runtime hands", + "unchanged_by_body_refinement": true, + "islands_per_side": 36 + }, + "review": "Final Bunker humanoid refinement; immutable, head, transition, animation-proof, and viewer gates completed before commit." + }, + "joints": 50, + "animations": 47, + "animation_names": [ + "block_hold", + "cast_aoe", + "cast_directed_loop", + "cast_directed_windup", + "crouch_idle", + "crouch_walk", + "death_b", + "death_f", + "deflect_c", + "deflect_l", + "deflect_r", + "gun_grip_trigger_discipline", + "idle", + "kneel_loop", + "meditate_loop", + "melee_draw", + "melee_draw_alt", + "melee_flinch", + "melee_grip", + "melee_idle", + "melee_ready", + "melee_run_f", + "melee_run_v2", + "melee_sheath", + "melee_strafe_l", + "melee_strafe_r", + "melee_turn_180", + "melee_turn_90", + "melee_walk_f", + "melee_walk_v2", + "reload", + "rifle_aim", + "rifle_fire", + "rifle_idle", + "rifle_run_f", + "rifle_walk_f", + "run_f", + "stim_inject", + "strafe_l", + "strafe_r", + "swing_h1", + "swing_h2", + "swing_h3", + "swing_spin_aoe", + "swing_thrust", + "walk_b", + "walk_f" + ], + "merged_skin_primitives": [ + { + "slot": 0, + "material": "hum_f_body", + "vertices": 790 + }, + { + "slot": 1, + "material": "hum_f_head", + "vertices": 12396 + } + ], + "face_panel_uv_rect": [ + 0.25, + 0.324, + 0.5, + 0.94 + ], + "primitives": [ + { + "material": "BodyZone_torso", + "zone": "torso", + "vertices": 321, + "triangles": 397, + "area_cm2": 3190.456, + "shading_min": 0.9798156023025513, + "shading_mean": 0.9956191778182983 + }, + { + "material": "BodyZone_pelvis", + "zone": "pelvis", + "vertices": 105, + "triangles": 146, + "area_cm2": 1876.243, + "shading_min": 0.9345221519470215, + "shading_mean": 0.9885234832763672 + }, + { + "material": "BodyZone_neck", + "zone": "neck", + "vertices": 164, + "triangles": 66, + "area_cm2": 63.203, + "shading_min": 0.9839693307876587, + "shading_mean": 0.9905405044555664 + }, + { + "material": "BodyZone_head", + "zone": "head", + "vertices": 681, + "triangles": 227, + "area_cm2": 861.892, + "shading_min": 0.9876691699028015, + "shading_mean": 0.9991410374641418 + }, + { + "material": "BodyZone_left_upper_arm", + "zone": "left_upper_arm", + "vertices": 58, + "triangles": 84, + "area_cm2": 981.422, + "shading_min": 0.9972501397132874, + "shading_mean": 0.9995008707046509 + }, + { + "material": "BodyZone_right_upper_arm", + "zone": "right_upper_arm", + "vertices": 58, + "triangles": 84, + "area_cm2": 981.422, + "shading_min": 0.997032105922699, + "shading_mean": 0.9996808767318726 + }, + { + "material": "BodyZone_left_forearm", + "zone": "left_forearm", + "vertices": 245, + "triangles": 133, + "area_cm2": 477.372, + "shading_min": 0.8959680795669556, + "shading_mean": 0.9503381848335266 + }, + { + "material": "BodyZone_right_forearm", + "zone": "right_forearm", + "vertices": 245, + "triangles": 133, + "area_cm2": 477.372, + "shading_min": 0.8976382613182068, + "shading_mean": 0.9505999684333801 + }, + { + "material": "BodyZone_left_hand", + "zone": "left_hand", + "vertices": 1088, + "triangles": 2032, + "area_cm2": 480.741, + "shading_min": 1.0, + "shading_mean": 1.0, + "geometry_source": "content-pipeline/labs/humanoid-runtime-refit/build/reference/female.glb" + }, + { + "material": "BodyZone_right_hand", + "zone": "right_hand", + "vertices": 1088, + "triangles": 2032, + "area_cm2": 480.741, + "shading_min": 1.0, + "shading_mean": 1.0, + "geometry_source": "content-pipeline/labs/humanoid-runtime-refit/build/reference/female.glb" + }, + { + "material": "BodyZone_left_thigh", + "zone": "left_thigh", + "vertices": 62, + "triangles": 77, + "area_cm2": 1580.901, + "shading_min": 0.9387226700782776, + "shading_mean": 0.9866659045219421 + }, + { + "material": "BodyZone_right_thigh", + "zone": "right_thigh", + "vertices": 62, + "triangles": 77, + "area_cm2": 1580.901, + "shading_min": 0.9415721893310547, + "shading_mean": 0.9872750639915466 + }, + { + "material": "BodyZone_left_calf", + "zone": "left_calf", + "vertices": 77, + "triangles": 101, + "area_cm2": 1050.905, + "shading_min": 0.984488844871521, + "shading_mean": 0.9987558126449585 + }, + { + "material": "BodyZone_right_calf", + "zone": "right_calf", + "vertices": 77, + "triangles": 101, + "area_cm2": 1050.905, + "shading_min": 0.9850500226020813, + "shading_mean": 0.9990007281303406 + }, + { + "material": "BodyZone_left_foot", + "zone": "left_foot", + "vertices": 56, + "triangles": 68, + "area_cm2": 603.509, + "shading_min": 0.9981849789619446, + "shading_mean": 0.9995891451835632 + }, + { + "material": "BodyZone_right_foot", + "zone": "right_foot", + "vertices": 56, + "triangles": 68, + "area_cm2": 603.509, + "shading_min": 0.9984211921691895, + "shading_mean": 0.9997367262840271 + }, + { + "material": "BodyZone_head", + "zone": "head", + "role": "face_panel_skin_base", + "vertices": 168, + "triangles": 56, + "shading_min": 1.0, + "shading_mean": 1.0 + }, + { + "material": "RB_Face", + "zone": null, + "role": "transparent_face_overlay", + "vertices": 168, + "triangles": 56, + "inflate_m": 0.0015 + } + ], + "zone_segmentation": { + "welded_points": 3062, + "boundary_edges": 84, + "relax_passes": 3, + "relax_weight": 0.5, + "speck_area_cm2": 2.0, + "specks_absorbed": [], + "seams": [ + [ + "head", + "neck" + ], + [ + "left_calf", + "left_foot" + ], + [ + "left_calf", + "left_thigh" + ], + [ + "left_thigh", + "pelvis" + ], + [ + "left_upper_arm", + "torso" + ], + [ + "neck", + "torso" + ], + [ + "pelvis", + "right_thigh" + ], + [ + "pelvis", + "torso" + ], + [ + "right_calf", + "right_foot" + ], + [ + "right_calf", + "right_thigh" + ], + [ + "right_upper_arm", + "torso" + ] + ], + "zones": { + "torso": { + "triangles": 397, + "patches": 1, + "area_cm2": 3190.456, + "centroid_y_range_m": [ + 1.0881, + 1.5253 + ] + }, + "pelvis": { + "triangles": 146, + "patches": 1, + "area_cm2": 1876.243, + "centroid_y_range_m": [ + 0.8449, + 1.1148 + ] + }, + "neck": { + "triangles": 66, + "patches": 1, + "area_cm2": 63.203, + "centroid_y_range_m": [ + 1.5038, + 1.5378 + ] + }, + "head": { + "triangles": 227, + "patches": 1, + "area_cm2": 861.892, + "centroid_y_range_m": [ + 1.5378, + 1.7455 + ] + }, + "left_upper_arm": { + "triangles": 84, + "patches": 1, + "area_cm2": 981.422, + "centroid_y_range_m": [ + 1.3809, + 1.4893 + ] + }, + "right_upper_arm": { + "triangles": 84, + "patches": 1, + "area_cm2": 981.422, + "centroid_y_range_m": [ + 1.3809, + 1.4893 + ] + }, + "left_forearm": { + "triangles": 133, + "patches": 2, + "area_cm2": 477.372, + "centroid_y_range_m": [ + 1.4063, + 1.4724 + ] + }, + "right_forearm": { + "triangles": 133, + "patches": 2, + "area_cm2": 477.372, + "centroid_y_range_m": [ + 1.4063, + 1.4724 + ] + }, + "left_hand": { + "triangles": 2032, + "patches": 36, + "area_cm2": 480.673, + "centroid_y_range_m": [ + 1.3825, + 1.4483 + ] + }, + "right_hand": { + "triangles": 2032, + "patches": 36, + "area_cm2": 480.673, + "centroid_y_range_m": [ + 1.3825, + 1.4483 + ] + }, + "left_thigh": { + "triangles": 77, + "patches": 1, + "area_cm2": 1580.901, + "centroid_y_range_m": [ + 0.4963, + 0.9195 + ] + }, + "right_thigh": { + "triangles": 77, + "patches": 1, + "area_cm2": 1580.901, + "centroid_y_range_m": [ + 0.4963, + 0.9195 + ] + }, + "left_calf": { + "triangles": 101, + "patches": 1, + "area_cm2": 1050.905, + "centroid_y_range_m": [ + 0.0923, + 0.5073 + ] + }, + "right_calf": { + "triangles": 101, + "patches": 1, + "area_cm2": 1050.905, + "centroid_y_range_m": [ + 0.0923, + 0.5073 + ] + }, + "left_foot": { + "triangles": 68, + "patches": 1, + "area_cm2": 603.509, + "centroid_y_range_m": [ + 0.0004, + 0.1015 + ] + }, + "right_foot": { + "triangles": 68, + "patches": 1, + "area_cm2": 603.509, + "centroid_y_range_m": [ + 0.0004, + 0.1015 + ] + } + } + }, + "compatibility": { + "rest_translation_drift_m": 2.942979335784912e-07, + "rest_rotation_drift_deg": 0.03843819931216527, + "inverse_bind_drift": 1.5944242477416992e-06 + }, + "canonical_hand_source": { + "path": "content-pipeline/labs/humanoid-runtime-refit/build/reference/female.glb", + "sha256": "16d8c879aabc58b5ee176edcfc0b0dc43fc695e7d59f4a2ef6d6a15890e7d000", + "compatibility": { + "rest_translation_drift_m": 9.601935744285583e-07, + "rest_rotation_drift_deg": 0.03837102297622957, + "inverse_bind_drift": 5.338340997695923e-06 + } + }, + "face_texture": "content-pipeline/labs/humanoid-runtime-refit/build/textures/face_default.png" + }, + "male": { + "output": "client-3d/public/assets/pawn-pack/pawn_male.glb", + "aliases": [ + "client-3d/public/assets/pawn-pack/pawn_male_bare.glb" + ], + "bytes": 2867012, + "sha256": "a865e80f3fe740c135633c2c03b94afcfb0d10def3756cb7ca43fec5e51e7f9c", + "source": "content-pipeline/labs/humanoid-runtime-refit/source/male/pawn_male_refined.glb", + "source_sha256": "d5cb14b321a5f6024820e6c49eaa7f4200dca303336c95758792847f34211401", + "source_sidecar": { + "body": "pawn_male_refined.glb", + "source_repository": "ssh://bunker/home/lycaon/git/successor.git", + "source_branch": "body-refine-20260802", + "source_commit": "d3fdfa2831bf938c0d9c8bf06f8796a00a935af5", + "source_sha256": "d5cb14b321a5f6024820e6c49eaa7f4200dca303336c95758792847f34211401", + "clips_from": "hash-pinned runtime shell during promotion", + "actions": 0, + "runtime_actions": 47, + "verts": 3226, + "tris": 5818, + "height": 1.7525, + "vertex_groups": 50, + "head": { + "method": "reviewed shared-topology 10-ring, 16-column faceted head and neck graft", + "face_panel_faces": 56, + "art_gates": { + "depth_over_height": true, + "rear_fill": true, + "occiput_peak": true + } + }, + "hands": { + "source": "verified legacy runtime hands", + "unchanged_by_body_refinement": true, + "islands_per_side": 36 + }, + "review": "Final Bunker humanoid refinement; immutable, head, transition, animation-proof, and viewer gates completed before commit." + }, + "joints": 50, + "animations": 47, + "animation_names": [ + "block_hold", + "cast_aoe", + "cast_directed_loop", + "cast_directed_windup", + "crouch_idle", + "crouch_walk", + "death_b", + "death_f", + "deflect_c", + "deflect_l", + "deflect_r", + "gun_grip_trigger_discipline", + "idle", + "kneel_loop", + "meditate_loop", + "melee_draw", + "melee_draw_alt", + "melee_flinch", + "melee_grip", + "melee_idle", + "melee_ready", + "melee_run_f", + "melee_run_v2", + "melee_sheath", + "melee_strafe_l", + "melee_strafe_r", + "melee_turn_180", + "melee_turn_90", + "melee_walk_f", + "melee_walk_v2", + "reload", + "rifle_aim", + "rifle_fire", + "rifle_idle", + "rifle_run_f", + "rifle_walk_f", + "run_f", + "stim_inject", + "strafe_l", + "strafe_r", + "swing_h1", + "swing_h2", + "swing_h3", + "swing_spin_aoe", + "swing_thrust", + "walk_b", + "walk_f" + ], + "merged_skin_primitives": [ + { + "slot": 0, + "material": "hum_m_body", + "vertices": 766 + }, + { + "slot": 1, + "material": "hum_m_head", + "vertices": 12343 + } + ], + "face_panel_uv_rect": [ + 0.25, + 0.324, + 0.5, + 0.94 + ], + "primitives": [ + { + "material": "BodyZone_torso", + "zone": "torso", + "vertices": 267, + "triangles": 311, + "area_cm2": 3855.826, + "shading_min": 0.9808084964752197, + "shading_mean": 0.9943628311157227 + }, + { + "material": "BodyZone_pelvis", + "zone": "pelvis", + "vertices": 88, + "triangles": 121, + "area_cm2": 1609.484, + "shading_min": 0.9399081468582153, + "shading_mean": 0.9871720671653748 + }, + { + "material": "BodyZone_neck", + "zone": "neck", + "vertices": 166, + "triangles": 77, + "area_cm2": 87.306, + "shading_min": 0.9806256890296936, + "shading_mean": 0.9859585762023926 + }, + { + "material": "BodyZone_head", + "zone": "head", + "vertices": 690, + "triangles": 230, + "area_cm2": 958.522, + "shading_min": 0.983392059803009, + "shading_mean": 0.998429536819458 + }, + { + "material": "BodyZone_left_upper_arm", + "zone": "left_upper_arm", + "vertices": 66, + "triangles": 97, + "area_cm2": 1212.401, + "shading_min": 0.9967027306556702, + "shading_mean": 0.9995764493942261 + }, + { + "material": "BodyZone_right_upper_arm", + "zone": "right_upper_arm", + "vertices": 66, + "triangles": 97, + "area_cm2": 1212.401, + "shading_min": 0.996150553226471, + "shading_mean": 0.9994533658027649 + }, + { + "material": "BodyZone_left_forearm", + "zone": "left_forearm", + "vertices": 246, + "triangles": 145, + "area_cm2": 518.889, + "shading_min": 0.8943880200386047, + "shading_mean": 0.9484396576881409 + }, + { + "material": "BodyZone_right_forearm", + "zone": "right_forearm", + "vertices": 246, + "triangles": 145, + "area_cm2": 518.889, + "shading_min": 0.8946832418441772, + "shading_mean": 0.9484513998031616 + }, + { + "material": "BodyZone_left_hand", + "zone": "left_hand", + "vertices": 1088, + "triangles": 2032, + "area_cm2": 480.673, + "shading_min": 1.0, + "shading_mean": 1.0, + "geometry_source": "content-pipeline/labs/humanoid-runtime-refit/build/reference/male.glb" + }, + { + "material": "BodyZone_right_hand", + "zone": "right_hand", + "vertices": 1088, + "triangles": 2032, + "area_cm2": 480.673, + "shading_min": 1.0, + "shading_mean": 1.0, + "geometry_source": "content-pipeline/labs/humanoid-runtime-refit/build/reference/male.glb" + }, + { + "material": "BodyZone_left_thigh", + "zone": "left_thigh", + "vertices": 69, + "triangles": 94, + "area_cm2": 1648.396, + "shading_min": 0.9353067874908447, + "shading_mean": 0.9859322905540466 + }, + { + "material": "BodyZone_right_thigh", + "zone": "right_thigh", + "vertices": 69, + "triangles": 95, + "area_cm2": 1676.093, + "shading_min": 0.934494137763977, + "shading_mean": 0.9855992197990417 + }, + { + "material": "BodyZone_left_calf", + "zone": "left_calf", + "vertices": 67, + "triangles": 87, + "area_cm2": 1293.324, + "shading_min": 0.9886006116867065, + "shading_mean": 0.9968411922454834 + }, + { + "material": "BodyZone_right_calf", + "zone": "right_calf", + "vertices": 67, + "triangles": 87, + "area_cm2": 1293.324, + "shading_min": 0.9886415600776672, + "shading_mean": 0.9966046810150146 + }, + { + "material": "BodyZone_left_foot", + "zone": "left_foot", + "vertices": 48, + "triangles": 56, + "area_cm2": 742.925, + "shading_min": 0.9961320161819458, + "shading_mean": 0.9990367889404297 + }, + { + "material": "BodyZone_right_foot", + "zone": "right_foot", + "vertices": 48, + "triangles": 56, + "area_cm2": 742.925, + "shading_min": 0.9961107969284058, + "shading_mean": 0.9990103840827942 + }, + { + "material": "BodyZone_head", + "zone": "head", + "role": "face_panel_skin_base", + "vertices": 168, + "triangles": 56, + "shading_min": 1.0, + "shading_mean": 1.0 + }, + { + "material": "RB_Face", + "zone": null, + "role": "transparent_face_overlay", + "vertices": 168, + "triangles": 56, + "inflate_m": 0.0015 + } + ], + "zone_segmentation": { + "welded_points": 3046, + "boundary_edges": 76, + "relax_passes": 3, + "relax_weight": 0.5, + "speck_area_cm2": 2.0, + "specks_absorbed": [], + "seams": [ + [ + "head", + "neck" + ], + [ + "left_calf", + "left_foot" + ], + [ + "left_calf", + "left_thigh" + ], + [ + "left_thigh", + "pelvis" + ], + [ + "left_upper_arm", + "torso" + ], + [ + "neck", + "torso" + ], + [ + "pelvis", + "right_thigh" + ], + [ + "pelvis", + "torso" + ], + [ + "right_calf", + "right_foot" + ], + [ + "right_calf", + "right_thigh" + ], + [ + "right_upper_arm", + "torso" + ] + ], + "zones": { + "torso": { + "triangles": 311, + "patches": 1, + "area_cm2": 3855.826, + "centroid_y_range_m": [ + 1.0634, + 1.5253 + ] + }, + "pelvis": { + "triangles": 121, + "patches": 1, + "area_cm2": 1609.484, + "centroid_y_range_m": [ + 0.8399, + 1.109 + ] + }, + "neck": { + "triangles": 77, + "patches": 1, + "area_cm2": 87.306, + "centroid_y_range_m": [ + 1.4937, + 1.5338 + ] + }, + "head": { + "triangles": 230, + "patches": 1, + "area_cm2": 958.522, + "centroid_y_range_m": [ + 1.5309, + 1.7453 + ] + }, + "left_upper_arm": { + "triangles": 97, + "patches": 1, + "area_cm2": 1212.401, + "centroid_y_range_m": [ + 1.3889, + 1.5114 + ] + }, + "right_upper_arm": { + "triangles": 97, + "patches": 1, + "area_cm2": 1212.401, + "centroid_y_range_m": [ + 1.3889, + 1.5114 + ] + }, + "left_forearm": { + "triangles": 145, + "patches": 2, + "area_cm2": 518.889, + "centroid_y_range_m": [ + 1.4176, + 1.4926 + ] + }, + "right_forearm": { + "triangles": 145, + "patches": 2, + "area_cm2": 518.889, + "centroid_y_range_m": [ + 1.4176, + 1.4926 + ] + }, + "left_hand": { + "triangles": 2032, + "patches": 36, + "area_cm2": 480.673, + "centroid_y_range_m": [ + 1.3825, + 1.4483 + ] + }, + "right_hand": { + "triangles": 2032, + "patches": 36, + "area_cm2": 480.673, + "centroid_y_range_m": [ + 1.3825, + 1.4483 + ] + }, + "left_thigh": { + "triangles": 94, + "patches": 1, + "area_cm2": 1648.396, + "centroid_y_range_m": [ + 0.5055, + 0.9329 + ] + }, + "right_thigh": { + "triangles": 95, + "patches": 1, + "area_cm2": 1676.093, + "centroid_y_range_m": [ + 0.5055, + 0.9329 + ] + }, + "left_calf": { + "triangles": 87, + "patches": 1, + "area_cm2": 1293.324, + "centroid_y_range_m": [ + 0.1049, + 0.5279 + ] + }, + "right_calf": { + "triangles": 87, + "patches": 1, + "area_cm2": 1293.324, + "centroid_y_range_m": [ + 0.1049, + 0.5279 + ] + }, + "left_foot": { + "triangles": 56, + "patches": 1, + "area_cm2": 742.925, + "centroid_y_range_m": [ + 0.0, + 0.1082 + ] + }, + "right_foot": { + "triangles": 56, + "patches": 1, + "area_cm2": 742.925, + "centroid_y_range_m": [ + 0.0, + 0.1082 + ] + } + } + }, + "compatibility": { + "rest_translation_drift_m": 2.942979335784912e-07, + "rest_rotation_drift_deg": 0.03843819931216527, + "inverse_bind_drift": 1.5944242477416992e-06 + }, + "canonical_hand_source": { + "path": "content-pipeline/labs/humanoid-runtime-refit/build/reference/male.glb", + "sha256": "6eaac9062bcfbc5efdf40c64f9e3236402a0d81832f1e0e8f4ce9a0a4dacac42", + "compatibility": { + "rest_translation_drift_m": 0.0, + "rest_rotation_drift_deg": 0.038466318374601585, + "inverse_bind_drift": 0.0 + } + }, + "face_texture": "content-pipeline/labs/humanoid-runtime-refit/build/textures/face_default.png" + } +} \ No newline at end of file diff --git a/content-pipeline/labs/humanoid-runtime-refit/reports/proof_manifest.json b/content-pipeline/labs/humanoid-runtime-refit/reports/proof_manifest.json new file mode 100644 index 00000000..8ae7aac6 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/reports/proof_manifest.json @@ -0,0 +1,298 @@ +{ + "generator": "successor content-pipeline/labs/humanoid-runtime-refit/proof_renders.py", + "size_px": 720, + "views_deg": { + "front": 0.0, + "side": 270.0, + "threequarter": 225.0, + "rear": 180.0 + }, + "loadout_poses": [ + { + "clip": null, + "phase": 0.0 + }, + { + "clip": "walk_f", + "phase": 0.25 + }, + { + "clip": "crouch_idle", + "phase": 0.5 + }, + { + "clip": "meditate_loop", + "phase": 0.5 + }, + { + "clip": "rifle_aim", + "phase": 0.5 + }, + { + "clip": "swing_h1", + "phase": 0.45 + } + ], + "cells": { + "body_male_front": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/body_male_front.png", + "sha256": "1f3b69fdaa45a41f1ec04876a3012ec85c2678cd64c18b68f2fbee4124b920f1", + "bytes": 545055 + }, + "body_male_side": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/body_male_side.png", + "sha256": "39dcf40a2dbf9eee9717fa45fdead9ee6ff080613fb6be1b66eee3d0648d2abb", + "bytes": 524024 + }, + "body_female_front": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/body_female_front.png", + "sha256": "61fe4566d54e88a44037fffefd0313d9f9d63e2f4e323eab3430ffdf42c35649", + "bytes": 538817 + }, + "body_female_side": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/body_female_side.png", + "sha256": "cb947f042232d114896afe4ef06b484291d2d48c97b82c463183ab360b3c92bc", + "bytes": 520832 + }, + "body_male_front_rifle_aim": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/body_male_front_rifle_aim.png", + "sha256": "0a6d969b881f3bdd076d9438714c774234e41c9516f45c8dc15017bc8d7e4957", + "bytes": 549504 + }, + "body_male_side_rifle_aim": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/body_male_side_rifle_aim.png", + "sha256": "77afc19275825e64c410daae4e6312e437e8370bc53df19b7e0ae50ac4872ed6", + "bytes": 555237 + }, + "body_male_threequarter_rifle_aim": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/body_male_threequarter_rifle_aim.png", + "sha256": "6971c5d169acfeb8e0dc209007f2db5a17d995036e893fe0f7352afae35be304", + "bytes": 540774 + }, + "body_male_front_swing_h1": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/body_male_front_swing_h1.png", + "sha256": "ea415bee5707783954e665b0352b923cf136128166c7bab355480075d237089e", + "bytes": 553132 + }, + "body_male_side_swing_h1": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/body_male_side_swing_h1.png", + "sha256": "fce4a31a969fcbc8317b65a3f709954880e4dde3c29252869ec8ea7d4bd9a2bb", + "bytes": 556984 + }, + "body_male_threequarter_swing_h1": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/body_male_threequarter_swing_h1.png", + "sha256": "e43299ec5f1a132551bd1360fe2ec23f8c225d74c359dc9617c4dd64f1667ef4", + "bytes": 540962 + }, + "body_female_front_rifle_aim": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/body_female_front_rifle_aim.png", + "sha256": "974b7d3d3da2561d981718f82d7b376c5b2e70f530e921a5c358795ecae0406d", + "bytes": 547034 + }, + "body_female_side_rifle_aim": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/body_female_side_rifle_aim.png", + "sha256": "5c1e90d852783b7a55d538c8f63dff8078a980c46df298acee45239ccab4d7f1", + "bytes": 552655 + }, + "body_female_threequarter_rifle_aim": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/body_female_threequarter_rifle_aim.png", + "sha256": "d7096effa34d966fb4c9b31b0cab08c3fd5582df47575995cce3ff59191d7f74", + "bytes": 537580 + }, + "body_female_front_swing_h1": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/body_female_front_swing_h1.png", + "sha256": "29305dd69d47c2a1a20d315e79d43818ee2595280bd139ef5835b5c45f1fa93e", + "bytes": 549965 + }, + "body_female_side_swing_h1": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/body_female_side_swing_h1.png", + "sha256": "9137f67fe46d21332c9a1c33541b9d260e640a476abf74bc78898e7e65ce1fbe", + "bytes": 556055 + }, + "body_female_threequarter_swing_h1": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/body_female_threequarter_swing_h1.png", + "sha256": "3660d1746eaab2c0fc08da598588b67c05c5ab48e7050c0f618781f21fd8b447", + "bytes": 541395 + }, + "starter_male_front": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_male_front.png", + "sha256": "91bd45c4fc0f827622dab0136f4cc1a8e1ca412e3b570d4fbd181f4ba74b5c48", + "bytes": 539892 + }, + "starter_male_side": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_male_side.png", + "sha256": "8bd91c1a9691e75d589eaced461145e248939695629c65d43fe480eab46e6df9", + "bytes": 519844 + }, + "starter_male_threequarter": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_male_threequarter.png", + "sha256": "1e83a1c0762f605eb8203c5a81481ff1812c50824dec9e18400c596e46e2d4e3", + "bytes": 532937 + }, + "starter_male_front_walk": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_male_front_walk.png", + "sha256": "1d2db0ae7d3b5d12aa06f992379cd497fe1c37a99bdec754b52bede28d422972", + "bytes": 561714 + }, + "starter_male_side_walk": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_male_side_walk.png", + "sha256": "74f88c3d5a43c4d6acd27c5ff675281947c61d9d966c0b7bb9c79d87b109cf49", + "bytes": 564114 + }, + "starter_male_threequarter_walk": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_male_threequarter_walk.png", + "sha256": "404177cb789b10c338681f38046c936091db57883abba4dc686348cecf3d271a", + "bytes": 560490 + }, + "starter_male_front_crouch": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_male_front_crouch.png", + "sha256": "2d4c88bdba187077a9693fe071694cd8c6ba8d10e68bbbc68a0fd97f6509812a", + "bytes": 590199 + }, + "starter_male_side_crouch": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_male_side_crouch.png", + "sha256": "79e8211000a8c6d4add6ab5ac8e1ff5d01b6c443d185bb59a06d7c8be62af62e", + "bytes": 569346 + }, + "starter_male_threequarter_crouch": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_male_threequarter_crouch.png", + "sha256": "c67c438f87819fc92faccba23f146de4daf9f8f52a5c5886453c78a326a4224f", + "bytes": 574178 + }, + "starter_male_front_meditate": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_male_front_meditate.png", + "sha256": "8e93d5536928b1812aca13c568be4acc35323046ba3ea409c63a07d63d39eea4", + "bytes": 587869 + }, + "starter_male_side_meditate": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_male_side_meditate.png", + "sha256": "990289fd985f3064487194babda02f58b5b95e0271b8cf66083a0d7bea9b6bc5", + "bytes": 556054 + }, + "starter_male_threequarter_meditate": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_male_threequarter_meditate.png", + "sha256": "e7006b4de74b07b6c6090cd334b30c87370f98f9c5ac4ca95797c2e17352de06", + "bytes": 572213 + }, + "starter_male_front_rifle_aim": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_male_front_rifle_aim.png", + "sha256": "7c17464316ea33a0486b00af832944706e20cb563ab0802394197f6b85d09841", + "bytes": 555473 + }, + "starter_male_side_rifle_aim": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_male_side_rifle_aim.png", + "sha256": "33c662a1acd09abe19556eba83137a93bb28985d1bedab4ec25319fd3978ec55", + "bytes": 560142 + }, + "starter_male_threequarter_rifle_aim": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_male_threequarter_rifle_aim.png", + "sha256": "06274b48c8d38827e18e0791f0759b35d1ec4e27a239cd323956da4c0ab99fcb", + "bytes": 543948 + }, + "starter_male_front_swing_h1": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_male_front_swing_h1.png", + "sha256": "45be392dad9b8b082d514173765b4b5860e05ff4b02c7bf82905d081996dc4da", + "bytes": 555446 + }, + "starter_male_side_swing_h1": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_male_side_swing_h1.png", + "sha256": "64a0b54af7d2403c724f6d961f59d17c131e177283fa192436eee1261a0da68d", + "bytes": 560604 + }, + "starter_male_threequarter_swing_h1": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_male_threequarter_swing_h1.png", + "sha256": "db814f8063bd6565513208b18159a875f15edc2fa1e26053b98c85fa3a82db31", + "bytes": 542546 + }, + "starter_female_front": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_female_front.png", + "sha256": "a3723c6131a7b051db5cd7ec55c794b9d8a6423794173b095fc61c496dfb82db", + "bytes": 533758 + }, + "starter_female_side": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_female_side.png", + "sha256": "1c0a14b3cfa1c524513169089d793be21c76d5c383976f45d2aba9528c97d3b1", + "bytes": 517909 + }, + "starter_female_threequarter": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_female_threequarter.png", + "sha256": "2f5087cc649280b1bc653e79adb385ce4db16abe70f57e737ec1f285b83b756a", + "bytes": 529137 + }, + "starter_female_front_walk": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_female_front_walk.png", + "sha256": "82c24394dff1eb4a63b8a488b824c9464779cb380c7bde0f6fe663a18b8a1ef8", + "bytes": 555955 + }, + "starter_female_side_walk": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_female_side_walk.png", + "sha256": "51ad6f5c2639271cb6edda962fd88eed1bcae1a0d381c5f943d3ce179cba5a99", + "bytes": 561162 + }, + "starter_female_threequarter_walk": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_female_threequarter_walk.png", + "sha256": "ac0a0b4b09c97e43900e80119afcf526a2ca0c4fab793cc1dc94799790a6f395", + "bytes": 554691 + }, + "starter_female_front_crouch": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_female_front_crouch.png", + "sha256": "73f9a8917a755d10f2aa6cc7624a6c2a76fd1e4e71d86bc622ff215464c3eb06", + "bytes": 585761 + }, + "starter_female_side_crouch": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_female_side_crouch.png", + "sha256": "af59ecfc8702a4de0a10e791424ea38a037ab27f398f11c6d7af13e2bdbf6e76", + "bytes": 566762 + }, + "starter_female_threequarter_crouch": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_female_threequarter_crouch.png", + "sha256": "96924f3b0048829794e2d0adf3de2e248bf2a7cb2c8f99c8cb88ffbcbefa63b3", + "bytes": 568548 + }, + "starter_female_front_meditate": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_female_front_meditate.png", + "sha256": "0ced687c70a343f9fd547f86f1e71002a8528ebe96b89261071adf1768080620", + "bytes": 583118 + }, + "starter_female_side_meditate": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_female_side_meditate.png", + "sha256": "3f4438fb1248dee8cd974ef7fedf8063275c44ac65f68bdc629e72b41bc65809", + "bytes": 554851 + }, + "starter_female_threequarter_meditate": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_female_threequarter_meditate.png", + "sha256": "342cca95d74d84f6cf92d9534f1507abf0dd41a631832f56e229ac0efe28669e", + "bytes": 569582 + }, + "starter_female_front_rifle_aim": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_female_front_rifle_aim.png", + "sha256": "0e318c957c53df571c1fe2d7454ef6b3afed991eb282e0a7a58cda64a275023f", + "bytes": 551323 + }, + "starter_female_side_rifle_aim": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_female_side_rifle_aim.png", + "sha256": "8edf60606c2a0f96a7117a6cf5280aa059ab004ee23a08cfaebabe3634b08b71", + "bytes": 556366 + }, + "starter_female_threequarter_rifle_aim": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_female_threequarter_rifle_aim.png", + "sha256": "8cf5f17707d090ba688cd4eef91ce32cfa9a5631c92ae367b2d64213222dd3f4", + "bytes": 540134 + }, + "starter_female_front_swing_h1": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_female_front_swing_h1.png", + "sha256": "9837560b51d96fa471b6717897082af8da55dbcdaca7b12f80e56f3a58cd1962", + "bytes": 553771 + }, + "starter_female_side_swing_h1": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_female_side_swing_h1.png", + "sha256": "046a7497689a10afa0823316deb5968c8038dea6a001281995e41bae1a1509fc", + "bytes": 559840 + }, + "starter_female_threequarter_swing_h1": { + "path": "content-pipeline/labs/humanoid-runtime-refit/proof/cells/starter_female_threequarter_swing_h1.png", + "sha256": "52270d463727039df862a2d720a66eb051cfc96a392d5a61112034ed95366c0e", + "bytes": 543094 + } + } +} diff --git a/content-pipeline/labs/humanoid-runtime-refit/reports/rigid_mount_fit.json b/content-pipeline/labs/humanoid-runtime-refit/reports/rigid_mount_fit.json new file mode 100644 index 00000000..3ed84f4b --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/reports/rigid_mount_fit.json @@ -0,0 +1,397 @@ +{ + "failures": [ + "female: hat_field_cap floats 10.7 mm off the head (> 3 mm)", + "male: lightning_carbine grip buried 65.9 mm (> 30 mm)", + "female: lightning_carbine grip buried 65.9 mm (> 30 mm)", + "male: wpn_carbine grip buried 66.2 mm (> 30 mm)", + "female: wpn_carbine grip buried 66.2 mm (> 30 mm)", + "male: wpn_launcher grip buried 64.9 mm (> 30 mm)", + "female: wpn_launcher grip buried 64.9 mm (> 30 mm)", + "male: wpn_smg grip buried 66.2 mm (> 30 mm)", + "female: wpn_smg grip buried 66.2 mm (> 30 mm)", + "male: wpn_sniper grip buried 66.2 mm (> 30 mm)", + "female: wpn_sniper grip buried 66.2 mm (> 30 mm)" + ], + "items": { + "field_saber": { + "bone": "hand_r", + "delta_pos_mm": 0.0, + "female": { + "clearance_mm": -19.017, + "max_depth_mm": 19.017, + "mount_world_pos": [ + -0.88102, + 1.42927, + 0.01644 + ] + }, + "male": { + "clearance_mm": -19.018, + "max_depth_mm": 19.018, + "mount_world_pos": [ + -0.88102, + 1.42927, + 0.01644 + ] + } + }, + "hat_field_cap": { + "bone": "head", + "delta_pos_mm": 0.0, + "female": { + "clearance_mm": 10.738, + "max_depth_mm": 0.0, + "mount_world_pos": [ + 0.0, + 1.55, + -0.0 + ] + }, + "male": { + "clearance_mm": -1.253, + "max_depth_mm": 1.253, + "mount_world_pos": [ + 0.0, + 1.55, + -0.0 + ] + } + }, + "lightning_carbine": { + "bone": "hand_r", + "delta_pos_mm": 0.0, + "female": { + "clearance_mm": -65.902, + "max_depth_mm": 65.902, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + }, + "male": { + "clearance_mm": -65.902, + "max_depth_mm": 65.902, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + } + }, + "plasma_hilt": { + "bone": "hand_r", + "delta_pos_mm": 0.0, + "female": { + "clearance_mm": 58.944, + "max_depth_mm": 0.0, + "mount_world_pos": [ + -0.933, + 1.49021, + 0.14622 + ] + }, + "male": { + "clearance_mm": 58.945, + "max_depth_mm": 0.0, + "mount_world_pos": [ + -0.933, + 1.49021, + 0.14622 + ] + } + }, + "plasma_sword": { + "bone": "hand_r", + "delta_pos_mm": 0.0, + "female": { + "clearance_mm": -4.527, + "max_depth_mm": 4.527, + "mount_world_pos": [ + -0.87898, + 1.42687, + 0.01134 + ] + }, + "male": { + "clearance_mm": -4.525, + "max_depth_mm": 4.525, + "mount_world_pos": [ + -0.87898, + 1.42687, + 0.01134 + ] + } + }, + "quarry_chopper": { + "bone": "hand_r", + "delta_pos_mm": 0.0, + "female": { + "clearance_mm": -8.216, + "max_depth_mm": 8.216, + "mount_world_pos": [ + -0.88102, + 1.42927, + 0.01644 + ] + }, + "male": { + "clearance_mm": -8.215, + "max_depth_mm": 8.215, + "mount_world_pos": [ + -0.88102, + 1.42927, + 0.01644 + ] + } + }, + "scrapline_machete": { + "bone": "hand_r", + "delta_pos_mm": 0.0, + "female": { + "clearance_mm": -8.622, + "max_depth_mm": 8.622, + "mount_world_pos": [ + -0.88102, + 1.42927, + 0.01644 + ] + }, + "male": { + "clearance_mm": -8.621, + "max_depth_mm": 8.621, + "mount_world_pos": [ + -0.88102, + 1.42927, + 0.01644 + ] + } + }, + "slugthrower": { + "bone": "hand_r", + "delta_pos_mm": 0.0, + "female": { + "clearance_mm": -6.753, + "max_depth_mm": 6.753, + "mount_world_pos": [ + -0.95308, + 1.40112, + 0.05585 + ] + }, + "male": { + "clearance_mm": -6.751, + "max_depth_mm": 6.751, + "mount_world_pos": [ + -0.95308, + 1.40112, + 0.05585 + ] + } + }, + "vibrosword": { + "bone": "hand_r", + "delta_pos_mm": 0.0, + "female": { + "clearance_mm": -8.307, + "max_depth_mm": 8.307, + "mount_world_pos": [ + -0.933, + 1.49021, + 0.14622 + ] + }, + "male": { + "clearance_mm": -8.306, + "max_depth_mm": 8.306, + "mount_world_pos": [ + -0.933, + 1.49021, + 0.14622 + ] + } + }, + "wpn_assault": { + "bone": "hand_r", + "delta_pos_mm": 0.0, + "female": { + "clearance_mm": -4.988, + "max_depth_mm": 4.988, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + }, + "male": { + "clearance_mm": -4.987, + "max_depth_mm": 4.987, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + } + }, + "wpn_carbine": { + "bone": "hand_r", + "delta_pos_mm": 0.0, + "female": { + "clearance_mm": -66.176, + "max_depth_mm": 66.176, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + }, + "male": { + "clearance_mm": -66.176, + "max_depth_mm": 66.176, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + } + }, + "wpn_heavy": { + "bone": "hand_r", + "delta_pos_mm": 0.0, + "female": { + "clearance_mm": -4.988, + "max_depth_mm": 4.988, + "mount_world_pos": [ + -1.12789, + 1.3512, + -0.04882 + ] + }, + "male": { + "clearance_mm": -4.987, + "max_depth_mm": 4.987, + "mount_world_pos": [ + -1.12789, + 1.3512, + -0.04882 + ] + } + }, + "wpn_launcher": { + "bone": "hand_r", + "delta_pos_mm": 0.0, + "female": { + "clearance_mm": -64.904, + "max_depth_mm": 64.904, + "mount_world_pos": [ + -1.12789, + 1.3512, + -0.04882 + ] + }, + "male": { + "clearance_mm": -64.904, + "max_depth_mm": 64.904, + "mount_world_pos": [ + -1.12789, + 1.3512, + -0.04882 + ] + } + }, + "wpn_pistol": { + "bone": "hand_r", + "delta_pos_mm": 0.0, + "female": { + "clearance_mm": -8.806, + "max_depth_mm": 8.806, + "mount_world_pos": [ + -0.96525, + 1.3933, + -0.03986 + ] + }, + "male": { + "clearance_mm": -8.806, + "max_depth_mm": 8.806, + "mount_world_pos": [ + -0.96525, + 1.3933, + -0.03986 + ] + } + }, + "wpn_shotgun": { + "bone": "hand_r", + "delta_pos_mm": 0.0, + "female": { + "clearance_mm": -8.645, + "max_depth_mm": 8.645, + "mount_world_pos": [ + -1.17527, + 1.33959, + -0.03814 + ] + }, + "male": { + "clearance_mm": -8.644, + "max_depth_mm": 8.644, + "mount_world_pos": [ + -1.17527, + 1.33959, + -0.03814 + ] + } + }, + "wpn_smg": { + "bone": "hand_r", + "delta_pos_mm": 0.0, + "female": { + "clearance_mm": -66.176, + "max_depth_mm": 66.176, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + }, + "male": { + "clearance_mm": -66.176, + "max_depth_mm": 66.176, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + } + }, + "wpn_sniper": { + "bone": "hand_r", + "delta_pos_mm": 0.0, + "female": { + "clearance_mm": -66.176, + "max_depth_mm": 66.176, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + }, + "male": { + "clearance_mm": -66.176, + "max_depth_mm": 66.176, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + } + } + }, + "limits": { + "grip_depth_mm": 30.0, + "head_float_mm": 3.0, + "variant_pos_mm": 1.0 + }, + "schema": "successor.rigid-mount-fit.v1" +} \ No newline at end of file diff --git a/content-pipeline/labs/humanoid-runtime-refit/reports/starter_apparel.json b/content-pipeline/labs/humanoid-runtime-refit/reports/starter_apparel.json new file mode 100644 index 00000000..877439b6 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/reports/starter_apparel.json @@ -0,0 +1,171 @@ +{ + "generator": "successor content-pipeline/labs/humanoid-runtime-refit/author_starter_apparel.py", + "bodies": { + "male": { + "body": "client-3d/public/assets/pawn-pack/pawn_male.glb", + "body_sha256": "a865e80f3fe740c135633c2c03b94afcfb0d10def3756cb7ca43fec5e51e7f9c", + "under_bodysuit": { + "path": "client-3d/public/assets/pawn-pack/equipment/Under/under_bodysuit.glb", + "bytes": 58648, + "sha256": "24e67fe6ab502546f58a07cb9be24e87880cb2d341f4e2c56488eee37bde2d0b", + "vertices": 782, + "triangles": 1473, + "offset_mm": 1.0, + "zones": [ + "torso", + "pelvis", + "left_upper_arm", + "right_upper_arm", + "left_forearm", + "right_forearm", + "left_thigh", + "right_thigh", + "left_calf", + "right_calf" + ], + "bbox_min": [ + -0.825, + 0.07358, + -0.11524 + ], + "bbox_max": [ + 0.825, + 1.52882, + 0.14956 + ] + }, + "boots_canvas_ankle": { + "path": "client-3d/public/assets/pawn-pack/equipment/Under/boots_canvas_ankle.glb", + "bytes": 31872, + "sha256": "5980ff35c965522e2e88a03b63abdea9a75f49982c5e90fbd4a6c54f3f461cd6", + "offset_mm": 10.0, + "shaft_top_m": 0.215, + "trim_bottom_m": 0.185, + "sole_floor_m": -0.004, + "sides": [ + { + "mesh": "boots_canvas_ankle_l", + "vertices": 84, + "triangles": 152, + "rim_triangles": 28, + "trim_triangles": 34, + "sole_triangles": 9, + "upper_triangles": 109, + "bbox_min": [ + 0.01736, + -0.004, + -0.08373 + ], + "bbox_max": [ + 0.14289, + 0.21549, + 0.19618 + ] + }, + { + "mesh": "boots_canvas_ankle_r", + "vertices": 84, + "triangles": 152, + "rim_triangles": 28, + "trim_triangles": 33, + "sole_triangles": 9, + "upper_triangles": 110, + "bbox_min": [ + -0.14289, + -0.004, + -0.08373 + ], + "bbox_max": [ + -0.01736, + 0.21549, + 0.19618 + ] + } + ] + } + }, + "female": { + "body": "client-3d/public/assets/pawn-pack/pawn_female.glb", + "body_sha256": "9eb7242ce49fcd7d7afa53a898c35e5b7b5fd0d5d99d7109bfc82e33e4f095e9", + "under_bodysuit": { + "path": "client-3d/public/assets/pawn-pack/equipment/Female/Under/under_bodysuit.glb", + "bytes": 60608, + "sha256": "a4500baef1a8f9fdab63b472b2b2fc04c31c0ec7a93f84321ae43039e807de75", + "vertices": 817, + "triangles": 1543, + "offset_mm": 1.0, + "zones": [ + "torso", + "pelvis", + "left_upper_arm", + "right_upper_arm", + "left_forearm", + "right_forearm", + "left_thigh", + "right_thigh", + "left_calf", + "right_calf" + ], + "bbox_min": [ + -0.825, + 0.05723, + -0.09718 + ], + "bbox_max": [ + 0.825, + 1.52847, + 0.1516 + ] + }, + "boots_canvas_ankle": { + "path": "client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_canvas_ankle.glb", + "bytes": 33220, + "sha256": "24fb7c94ad564ded849884cb8e750e9898e85a8703a86012369404c845e93a51", + "offset_mm": 10.0, + "shaft_top_m": 0.215, + "trim_bottom_m": 0.185, + "sole_floor_m": -0.004, + "sides": [ + { + "mesh": "boots_canvas_ankle_l", + "vertices": 96, + "triangles": 176, + "rim_triangles": 28, + "trim_triangles": 37, + "sole_triangles": 12, + "upper_triangles": 127, + "bbox_min": [ + 0.02941, + -0.004, + -0.09631 + ], + "bbox_max": [ + 0.12945, + 0.21492, + 0.17378 + ] + }, + { + "mesh": "boots_canvas_ankle_r", + "vertices": 97, + "triangles": 178, + "rim_triangles": 28, + "trim_triangles": 37, + "sole_triangles": 12, + "upper_triangles": 129, + "bbox_min": [ + -0.12945, + -0.004, + -0.09631 + ], + "bbox_max": [ + -0.02941, + 0.21492, + 0.17378 + ] + } + ] + } + } + } +} \ No newline at end of file diff --git a/content-pipeline/labs/humanoid-runtime-refit/reports/starter_fit.json b/content-pipeline/labs/humanoid-runtime-refit/reports/starter_fit.json new file mode 100644 index 00000000..9b5442f9 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/reports/starter_fit.json @@ -0,0 +1,143 @@ +{ + "generator": "successor content-pipeline/labs/humanoid-runtime-refit/verify_starter_fit.py", + "open_rim_limit_mm": 1.0, + "excess_depth_limit_mm": 1.0, + "collar_band_mm": 20.0, + "required_hidden_zones": [ + "left_foot", + "right_foot" + ], + "bodies": { + "male": { + "suit": "client-3d/public/assets/pawn-pack/equipment/Under/under_bodysuit.glb", + "boots": "client-3d/public/assets/pawn-pack/equipment/Under/boots_canvas_ankle.glb", + "suit_hidden_zones": [ + "left_calf", + "left_forearm", + "left_thigh", + "left_upper_arm", + "pelvis", + "right_calf", + "right_forearm", + "right_thigh", + "right_upper_arm", + "torso" + ], + "loadout_hidden_zones": [ + "left_calf", + "left_foot", + "left_forearm", + "left_thigh", + "left_upper_arm", + "pelvis", + "right_calf", + "right_foot", + "right_forearm", + "right_thigh", + "right_upper_arm", + "torso" + ], + "foot_zones_hidden": [ + "left_foot", + "right_foot" + ], + "depth_mm": { + "suit_excess_depth_mm": -0.045, + "suit_depth_mm": -28.089, + "bare_body_depth_mm": -28.043, + "bare_leg_contact_mm": -21.901, + "boots_into_suit_away_from_collar_mm": -28.852, + "boots_into_suit_at_collar_mm": -6.416, + "hidden_skin_penetration_mm": -0.954 + }, + "counts": { + "suit_vs_visible_pairs": 442, + "bare_vs_visible_pairs": 506, + "boots_vs_suit_pairs": 94, + "suit_rim_gap_mm": 0.0, + "boot_rim_gap_mm": 0.544 + }, + "worst_pose": { + "suit_excess_depth_mm": "crouch_idle@0.5", + "suit_depth_mm": "crouch_idle@0.5", + "bare_body_depth_mm": "crouch_idle@0.5", + "bare_leg_contact_mm": "meditate_loop@0.5", + "boots_into_suit_away_from_collar_mm": "meditate_loop@0.5", + "boots_into_suit_at_collar_mm": "meditate_loop@0.5", + "hidden_skin_penetration_mm": "swing_h1@0.4", + "suit_vs_visible_pairs": "meditate_loop@0.5", + "bare_vs_visible_pairs": "meditate_loop@0.5", + "boots_vs_suit_pairs": "meditate_loop@0.5", + "suit_rim_gap_mm": null, + "boot_rim_gap_mm": "meditate_loop@0.5" + }, + "boot_suit_allowance_mm": 33.901 + }, + "female": { + "suit": "client-3d/public/assets/pawn-pack/equipment/Female/Under/under_bodysuit.glb", + "boots": "client-3d/public/assets/pawn-pack/equipment/Female/Under/boots_canvas_ankle.glb", + "suit_hidden_zones": [ + "left_calf", + "left_forearm", + "left_thigh", + "left_upper_arm", + "pelvis", + "right_calf", + "right_forearm", + "right_thigh", + "right_upper_arm", + "torso" + ], + "loadout_hidden_zones": [ + "left_calf", + "left_foot", + "left_forearm", + "left_thigh", + "left_upper_arm", + "pelvis", + "right_calf", + "right_foot", + "right_forearm", + "right_thigh", + "right_upper_arm", + "torso" + ], + "foot_zones_hidden": [ + "left_foot", + "right_foot" + ], + "depth_mm": { + "suit_excess_depth_mm": -0.329, + "suit_depth_mm": -9.616, + "bare_body_depth_mm": -9.698, + "bare_leg_contact_mm": -20.296, + "boots_into_suit_away_from_collar_mm": -15.601, + "boots_into_suit_at_collar_mm": -2.531, + "hidden_skin_penetration_mm": -0.959 + }, + "counts": { + "suit_vs_visible_pairs": 342, + "bare_vs_visible_pairs": 381, + "boots_vs_suit_pairs": 76, + "suit_rim_gap_mm": 0.0, + "boot_rim_gap_mm": 0.0 + }, + "worst_pose": { + "suit_excess_depth_mm": "crouch_walk@0.3", + "suit_depth_mm": "run_f@0.7", + "bare_body_depth_mm": "run_f@0.2", + "bare_leg_contact_mm": "meditate_loop@0.5", + "boots_into_suit_away_from_collar_mm": "meditate_loop@0.5", + "boots_into_suit_at_collar_mm": "meditate_loop@0.5", + "hidden_skin_penetration_mm": "meditate_loop@0.5", + "suit_vs_visible_pairs": "meditate_loop@0.5", + "bare_vs_visible_pairs": "meditate_loop@0.5", + "boots_vs_suit_pairs": "meditate_loop@0.5", + "suit_rim_gap_mm": null, + "boot_rim_gap_mm": null + }, + "boot_suit_allowance_mm": 32.296 + } + }, + "failures": [] +} diff --git a/content-pipeline/labs/humanoid-runtime-refit/reports/wearable_fit.json b/content-pipeline/labs/humanoid-runtime-refit/reports/wearable_fit.json new file mode 100644 index 00000000..0d71f0c5 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/reports/wearable_fit.json @@ -0,0 +1,2868 @@ +{ + "generator": "successor content-pipeline/labs/humanoid-runtime-refit/verify_wearable_fit.py", + "contact_band_mm": 2.0, + "float_limit_mm": 3.0, + "buried_limit_mm": 60.0, + "variant_delta_limit_mm": 25.0, + "category_rules": { + "boots": { + "standoff_min_mm": -10.0, + "standoff_max_mm": 10.0, + "burial_limit_mm": 10.0 + }, + "gloves": { + "standoff_min_mm": -20.0, + "standoff_max_mm": 15.0, + "burial_limit_mm": 20.0 + }, + "legs": { + "standoff_min_mm": -10.0, + "standoff_max_mm": 15.0, + "burial_limit_mm": 10.0 + }, + "tops": { + "standoff_min_mm": -12.0, + "standoff_max_mm": 15.0, + "burial_limit_mm": 12.0 + }, + "armor": { + "standoff_min_mm": -30.0, + "standoff_max_mm": 30.0, + "burial_limit_mm": 30.0 + }, + "helmets": { + "standoff_min_mm": -65.0, + "standoff_max_mm": 20.0, + "burial_limit_mm": 65.0 + } + }, + "bodies": { + "male": { + "skull_top_m": 1.7525, + "items": { + "Hair_Afro": { + "nearest_surface_mm": -10.137, + "max_depth_mm": 10.137, + "contact_fraction": 0.0271, + "crown_clearance_mm": 53.99, + "scalp_poke_vertices": 14, + "scalp_covered_vertices": 373, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 332 + }, + "Hair_Crop": { + "nearest_surface_mm": -14.255, + "max_depth_mm": 14.255, + "contact_fraction": 0.0422, + "crown_clearance_mm": 14.04, + "scalp_poke_vertices": 10, + "scalp_covered_vertices": 377, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 332 + }, + "Hair_Mop": { + "nearest_surface_mm": -14.97, + "max_depth_mm": 14.97, + "contact_fraction": 0.016, + "crown_clearance_mm": 12.65, + "scalp_poke_vertices": 8, + "scalp_covered_vertices": 396, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 376 + }, + "Hat_Warm": { + "nearest_surface_mm": -9.922, + "max_depth_mm": 9.922, + "contact_fraction": 0.0, + "crown_clearance_mm": 123.83, + "scalp_poke_vertices": 27, + "scalp_covered_vertices": 235, + "scalp_probed_vertices": 444, + "hair": false, + "category": "helmets", + "vertices": 293 + }, + "SCFI_Pants_Refit": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0031, + "crown_clearance_mm": -694.57, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "legs", + "vertices": 3265 + }, + "SCFI_Top_Refit": { + "nearest_surface_mm": -5.253, + "max_depth_mm": 5.253, + "contact_fraction": 0.0079, + "crown_clearance_mm": -150.44, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "tops", + "vertices": 4810 + }, + "Shorts": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.047, + "crown_clearance_mm": -761.29, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "legs", + "vertices": 447 + }, + "Tank": { + "nearest_surface_mm": -7.443, + "max_depth_mm": 7.443, + "contact_fraction": 0.1298, + "crown_clearance_mm": -224.54, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "tops", + "vertices": 524 + }, + "boots_canvas_ankle": { + "nearest_surface_mm": 1.798, + "max_depth_mm": 0.0, + "contact_fraction": 0.1256, + "crown_clearance_mm": -1537.01, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "boots", + "vertices": 223 + }, + "boots_combat_sneakers": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0179, + "crown_clearance_mm": -1621.8, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "boots", + "vertices": 838 + }, + "boots_cuffed_runners": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0138, + "crown_clearance_mm": -1623.5, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "boots", + "vertices": 868 + }, + "boots_double_strap": { + "nearest_surface_mm": -17.576, + "max_depth_mm": 17.576, + "contact_fraction": 0.0214, + "crown_clearance_mm": -1623.94, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "boots", + "vertices": 1214 + }, + "boots_layered_sneakers": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0029, + "crown_clearance_mm": -1621.12, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "boots", + "vertices": 682 + }, + "boots_reinforced_street": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.072, + "crown_clearance_mm": -1627.63, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "boots", + "vertices": 1028 + }, + "boots_rubberized_canvas": { + "nearest_surface_mm": -8.715, + "max_depth_mm": 8.715, + "contact_fraction": 0.0196, + "crown_clearance_mm": -1623.85, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "boots", + "vertices": 1428 + }, + "boots_split_toe": { + "nearest_surface_mm": -73.015, + "max_depth_mm": 73.015, + "contact_fraction": 0.043, + "crown_clearance_mm": -1606.44, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "boots", + "vertices": 860 + }, + "boots_toe_capped": { + "nearest_surface_mm": 1.479, + "max_depth_mm": 0.0, + "contact_fraction": 0.0714, + "crown_clearance_mm": -1623.75, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "boots", + "vertices": 518 + }, + "boots_trail_shoes": { + "nearest_surface_mm": 1.499, + "max_depth_mm": 0.0, + "contact_fraction": 0.0596, + "crown_clearance_mm": -1626.45, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "boots", + "vertices": 906 + }, + "gloves_guarded_leather": { + "nearest_surface_mm": 4.416, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -263.01, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "gloves", + "vertices": 1125 + }, + "gloves_knuckled_half": { + "nearest_surface_mm": 5.094, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -282.55, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "gloves", + "vertices": 468 + }, + "gloves_light_utility": { + "nearest_surface_mm": 4.843, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -289.4, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "gloves", + "vertices": 763 + }, + "gloves_plain_work": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0017, + "crown_clearance_mm": -265.24, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "gloves", + "vertices": 575 + }, + "gloves_plated_handwraps": { + "nearest_surface_mm": -41.351, + "max_depth_mm": 41.351, + "contact_fraction": 0.0, + "crown_clearance_mm": -207.34, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "gloves", + "vertices": 795 + }, + "gloves_reinforced_knit": { + "nearest_surface_mm": 5.221, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -288.4, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "gloves", + "vertices": 551 + }, + "gloves_tipped_work": { + "nearest_surface_mm": 3.595, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -270.34, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "gloves", + "vertices": 1178 + }, + "hair_banded_mohawk": { + "nearest_surface_mm": -10.368, + "max_depth_mm": 10.368, + "contact_fraction": 0.0293, + "crown_clearance_mm": 106.15, + "scalp_poke_vertices": 17, + "scalp_covered_vertices": 93, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 1161 + }, + "hair_braided_crown": { + "nearest_surface_mm": -10.528, + "max_depth_mm": 10.528, + "contact_fraction": 0.0107, + "crown_clearance_mm": 14.04, + "scalp_poke_vertices": 18, + "scalp_covered_vertices": 358, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 4480 + }, + "hair_cropped_buzz": { + "nearest_surface_mm": -12.394, + "max_depth_mm": 12.394, + "contact_fraction": 0.0243, + "crown_clearance_mm": 43.13, + "scalp_poke_vertices": 23, + "scalp_covered_vertices": 283, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 1521 + }, + "hair_fauxhawk": { + "nearest_surface_mm": -11.015, + "max_depth_mm": 11.015, + "contact_fraction": 0.0174, + "crown_clearance_mm": 119.63, + "scalp_poke_vertices": 24, + "scalp_covered_vertices": 132, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 1662 + }, + "hair_flared_shag": { + "nearest_surface_mm": -14.072, + "max_depth_mm": 14.072, + "contact_fraction": 0.0136, + "crown_clearance_mm": 56.78, + "scalp_poke_vertices": 16, + "scalp_covered_vertices": 210, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 1617 + }, + "hair_flared_wings": { + "nearest_surface_mm": -44.109, + "max_depth_mm": 44.109, + "contact_fraction": 0.0234, + "crown_clearance_mm": 79.75, + "scalp_poke_vertices": 20, + "scalp_covered_vertices": 155, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 897 + }, + "hair_flat_top": { + "nearest_surface_mm": -40.505, + "max_depth_mm": 40.505, + "contact_fraction": 0.0462, + "crown_clearance_mm": 31.01, + "scalp_poke_vertices": 15, + "scalp_covered_vertices": 352, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 1602 + }, + "hair_low_tufts": { + "nearest_surface_mm": -35.581, + "max_depth_mm": 35.581, + "contact_fraction": 0.0478, + "crown_clearance_mm": 13.55, + "scalp_poke_vertices": 22, + "scalp_covered_vertices": 136, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 1151 + }, + "hair_messy_crop": { + "nearest_surface_mm": -21.617, + "max_depth_mm": 21.617, + "contact_fraction": 0.0394, + "crown_clearance_mm": 35.5, + "scalp_poke_vertices": 18, + "scalp_covered_vertices": 340, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 1320 + }, + "hair_messy_side_part": { + "nearest_surface_mm": -23.181, + "max_depth_mm": 23.181, + "contact_fraction": 0.0297, + "crown_clearance_mm": 59.95, + "scalp_poke_vertices": 20, + "scalp_covered_vertices": 251, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 1850 + }, + "hair_ridge_mohawk": { + "nearest_surface_mm": -0.919, + "max_depth_mm": 0.919, + "contact_fraction": 0.0069, + "crown_clearance_mm": 81.5, + "scalp_poke_vertices": 16, + "scalp_covered_vertices": 73, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 291 + }, + "hair_short_ponytail": { + "nearest_surface_mm": -20.948, + "max_depth_mm": 20.948, + "contact_fraction": 0.034, + "crown_clearance_mm": 39.22, + "scalp_poke_vertices": 15, + "scalp_covered_vertices": 182, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 2090 + }, + "hair_shoulder_shag": { + "nearest_surface_mm": -22.574, + "max_depth_mm": 22.574, + "contact_fraction": 0.0309, + "crown_clearance_mm": 20.73, + "scalp_poke_vertices": 12, + "scalp_covered_vertices": 382, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 1780 + }, + "hair_side_ponytail": { + "nearest_surface_mm": -19.306, + "max_depth_mm": 19.306, + "contact_fraction": 0.0128, + "crown_clearance_mm": 46.64, + "scalp_poke_vertices": 12, + "scalp_covered_vertices": 176, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 2737 + }, + "hair_side_sweep": { + "nearest_surface_mm": -8.007, + "max_depth_mm": 8.007, + "contact_fraction": 0.022, + "crown_clearance_mm": 85.41, + "scalp_poke_vertices": 16, + "scalp_covered_vertices": 381, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 1270 + }, + "hair_slicked_crop": { + "nearest_surface_mm": -11.776, + "max_depth_mm": 11.776, + "contact_fraction": 0.0566, + "crown_clearance_mm": 32.84, + "scalp_poke_vertices": 17, + "scalp_covered_vertices": 352, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 954 + }, + "hair_spiked_mohawk": { + "nearest_surface_mm": -23.277, + "max_depth_mm": 23.277, + "contact_fraction": 0.011, + "crown_clearance_mm": 166.11, + "scalp_poke_vertices": 23, + "scalp_covered_vertices": 121, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 1722 + }, + "hair_spiked_topknot": { + "nearest_surface_mm": -34.749, + "max_depth_mm": 34.749, + "contact_fraction": 0.0178, + "crown_clearance_mm": 81.08, + "scalp_poke_vertices": 41, + "scalp_covered_vertices": 173, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 1402 + }, + "hair_split_mohawk": { + "nearest_surface_mm": -8.787, + "max_depth_mm": 8.787, + "contact_fraction": 0.0079, + "crown_clearance_mm": 116.37, + "scalp_poke_vertices": 22, + "scalp_covered_vertices": 339, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 1900 + }, + "hair_swept_bob": { + "nearest_surface_mm": -34.155, + "max_depth_mm": 34.155, + "contact_fraction": 0.027, + "crown_clearance_mm": 33.83, + "scalp_poke_vertices": 14, + "scalp_covered_vertices": 303, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 1558 + }, + "hair_swept_spikes": { + "nearest_surface_mm": -17.651, + "max_depth_mm": 17.651, + "contact_fraction": 0.0171, + "crown_clearance_mm": 89.55, + "scalp_poke_vertices": 18, + "scalp_covered_vertices": 135, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 1524 + }, + "hair_textured_crop": { + "nearest_surface_mm": -28.554, + "max_depth_mm": 28.554, + "contact_fraction": 0.0365, + "crown_clearance_mm": 45.33, + "scalp_poke_vertices": 14, + "scalp_covered_vertices": 384, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 1563 + }, + "hair_top_patch": { + "nearest_surface_mm": -8.067, + "max_depth_mm": 8.067, + "contact_fraction": 0.0374, + "crown_clearance_mm": 27.25, + "scalp_poke_vertices": 9, + "scalp_covered_vertices": 72, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 748 + }, + "hair_twin_pigtails": { + "nearest_surface_mm": -21.611, + "max_depth_mm": 21.611, + "contact_fraction": 0.0448, + "crown_clearance_mm": 56.97, + "scalp_poke_vertices": 25, + "scalp_covered_vertices": 347, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 2523 + }, + "legs_gaitered_cargo_pants": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0313, + "crown_clearance_mm": -723.71, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "legs", + "vertices": 3517 + }, + "legs_layered_shorts": { + "nearest_surface_mm": 1.463, + "max_depth_mm": 0.0, + "contact_fraction": 0.0822, + "crown_clearance_mm": -723.58, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "legs", + "vertices": 4100 + }, + "legs_layered_wrap_skirt": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0176, + "crown_clearance_mm": -705.97, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "legs", + "vertices": 3237 + }, + "legs_padded_canvas_trousers": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0435, + "crown_clearance_mm": -723.72, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "legs", + "vertices": 3222 + }, + "legs_plated_trousers": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0333, + "crown_clearance_mm": -723.71, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "legs", + "vertices": 3002 + }, + "legs_reinforced_denim_pants": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0527, + "crown_clearance_mm": -723.56, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "legs", + "vertices": 3508 + }, + "legs_sashed_patrol_pants": { + "nearest_surface_mm": 1.46, + "max_depth_mm": 0.0, + "contact_fraction": 0.0348, + "crown_clearance_mm": -723.51, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "legs", + "vertices": 3537 + }, + "legs_skirted_workpants": { + "nearest_surface_mm": 1.452, + "max_depth_mm": 0.0, + "contact_fraction": 0.0354, + "crown_clearance_mm": -723.65, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "legs", + "vertices": 3188 + }, + "legs_strapped_trousers": { + "nearest_surface_mm": 1.473, + "max_depth_mm": 0.0, + "contact_fraction": 0.0622, + "crown_clearance_mm": -723.58, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "legs", + "vertices": 4035 + }, + "legs_wrapped_workpants": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.008, + "crown_clearance_mm": -723.72, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "legs", + "vertices": 3379 + }, + "top_frayed_tunic": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0493, + "crown_clearance_mm": -223.12, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "tops", + "vertices": 2495 + }, + "top_laced_corset_vest": { + "nearest_surface_mm": 4.537, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -229.18, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "tops", + "vertices": 2570 + }, + "top_padded_leather_vest": { + "nearest_surface_mm": 6.462, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -212.71, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "tops", + "vertices": 2339 + }, + "top_plated_rig_vest": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0314, + "crown_clearance_mm": -212.99, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "tops", + "vertices": 2390 + }, + "top_reinforced_crop_vest": { + "nearest_surface_mm": 4.695, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -224.6, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "tops", + "vertices": 1548 + }, + "top_rigged_tank": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0197, + "crown_clearance_mm": -228.36, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "tops", + "vertices": 1675 + }, + "top_scrap_plate_tunic": { + "nearest_surface_mm": 1.459, + "max_depth_mm": 0.0, + "contact_fraction": 0.0164, + "crown_clearance_mm": -206.38, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "tops", + "vertices": 2320 + }, + "top_spiked_leather_vest": { + "nearest_surface_mm": 1.473, + "max_depth_mm": 0.0, + "contact_fraction": 0.0033, + "crown_clearance_mm": -197.6, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "tops", + "vertices": 2149 + }, + "under_bodysuit": { + "nearest_surface_mm": -0.091, + "max_depth_mm": 0.091, + "contact_fraction": 1.0, + "crown_clearance_mm": -223.68, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "tops", + "vertices": 782 + }, + "Bicep_L": { + "nearest_surface_mm": 1.07, + "max_depth_mm": 0.0, + "contact_fraction": 0.0208, + "crown_clearance_mm": -245.36, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "armor", + "vertices": 144 + }, + "Bicep_R": { + "nearest_surface_mm": 1.467, + "max_depth_mm": 0.0, + "contact_fraction": 0.0208, + "crown_clearance_mm": -245.37, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "armor", + "vertices": 144 + }, + "Gorget": { + "nearest_surface_mm": 2.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -150.2, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "armor", + "vertices": 320 + }, + "Harness": { + "nearest_surface_mm": -25.2, + "max_depth_mm": 25.2, + "contact_fraction": 0.0395, + "crown_clearance_mm": -199.01, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "armor", + "vertices": 988 + }, + "Helmet_A": { + "nearest_surface_mm": 5.468, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": 32.87, + "scalp_poke_vertices": 6, + "scalp_covered_vertices": 408, + "scalp_probed_vertices": 444, + "hair": false, + "category": "helmets", + "vertices": 1136 + }, + "Helmet_B": { + "nearest_surface_mm": 7.354, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": 34.8, + "scalp_poke_vertices": 3, + "scalp_covered_vertices": 426, + "scalp_probed_vertices": 444, + "hair": false, + "category": "helmets", + "vertices": 1258 + }, + "Helmet_C": { + "nearest_surface_mm": 7.008, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": 30.72, + "scalp_poke_vertices": 3, + "scalp_covered_vertices": 438, + "scalp_probed_vertices": 444, + "hair": false, + "category": "helmets", + "vertices": 1368 + }, + "Helmet_D": { + "nearest_surface_mm": -3.005, + "max_depth_mm": 3.005, + "contact_fraction": 0.0, + "crown_clearance_mm": 83.5, + "scalp_poke_vertices": 2, + "scalp_covered_vertices": 435, + "scalp_probed_vertices": 444, + "hair": false, + "category": "helmets", + "vertices": 1446 + }, + "Helmet_S1": { + "nearest_surface_mm": 8.796, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": 40.72, + "scalp_poke_vertices": 8, + "scalp_covered_vertices": 414, + "scalp_probed_vertices": 444, + "hair": false, + "category": "helmets", + "vertices": 900 + }, + "Helmet_S2": { + "nearest_surface_mm": 3.442, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": 32.02, + "scalp_poke_vertices": 8, + "scalp_covered_vertices": 414, + "scalp_probed_vertices": 444, + "hair": false, + "category": "helmets", + "vertices": 1446 + }, + "Helmet_S3": { + "nearest_surface_mm": 2.092, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": 43.53, + "scalp_poke_vertices": 8, + "scalp_covered_vertices": 414, + "scalp_probed_vertices": 444, + "hair": false, + "category": "helmets", + "vertices": 1528 + }, + "Helmet_V1PORT": { + "nearest_surface_mm": 8.17, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": 40.85, + "scalp_poke_vertices": 6, + "scalp_covered_vertices": 408, + "scalp_probed_vertices": 444, + "hair": false, + "category": "helmets", + "vertices": 834 + }, + "Nape_Reinforcement": { + "nearest_surface_mm": 4.645, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -122.22, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 6, + "scalp_probed_vertices": 444, + "hair": false, + "category": "armor", + "vertices": 1268 + }, + "Reinforcement": { + "nearest_surface_mm": 4.825, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -156.45, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "armor", + "vertices": 160 + }, + "trial_back_a": { + "nearest_surface_mm": 19.741, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -183.71, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "armor", + "vertices": 1014 + }, + "trial_back_b": { + "nearest_surface_mm": 5.528, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -254.1, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "armor", + "vertices": 1139 + }, + "trial_hair_a": { + "nearest_surface_mm": -33.041, + "max_depth_mm": 33.041, + "contact_fraction": 0.087, + "crown_clearance_mm": 25.08, + "scalp_poke_vertices": 20, + "scalp_covered_vertices": 250, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 586 + }, + "trial_hair_b": { + "nearest_surface_mm": -47.58, + "max_depth_mm": 47.58, + "contact_fraction": 0.0385, + "crown_clearance_mm": 34.36, + "scalp_poke_vertices": 20, + "scalp_covered_vertices": 151, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 727 + }, + "trial_hair_c": { + "nearest_surface_mm": -1.121, + "max_depth_mm": 1.121, + "contact_fraction": 0.0074, + "crown_clearance_mm": 39.74, + "scalp_poke_vertices": 17, + "scalp_covered_vertices": 288, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 542 + }, + "trial_hair_d": { + "nearest_surface_mm": -29.664, + "max_depth_mm": 29.664, + "contact_fraction": 0.0188, + "crown_clearance_mm": 48.47, + "scalp_poke_vertices": 20, + "scalp_covered_vertices": 211, + "scalp_probed_vertices": 444, + "hair": true, + "category": "hair", + "vertices": 1544 + }, + "trial_helm_a": { + "nearest_surface_mm": -63.046, + "max_depth_mm": 63.046, + "contact_fraction": 0.1079, + "crown_clearance_mm": 6.68, + "scalp_poke_vertices": 16, + "scalp_covered_vertices": 399, + "scalp_probed_vertices": 444, + "hair": false, + "category": "helmets", + "vertices": 556 + }, + "trial_helm_b": { + "nearest_surface_mm": -56.695, + "max_depth_mm": 56.695, + "contact_fraction": 0.0392, + "crown_clearance_mm": 43.97, + "scalp_poke_vertices": 19, + "scalp_covered_vertices": 381, + "scalp_probed_vertices": 444, + "hair": false, + "category": "helmets", + "vertices": 587 + }, + "trial_helm_c": { + "nearest_surface_mm": -32.169, + "max_depth_mm": 32.169, + "contact_fraction": 0.0543, + "crown_clearance_mm": 16.27, + "scalp_poke_vertices": 21, + "scalp_covered_vertices": 233, + "scalp_probed_vertices": 444, + "hair": false, + "category": "helmets", + "vertices": 368 + }, + "trial_helm_d": { + "nearest_surface_mm": -56.234, + "max_depth_mm": 56.234, + "contact_fraction": 0.0793, + "crown_clearance_mm": 21.44, + "scalp_poke_vertices": 24, + "scalp_covered_vertices": 118, + "scalp_probed_vertices": 444, + "hair": false, + "category": "helmets", + "vertices": 618 + }, + "trial_hip_back_a": { + "nearest_surface_mm": 10.921, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -786.73, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "armor", + "vertices": 234 + }, + "trial_hip_front_a": { + "nearest_surface_mm": 4.122, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -723.97, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "armor", + "vertices": 581 + }, + "trial_hip_side_a": { + "nearest_surface_mm": 2.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -777.9, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 444, + "hair": false, + "category": "armor", + "vertices": 512 + }, + "trial_hood_a": { + "nearest_surface_mm": -62.777, + "max_depth_mm": 62.777, + "contact_fraction": 0.024, + "crown_clearance_mm": 50.96, + "scalp_poke_vertices": 13, + "scalp_covered_vertices": 172, + "scalp_probed_vertices": 444, + "hair": false, + "category": "helmets", + "vertices": 793 + }, + "trial_mask_b": { + "nearest_surface_mm": -26.405, + "max_depth_mm": 26.405, + "contact_fraction": 0.0657, + "crown_clearance_mm": 16.91, + "scalp_poke_vertices": 7, + "scalp_covered_vertices": 32, + "scalp_probed_vertices": 444, + "hair": false, + "category": "helmets", + "vertices": 137 + }, + "trial_visor_a": { + "nearest_surface_mm": -25.109, + "max_depth_mm": 25.109, + "contact_fraction": 0.0377, + "crown_clearance_mm": -109.93, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 12, + "scalp_probed_vertices": 444, + "hair": false, + "category": "helmets", + "vertices": 398 + } + } + }, + "female": { + "skull_top_m": 1.7525, + "items": { + "Hair_Afro": { + "nearest_surface_mm": -4.367, + "max_depth_mm": 4.367, + "contact_fraction": 0.0693, + "crown_clearance_mm": 49.06, + "scalp_poke_vertices": 8, + "scalp_covered_vertices": 342, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 332 + }, + "Hair_Crop": { + "nearest_surface_mm": -11.635, + "max_depth_mm": 11.635, + "contact_fraction": 0.0663, + "crown_clearance_mm": 11.43, + "scalp_poke_vertices": 8, + "scalp_covered_vertices": 347, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 332 + }, + "Hair_Mop": { + "nearest_surface_mm": -12.5, + "max_depth_mm": 12.5, + "contact_fraction": 0.0319, + "crown_clearance_mm": 10.15, + "scalp_poke_vertices": 6, + "scalp_covered_vertices": 365, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 376 + }, + "Hat_Warm": { + "nearest_surface_mm": -11.955, + "max_depth_mm": 11.955, + "contact_fraction": 0.0341, + "crown_clearance_mm": 114.88, + "scalp_poke_vertices": 16, + "scalp_covered_vertices": 251, + "scalp_probed_vertices": 411, + "hair": false, + "category": "helmets", + "vertices": 293 + }, + "SCFI_Pants_Refit": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0413, + "crown_clearance_mm": -694.57, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "legs", + "vertices": 3265 + }, + "SCFI_Top_Refit": { + "nearest_surface_mm": 1.46, + "max_depth_mm": 0.0, + "contact_fraction": 0.0067, + "crown_clearance_mm": -154.01, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "tops", + "vertices": 4810 + }, + "Shorts": { + "nearest_surface_mm": -5.018, + "max_depth_mm": 5.018, + "contact_fraction": 0.3736, + "crown_clearance_mm": -761.36, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "legs", + "vertices": 447 + }, + "Tank": { + "nearest_surface_mm": -8.605, + "max_depth_mm": 8.605, + "contact_fraction": 0.1469, + "crown_clearance_mm": -223.05, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "tops", + "vertices": 524 + }, + "boots_canvas_ankle": { + "nearest_surface_mm": 1.748, + "max_depth_mm": 0.0, + "contact_fraction": 0.1134, + "crown_clearance_mm": -1537.58, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "boots", + "vertices": 247 + }, + "boots_combat_sneakers": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0024, + "crown_clearance_mm": -1622.3, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "boots", + "vertices": 838 + }, + "boots_cuffed_runners": { + "nearest_surface_mm": 1.488, + "max_depth_mm": 0.0, + "contact_fraction": 0.0276, + "crown_clearance_mm": -1624.3, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "boots", + "vertices": 868 + }, + "boots_double_strap": { + "nearest_surface_mm": 1.452, + "max_depth_mm": 0.0, + "contact_fraction": 0.0214, + "crown_clearance_mm": -1624.61, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "boots", + "vertices": 1214 + }, + "boots_layered_sneakers": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0161, + "crown_clearance_mm": -1621.75, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "boots", + "vertices": 682 + }, + "boots_reinforced_street": { + "nearest_surface_mm": 1.464, + "max_depth_mm": 0.0, + "contact_fraction": 0.0467, + "crown_clearance_mm": -1628.84, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "boots", + "vertices": 1028 + }, + "boots_rubberized_canvas": { + "nearest_surface_mm": -5.148, + "max_depth_mm": 5.148, + "contact_fraction": 0.021, + "crown_clearance_mm": -1624.75, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "boots", + "vertices": 1428 + }, + "boots_split_toe": { + "nearest_surface_mm": -3.658, + "max_depth_mm": 3.658, + "contact_fraction": 0.0209, + "crown_clearance_mm": -1607.19, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "boots", + "vertices": 860 + }, + "boots_toe_capped": { + "nearest_surface_mm": -12.795, + "max_depth_mm": 12.795, + "contact_fraction": 0.0463, + "crown_clearance_mm": -1624.21, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "boots", + "vertices": 518 + }, + "boots_trail_shoes": { + "nearest_surface_mm": 1.462, + "max_depth_mm": 0.0, + "contact_fraction": 0.0342, + "crown_clearance_mm": -1627.07, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "boots", + "vertices": 906 + }, + "gloves_guarded_leather": { + "nearest_surface_mm": 4.421, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -259.82, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "gloves", + "vertices": 1125 + }, + "gloves_knuckled_half": { + "nearest_surface_mm": 5.116, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -282.55, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "gloves", + "vertices": 468 + }, + "gloves_light_utility": { + "nearest_surface_mm": -6.967, + "max_depth_mm": 6.967, + "contact_fraction": 0.0, + "crown_clearance_mm": -289.51, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "gloves", + "vertices": 763 + }, + "gloves_plain_work": { + "nearest_surface_mm": 0.47, + "max_depth_mm": 0.0, + "contact_fraction": 0.0052, + "crown_clearance_mm": -267.69, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "gloves", + "vertices": 575 + }, + "gloves_plated_handwraps": { + "nearest_surface_mm": -41.351, + "max_depth_mm": 41.351, + "contact_fraction": 0.0, + "crown_clearance_mm": -207.98, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "gloves", + "vertices": 795 + }, + "gloves_reinforced_knit": { + "nearest_surface_mm": -0.086, + "max_depth_mm": 0.086, + "contact_fraction": 0.0672, + "crown_clearance_mm": -288.79, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "gloves", + "vertices": 551 + }, + "gloves_tipped_work": { + "nearest_surface_mm": -14.576, + "max_depth_mm": 14.576, + "contact_fraction": 0.0, + "crown_clearance_mm": -272.86, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "gloves", + "vertices": 1178 + }, + "hair_banded_mohawk": { + "nearest_surface_mm": -9.003, + "max_depth_mm": 9.003, + "contact_fraction": 0.0551, + "crown_clearance_mm": 98.61, + "scalp_poke_vertices": 15, + "scalp_covered_vertices": 96, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 1161 + }, + "hair_braided_crown": { + "nearest_surface_mm": -11.386, + "max_depth_mm": 11.386, + "contact_fraction": 0.0109, + "crown_clearance_mm": 11.53, + "scalp_poke_vertices": 16, + "scalp_covered_vertices": 340, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 4480 + }, + "hair_cropped_buzz": { + "nearest_surface_mm": -11.952, + "max_depth_mm": 11.952, + "contact_fraction": 0.0316, + "crown_clearance_mm": 38.87, + "scalp_poke_vertices": 19, + "scalp_covered_vertices": 286, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 1521 + }, + "hair_fauxhawk": { + "nearest_surface_mm": -11.681, + "max_depth_mm": 11.681, + "contact_fraction": 0.0199, + "crown_clearance_mm": 111.18, + "scalp_poke_vertices": 17, + "scalp_covered_vertices": 146, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 1662 + }, + "hair_flared_shag": { + "nearest_surface_mm": -15.226, + "max_depth_mm": 15.226, + "contact_fraction": 0.0223, + "crown_clearance_mm": 51.87, + "scalp_poke_vertices": 16, + "scalp_covered_vertices": 218, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 1617 + }, + "hair_flared_wings": { + "nearest_surface_mm": -44.665, + "max_depth_mm": 44.665, + "contact_fraction": 0.0334, + "crown_clearance_mm": 73.42, + "scalp_poke_vertices": 21, + "scalp_covered_vertices": 148, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 897 + }, + "hair_flat_top": { + "nearest_surface_mm": -39.33, + "max_depth_mm": 39.33, + "contact_fraction": 0.0643, + "crown_clearance_mm": 27.56, + "scalp_poke_vertices": 8, + "scalp_covered_vertices": 329, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 1602 + }, + "hair_low_tufts": { + "nearest_surface_mm": -37.17, + "max_depth_mm": 37.17, + "contact_fraction": 0.0608, + "crown_clearance_mm": 11.08, + "scalp_poke_vertices": 24, + "scalp_covered_vertices": 118, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 1151 + }, + "hair_messy_crop": { + "nearest_surface_mm": -17.363, + "max_depth_mm": 17.363, + "contact_fraction": 0.0265, + "crown_clearance_mm": 31.63, + "scalp_poke_vertices": 14, + "scalp_covered_vertices": 337, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 1320 + }, + "hair_messy_side_part": { + "nearest_surface_mm": -22.797, + "max_depth_mm": 22.797, + "contact_fraction": 0.0308, + "crown_clearance_mm": 54.85, + "scalp_poke_vertices": 17, + "scalp_covered_vertices": 228, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 1850 + }, + "hair_ridge_mohawk": { + "nearest_surface_mm": -3.416, + "max_depth_mm": 3.416, + "contact_fraction": 0.0069, + "crown_clearance_mm": 75.03, + "scalp_poke_vertices": 10, + "scalp_covered_vertices": 81, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 291 + }, + "hair_short_ponytail": { + "nearest_surface_mm": -21.459, + "max_depth_mm": 21.459, + "contact_fraction": 0.0268, + "crown_clearance_mm": 35.24, + "scalp_poke_vertices": 21, + "scalp_covered_vertices": 177, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 2090 + }, + "hair_shoulder_shag": { + "nearest_surface_mm": -22.298, + "max_depth_mm": 22.298, + "contact_fraction": 0.0539, + "crown_clearance_mm": 17.87, + "scalp_poke_vertices": 21, + "scalp_covered_vertices": 337, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 1780 + }, + "hair_side_ponytail": { + "nearest_surface_mm": -18.771, + "max_depth_mm": 18.771, + "contact_fraction": 0.0099, + "crown_clearance_mm": 42.2, + "scalp_poke_vertices": 13, + "scalp_covered_vertices": 184, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 2737 + }, + "hair_side_sweep": { + "nearest_surface_mm": -9.631, + "max_depth_mm": 9.631, + "contact_fraction": 0.0197, + "crown_clearance_mm": 78.98, + "scalp_poke_vertices": 11, + "scalp_covered_vertices": 352, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 1270 + }, + "hair_slicked_crop": { + "nearest_surface_mm": -15.621, + "max_depth_mm": 15.621, + "contact_fraction": 0.0524, + "crown_clearance_mm": 29.38, + "scalp_poke_vertices": 10, + "scalp_covered_vertices": 340, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 954 + }, + "hair_spiked_mohawk": { + "nearest_surface_mm": -19.711, + "max_depth_mm": 19.711, + "contact_fraction": 0.0343, + "crown_clearance_mm": 154.74, + "scalp_poke_vertices": 23, + "scalp_covered_vertices": 114, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 1722 + }, + "hair_spiked_topknot": { + "nearest_surface_mm": -33.161, + "max_depth_mm": 33.161, + "contact_fraction": 0.0278, + "crown_clearance_mm": 74.77, + "scalp_poke_vertices": 27, + "scalp_covered_vertices": 172, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 1402 + }, + "hair_split_mohawk": { + "nearest_surface_mm": -11.134, + "max_depth_mm": 11.134, + "contact_fraction": 0.0142, + "crown_clearance_mm": 108.2, + "scalp_poke_vertices": 17, + "scalp_covered_vertices": 318, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 1900 + }, + "hair_swept_bob": { + "nearest_surface_mm": -16.844, + "max_depth_mm": 16.844, + "contact_fraction": 0.0282, + "crown_clearance_mm": 30.2, + "scalp_poke_vertices": 6, + "scalp_covered_vertices": 305, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 1558 + }, + "hair_swept_spikes": { + "nearest_surface_mm": -22.055, + "max_depth_mm": 22.055, + "contact_fraction": 0.0262, + "crown_clearance_mm": 82.75, + "scalp_poke_vertices": 17, + "scalp_covered_vertices": 125, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 1524 + }, + "hair_textured_crop": { + "nearest_surface_mm": -29.844, + "max_depth_mm": 29.844, + "contact_fraction": 0.0429, + "crown_clearance_mm": 40.93, + "scalp_poke_vertices": 17, + "scalp_covered_vertices": 330, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 1563 + }, + "hair_top_patch": { + "nearest_surface_mm": -6.82, + "max_depth_mm": 6.82, + "contact_fraction": 0.0067, + "crown_clearance_mm": 24.02, + "scalp_poke_vertices": 9, + "scalp_covered_vertices": 71, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 748 + }, + "hair_twin_pigtails": { + "nearest_surface_mm": -20.928, + "max_depth_mm": 20.928, + "contact_fraction": 0.0484, + "crown_clearance_mm": 51.93, + "scalp_poke_vertices": 19, + "scalp_covered_vertices": 335, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 2523 + }, + "legs_gaitered_cargo_pants": { + "nearest_surface_mm": 1.485, + "max_depth_mm": 0.0, + "contact_fraction": 0.0819, + "crown_clearance_mm": -723.71, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "legs", + "vertices": 3517 + }, + "legs_layered_shorts": { + "nearest_surface_mm": 0.485, + "max_depth_mm": 0.0, + "contact_fraction": 0.191, + "crown_clearance_mm": -723.58, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "legs", + "vertices": 4100 + }, + "legs_layered_wrap_skirt": { + "nearest_surface_mm": 1.489, + "max_depth_mm": 0.0, + "contact_fraction": 0.0831, + "crown_clearance_mm": -705.97, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "legs", + "vertices": 3237 + }, + "legs_padded_canvas_trousers": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0947, + "crown_clearance_mm": -723.72, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "legs", + "vertices": 3222 + }, + "legs_plated_trousers": { + "nearest_surface_mm": 1.33, + "max_depth_mm": 0.0, + "contact_fraction": 0.1099, + "crown_clearance_mm": -723.71, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "legs", + "vertices": 3002 + }, + "legs_reinforced_denim_pants": { + "nearest_surface_mm": -14.441, + "max_depth_mm": 14.441, + "contact_fraction": 0.0773, + "crown_clearance_mm": -723.56, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "legs", + "vertices": 3508 + }, + "legs_sashed_patrol_pants": { + "nearest_surface_mm": 1.478, + "max_depth_mm": 0.0, + "contact_fraction": 0.0882, + "crown_clearance_mm": -723.51, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "legs", + "vertices": 3537 + }, + "legs_skirted_workpants": { + "nearest_surface_mm": 1.476, + "max_depth_mm": 0.0, + "contact_fraction": 0.0787, + "crown_clearance_mm": -723.65, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "legs", + "vertices": 3188 + }, + "legs_strapped_trousers": { + "nearest_surface_mm": -0.583, + "max_depth_mm": 0.583, + "contact_fraction": 0.115, + "crown_clearance_mm": -723.58, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "legs", + "vertices": 4035 + }, + "legs_wrapped_workpants": { + "nearest_surface_mm": 1.478, + "max_depth_mm": 0.0, + "contact_fraction": 0.0361, + "crown_clearance_mm": -723.72, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "legs", + "vertices": 3379 + }, + "top_frayed_tunic": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0052, + "crown_clearance_mm": -247.07, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "tops", + "vertices": 2495 + }, + "top_laced_corset_vest": { + "nearest_surface_mm": 4.574, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -241.92, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "tops", + "vertices": 2570 + }, + "top_padded_leather_vest": { + "nearest_surface_mm": 5.866, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -231.1, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "tops", + "vertices": 2339 + }, + "top_plated_rig_vest": { + "nearest_surface_mm": 1.5, + "max_depth_mm": 0.0, + "contact_fraction": 0.0033, + "crown_clearance_mm": -212.99, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "tops", + "vertices": 2390 + }, + "top_reinforced_crop_vest": { + "nearest_surface_mm": 3.8, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -245.34, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "tops", + "vertices": 1548 + }, + "top_rigged_tank": { + "nearest_surface_mm": 1.479, + "max_depth_mm": 0.0, + "contact_fraction": 0.0382, + "crown_clearance_mm": -243.36, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "tops", + "vertices": 1675 + }, + "top_scrap_plate_tunic": { + "nearest_surface_mm": 1.364, + "max_depth_mm": 0.0, + "contact_fraction": 0.0099, + "crown_clearance_mm": -225.42, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "tops", + "vertices": 2320 + }, + "top_spiked_leather_vest": { + "nearest_surface_mm": 1.462, + "max_depth_mm": 0.0, + "contact_fraction": 0.0023, + "crown_clearance_mm": -226.48, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "tops", + "vertices": 2149 + }, + "under_bodysuit": { + "nearest_surface_mm": -0.091, + "max_depth_mm": 0.091, + "contact_fraction": 1.0, + "crown_clearance_mm": -224.03, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "tops", + "vertices": 817 + }, + "Bicep_L": { + "nearest_surface_mm": 0.891, + "max_depth_mm": 0.0, + "contact_fraction": 0.0208, + "crown_clearance_mm": -259.52, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "armor", + "vertices": 144 + }, + "Bicep_R": { + "nearest_surface_mm": 1.212, + "max_depth_mm": 0.0, + "contact_fraction": 0.0208, + "crown_clearance_mm": -259.32, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "armor", + "vertices": 144 + }, + "Gorget": { + "nearest_surface_mm": 2.143, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -156.8, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "armor", + "vertices": 320 + }, + "Harness": { + "nearest_surface_mm": -25.999, + "max_depth_mm": 25.999, + "contact_fraction": 0.0648, + "crown_clearance_mm": -213.04, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "armor", + "vertices": 988 + }, + "Helmet_A": { + "nearest_surface_mm": 11.348, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": 29.24, + "scalp_poke_vertices": 5, + "scalp_covered_vertices": 388, + "scalp_probed_vertices": 411, + "hair": false, + "category": "helmets", + "vertices": 1136 + }, + "Helmet_B": { + "nearest_surface_mm": 13.782, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": 31.03, + "scalp_poke_vertices": 4, + "scalp_covered_vertices": 400, + "scalp_probed_vertices": 411, + "hair": false, + "category": "helmets", + "vertices": 1258 + }, + "Helmet_C": { + "nearest_surface_mm": 12.311, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": 27.21, + "scalp_poke_vertices": 3, + "scalp_covered_vertices": 405, + "scalp_probed_vertices": 411, + "hair": false, + "category": "helmets", + "vertices": 1368 + }, + "Helmet_D": { + "nearest_surface_mm": 1.875, + "max_depth_mm": 0.0, + "contact_fraction": 0.0028, + "crown_clearance_mm": 77.01, + "scalp_poke_vertices": 3, + "scalp_covered_vertices": 405, + "scalp_probed_vertices": 411, + "hair": false, + "category": "helmets", + "vertices": 1446 + }, + "Helmet_S1": { + "nearest_surface_mm": 10.389, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": 36.67, + "scalp_poke_vertices": 2, + "scalp_covered_vertices": 391, + "scalp_probed_vertices": 411, + "hair": false, + "category": "helmets", + "vertices": 900 + }, + "Helmet_S2": { + "nearest_surface_mm": 7.352, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": 28.45, + "scalp_poke_vertices": 2, + "scalp_covered_vertices": 391, + "scalp_probed_vertices": 411, + "hair": false, + "category": "helmets", + "vertices": 1446 + }, + "Helmet_S3": { + "nearest_surface_mm": 5.17, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": 39.31, + "scalp_poke_vertices": 5, + "scalp_covered_vertices": 388, + "scalp_probed_vertices": 411, + "hair": false, + "category": "helmets", + "vertices": 1528 + }, + "Helmet_V1PORT": { + "nearest_surface_mm": 8.373, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": 36.75, + "scalp_poke_vertices": 4, + "scalp_covered_vertices": 387, + "scalp_probed_vertices": 411, + "hair": false, + "category": "helmets", + "vertices": 834 + }, + "Nape_Reinforcement": { + "nearest_surface_mm": 4.513, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -130.79, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 9, + "scalp_probed_vertices": 411, + "hair": false, + "category": "armor", + "vertices": 1268 + }, + "Reinforcement": { + "nearest_surface_mm": 4.732, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -159.61, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "armor", + "vertices": 160 + }, + "trial_back_a": { + "nearest_surface_mm": 22.771, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -174.19, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "armor", + "vertices": 1014 + }, + "trial_back_b": { + "nearest_surface_mm": 4.907, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -257.1, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "armor", + "vertices": 1139 + }, + "trial_hair_a": { + "nearest_surface_mm": -32.321, + "max_depth_mm": 32.321, + "contact_fraction": 0.0853, + "crown_clearance_mm": 22.03, + "scalp_poke_vertices": 27, + "scalp_covered_vertices": 190, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 586 + }, + "trial_hair_b": { + "nearest_surface_mm": -47.312, + "max_depth_mm": 47.312, + "contact_fraction": 0.0399, + "crown_clearance_mm": 30.79, + "scalp_poke_vertices": 17, + "scalp_covered_vertices": 136, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 727 + }, + "trial_hair_c": { + "nearest_surface_mm": -16.892, + "max_depth_mm": 16.892, + "contact_fraction": 0.0148, + "crown_clearance_mm": 35.64, + "scalp_poke_vertices": 14, + "scalp_covered_vertices": 276, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 542 + }, + "trial_hair_d": { + "nearest_surface_mm": -21.362, + "max_depth_mm": 21.362, + "contact_fraction": 0.035, + "crown_clearance_mm": 44.04, + "scalp_poke_vertices": 22, + "scalp_covered_vertices": 212, + "scalp_probed_vertices": 411, + "hair": true, + "category": "hair", + "vertices": 1544 + }, + "trial_helm_a": { + "nearest_surface_mm": -56.192, + "max_depth_mm": 56.192, + "contact_fraction": 0.1349, + "crown_clearance_mm": 4.62, + "scalp_poke_vertices": 22, + "scalp_covered_vertices": 339, + "scalp_probed_vertices": 411, + "hair": false, + "category": "helmets", + "vertices": 556 + }, + "trial_helm_b": { + "nearest_surface_mm": -63.567, + "max_depth_mm": 63.567, + "contact_fraction": 0.017, + "crown_clearance_mm": 39.75, + "scalp_poke_vertices": 12, + "scalp_covered_vertices": 377, + "scalp_probed_vertices": 411, + "hair": false, + "category": "helmets", + "vertices": 587 + }, + "trial_helm_c": { + "nearest_surface_mm": -31.02, + "max_depth_mm": 31.02, + "contact_fraction": 0.0408, + "crown_clearance_mm": 13.57, + "scalp_poke_vertices": 18, + "scalp_covered_vertices": 232, + "scalp_probed_vertices": 411, + "hair": false, + "category": "helmets", + "vertices": 368 + }, + "trial_helm_d": { + "nearest_surface_mm": -49.665, + "max_depth_mm": 49.665, + "contact_fraction": 0.0841, + "crown_clearance_mm": 18.3, + "scalp_poke_vertices": 28, + "scalp_covered_vertices": 122, + "scalp_probed_vertices": 411, + "hair": false, + "category": "helmets", + "vertices": 618 + }, + "trial_hip_back_a": { + "nearest_surface_mm": 6.363, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -787.78, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "armor", + "vertices": 234 + }, + "trial_hip_front_a": { + "nearest_surface_mm": 4.469, + "max_depth_mm": 0.0, + "contact_fraction": 0.0, + "crown_clearance_mm": -725.77, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "armor", + "vertices": 581 + }, + "trial_hip_side_a": { + "nearest_surface_mm": -7.286, + "max_depth_mm": 7.286, + "contact_fraction": 0.0039, + "crown_clearance_mm": -774.75, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 0, + "scalp_probed_vertices": 411, + "hair": false, + "category": "armor", + "vertices": 512 + }, + "trial_hood_a": { + "nearest_surface_mm": -61.604, + "max_depth_mm": 61.604, + "contact_fraction": 0.0202, + "crown_clearance_mm": 46.33, + "scalp_poke_vertices": 12, + "scalp_covered_vertices": 158, + "scalp_probed_vertices": 411, + "hair": false, + "category": "helmets", + "vertices": 793 + }, + "trial_mask_b": { + "nearest_surface_mm": -22.833, + "max_depth_mm": 22.833, + "contact_fraction": 0.0292, + "crown_clearance_mm": 14.21, + "scalp_poke_vertices": 5, + "scalp_covered_vertices": 34, + "scalp_probed_vertices": 411, + "hair": false, + "category": "helmets", + "vertices": 137 + }, + "trial_visor_a": { + "nearest_surface_mm": -22.919, + "max_depth_mm": 22.919, + "contact_fraction": 0.0603, + "crown_clearance_mm": -105.32, + "scalp_poke_vertices": 0, + "scalp_covered_vertices": 12, + "scalp_probed_vertices": 411, + "hair": false, + "category": "helmets", + "vertices": 398 + } + } + } + }, + "rigid_mounts": { + "hat_field_cap": { + "bone": "head", + "male": { + "clearance_mm": -1.253, + "max_depth_mm": 1.253, + "mount_world_pos": [ + 0.0, + 1.55, + -0.0 + ] + }, + "female": { + "clearance_mm": 10.738, + "max_depth_mm": 0.0, + "mount_world_pos": [ + 0.0, + 1.55, + -0.0 + ] + }, + "delta_pos_mm": 0.0, + "delta_clearance_mm": 11.991 + }, + "slugthrower": { + "bone": "hand_r", + "male": { + "clearance_mm": -6.751, + "max_depth_mm": 6.751, + "mount_world_pos": [ + -0.95308, + 1.40112, + 0.05585 + ] + }, + "female": { + "clearance_mm": -6.753, + "max_depth_mm": 6.753, + "mount_world_pos": [ + -0.95308, + 1.40112, + 0.05585 + ] + }, + "delta_pos_mm": 0.0, + "delta_clearance_mm": 0.002 + }, + "vibrosword": { + "bone": "hand_r", + "male": { + "clearance_mm": -8.306, + "max_depth_mm": 8.306, + "mount_world_pos": [ + -0.933, + 1.49021, + 0.14622 + ] + }, + "female": { + "clearance_mm": -8.307, + "max_depth_mm": 8.307, + "mount_world_pos": [ + -0.933, + 1.49021, + 0.14622 + ] + }, + "delta_pos_mm": 0.0, + "delta_clearance_mm": 0.001 + }, + "plasma_hilt": { + "bone": "hand_r", + "male": { + "clearance_mm": 58.945, + "max_depth_mm": 0.0, + "mount_world_pos": [ + -0.933, + 1.49021, + 0.14622 + ] + }, + "female": { + "clearance_mm": 58.944, + "max_depth_mm": 0.0, + "mount_world_pos": [ + -0.933, + 1.49021, + 0.14622 + ] + }, + "delta_pos_mm": 0.0, + "delta_clearance_mm": 0.001 + }, + "scrapline_machete": { + "bone": "hand_r", + "male": { + "clearance_mm": -8.621, + "max_depth_mm": 8.621, + "mount_world_pos": [ + -0.88102, + 1.42927, + 0.01644 + ] + }, + "female": { + "clearance_mm": -8.622, + "max_depth_mm": 8.622, + "mount_world_pos": [ + -0.88102, + 1.42927, + 0.01644 + ] + }, + "delta_pos_mm": 0.0, + "delta_clearance_mm": 0.001 + }, + "field_saber": { + "bone": "hand_r", + "male": { + "clearance_mm": -19.018, + "max_depth_mm": 19.018, + "mount_world_pos": [ + -0.88102, + 1.42927, + 0.01644 + ] + }, + "female": { + "clearance_mm": -19.017, + "max_depth_mm": 19.017, + "mount_world_pos": [ + -0.88102, + 1.42927, + 0.01644 + ] + }, + "delta_pos_mm": 0.0, + "delta_clearance_mm": 0.001 + }, + "quarry_chopper": { + "bone": "hand_r", + "male": { + "clearance_mm": -8.215, + "max_depth_mm": 8.215, + "mount_world_pos": [ + -0.88102, + 1.42927, + 0.01644 + ] + }, + "female": { + "clearance_mm": -8.216, + "max_depth_mm": 8.216, + "mount_world_pos": [ + -0.88102, + 1.42927, + 0.01644 + ] + }, + "delta_pos_mm": 0.0, + "delta_clearance_mm": 0.001 + }, + "plasma_sword": { + "bone": "hand_r", + "male": { + "clearance_mm": -4.525, + "max_depth_mm": 4.525, + "mount_world_pos": [ + -0.87898, + 1.42687, + 0.01134 + ] + }, + "female": { + "clearance_mm": -4.527, + "max_depth_mm": 4.527, + "mount_world_pos": [ + -0.87898, + 1.42687, + 0.01134 + ] + }, + "delta_pos_mm": 0.0, + "delta_clearance_mm": 0.002 + }, + "wpn_pistol": { + "bone": "hand_r", + "male": { + "clearance_mm": -8.806, + "max_depth_mm": 8.806, + "mount_world_pos": [ + -0.96525, + 1.3933, + -0.03986 + ] + }, + "female": { + "clearance_mm": -8.806, + "max_depth_mm": 8.806, + "mount_world_pos": [ + -0.96525, + 1.3933, + -0.03986 + ] + }, + "delta_pos_mm": 0.0, + "delta_clearance_mm": 0.001 + }, + "wpn_smg": { + "bone": "hand_r", + "male": { + "clearance_mm": -66.176, + "max_depth_mm": 66.176, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + }, + "female": { + "clearance_mm": -66.176, + "max_depth_mm": 66.176, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + }, + "delta_pos_mm": 0.0, + "delta_clearance_mm": 0.0 + }, + "wpn_carbine": { + "bone": "hand_r", + "male": { + "clearance_mm": -66.176, + "max_depth_mm": 66.176, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + }, + "female": { + "clearance_mm": -66.176, + "max_depth_mm": 66.176, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + }, + "delta_pos_mm": 0.0, + "delta_clearance_mm": 0.0 + }, + "lightning_carbine": { + "bone": "hand_r", + "male": { + "clearance_mm": -65.902, + "max_depth_mm": 65.902, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + }, + "female": { + "clearance_mm": -65.902, + "max_depth_mm": 65.902, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + }, + "delta_pos_mm": 0.0, + "delta_clearance_mm": 0.0 + }, + "wpn_assault": { + "bone": "hand_r", + "male": { + "clearance_mm": -4.987, + "max_depth_mm": 4.987, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + }, + "female": { + "clearance_mm": -4.988, + "max_depth_mm": 4.988, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + }, + "delta_pos_mm": 0.0, + "delta_clearance_mm": 0.001 + }, + "wpn_shotgun": { + "bone": "hand_r", + "male": { + "clearance_mm": -8.644, + "max_depth_mm": 8.644, + "mount_world_pos": [ + -1.17527, + 1.33959, + -0.03814 + ] + }, + "female": { + "clearance_mm": -8.645, + "max_depth_mm": 8.645, + "mount_world_pos": [ + -1.17527, + 1.33959, + -0.03814 + ] + }, + "delta_pos_mm": 0.0, + "delta_clearance_mm": 0.002 + }, + "wpn_sniper": { + "bone": "hand_r", + "male": { + "clearance_mm": -66.176, + "max_depth_mm": 66.176, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + }, + "female": { + "clearance_mm": -66.176, + "max_depth_mm": 66.176, + "mount_world_pos": [ + -1.09909, + 1.35926, + -0.03486 + ] + }, + "delta_pos_mm": 0.0, + "delta_clearance_mm": 0.0 + }, + "wpn_heavy": { + "bone": "hand_r", + "male": { + "clearance_mm": -4.987, + "max_depth_mm": 4.987, + "mount_world_pos": [ + -1.12789, + 1.3512, + -0.04882 + ] + }, + "female": { + "clearance_mm": -4.988, + "max_depth_mm": 4.988, + "mount_world_pos": [ + -1.12789, + 1.3512, + -0.04882 + ] + }, + "delta_pos_mm": 0.0, + "delta_clearance_mm": 0.001 + }, + "wpn_launcher": { + "bone": "hand_r", + "male": { + "clearance_mm": -64.904, + "max_depth_mm": 64.904, + "mount_world_pos": [ + -1.12789, + 1.3512, + -0.04882 + ] + }, + "female": { + "clearance_mm": -64.904, + "max_depth_mm": 64.904, + "mount_world_pos": [ + -1.12789, + 1.3512, + -0.04882 + ] + }, + "delta_pos_mm": 0.0, + "delta_clearance_mm": 0.0 + } + }, + "failures": [ + "male: Hair_Afro lets the skull through at 14 points inside its own cap", + "male: Hair_Crop lets the skull through at 10 points inside its own cap", + "male: boots_double_strap (boots) standoff -17.6 mm below min standoff -10.0 mm", + "male: boots_double_strap (boots) burial depth 17.6 mm exceeds hard limit 10.0 mm", + "male: boots_split_toe (boots) standoff -73.0 mm below min standoff -10.0 mm", + "male: boots_split_toe (boots) burial depth 73.0 mm exceeds hard limit 10.0 mm", + "male: gloves_plated_handwraps (gloves) standoff -41.4 mm below min standoff -20.0 mm", + "male: gloves_plated_handwraps (gloves) burial depth 41.4 mm exceeds hard limit 20.0 mm", + "male: hair_banded_mohawk lets the skull through at 17 points inside its own cap", + "male: hair_braided_crown lets the skull through at 18 points inside its own cap", + "male: hair_cropped_buzz lets the skull through at 23 points inside its own cap", + "male: hair_fauxhawk lets the skull through at 24 points inside its own cap", + "male: hair_flared_shag lets the skull through at 16 points inside its own cap", + "male: hair_flared_wings lets the skull through at 20 points inside its own cap", + "male: hair_flat_top lets the skull through at 15 points inside its own cap", + "male: hair_low_tufts lets the skull through at 22 points inside its own cap", + "male: hair_messy_crop lets the skull through at 18 points inside its own cap", + "male: hair_messy_side_part lets the skull through at 20 points inside its own cap", + "male: hair_ridge_mohawk lets the skull through at 16 points inside its own cap", + "male: hair_short_ponytail lets the skull through at 15 points inside its own cap", + "male: hair_shoulder_shag lets the skull through at 12 points inside its own cap", + "male: hair_side_ponytail lets the skull through at 12 points inside its own cap", + "male: hair_side_sweep lets the skull through at 16 points inside its own cap", + "male: hair_slicked_crop lets the skull through at 17 points inside its own cap", + "male: hair_spiked_mohawk lets the skull through at 23 points inside its own cap", + "male: hair_spiked_topknot lets the skull through at 41 points inside its own cap", + "male: hair_split_mohawk lets the skull through at 22 points inside its own cap", + "male: hair_swept_bob lets the skull through at 14 points inside its own cap", + "male: hair_swept_spikes lets the skull through at 18 points inside its own cap", + "male: hair_textured_crop lets the skull through at 14 points inside its own cap", + "male: hair_top_patch lets the skull through at 9 points inside its own cap", + "male: hair_twin_pigtails lets the skull through at 25 points inside its own cap", + "male: trial_hair_a lets the skull through at 20 points inside its own cap", + "male: trial_hair_b lets the skull through at 20 points inside its own cap", + "male: trial_hair_c lets the skull through at 17 points inside its own cap", + "male: trial_hair_d lets the skull through at 20 points inside its own cap", + "female: boots_toe_capped (boots) standoff -12.8 mm below min standoff -10.0 mm", + "female: boots_toe_capped (boots) burial depth 12.8 mm exceeds hard limit 10.0 mm", + "female: gloves_plated_handwraps (gloves) standoff -41.4 mm below min standoff -20.0 mm", + "female: gloves_plated_handwraps (gloves) burial depth 41.4 mm exceeds hard limit 20.0 mm", + "female: hair_banded_mohawk lets the skull through at 15 points inside its own cap", + "female: hair_braided_crown lets the skull through at 16 points inside its own cap", + "female: hair_cropped_buzz lets the skull through at 19 points inside its own cap", + "female: hair_fauxhawk lets the skull through at 17 points inside its own cap", + "female: hair_flared_shag lets the skull through at 16 points inside its own cap", + "female: hair_flared_wings lets the skull through at 21 points inside its own cap", + "female: hair_low_tufts lets the skull through at 24 points inside its own cap", + "female: hair_messy_crop lets the skull through at 14 points inside its own cap", + "female: hair_messy_side_part lets the skull through at 17 points inside its own cap", + "female: hair_ridge_mohawk lets the skull through at 10 points inside its own cap", + "female: hair_short_ponytail lets the skull through at 21 points inside its own cap", + "female: hair_shoulder_shag lets the skull through at 21 points inside its own cap", + "female: hair_side_ponytail lets the skull through at 13 points inside its own cap", + "female: hair_side_sweep lets the skull through at 11 points inside its own cap", + "female: hair_slicked_crop lets the skull through at 10 points inside its own cap", + "female: hair_spiked_mohawk lets the skull through at 23 points inside its own cap", + "female: hair_spiked_topknot lets the skull through at 27 points inside its own cap", + "female: hair_split_mohawk lets the skull through at 17 points inside its own cap", + "female: hair_swept_spikes lets the skull through at 17 points inside its own cap", + "female: hair_textured_crop lets the skull through at 17 points inside its own cap", + "female: hair_top_patch lets the skull through at 9 points inside its own cap", + "female: hair_twin_pigtails lets the skull through at 19 points inside its own cap", + "female: legs_reinforced_denim_pants (legs) standoff -14.4 mm below min standoff -10.0 mm", + "female: legs_reinforced_denim_pants (legs) burial depth 14.4 mm exceeds hard limit 10.0 mm", + "female: trial_hair_a lets the skull through at 27 points inside its own cap", + "female: trial_hair_b lets the skull through at 17 points inside its own cap", + "female: trial_hair_c lets the skull through at 14 points inside its own cap", + "female: trial_hair_d lets the skull through at 22 points inside its own cap", + "boots_split_toe: male and female seat 69.4 mm apart (73.0 vs 3.7)", + "rigid: hat_field_cap female mount floats 10.7 mm above scalp (male clearance -1.3 mm)" + ] +} diff --git a/content-pipeline/labs/humanoid-runtime-refit/source/female/pawn_female_refined.glb b/content-pipeline/labs/humanoid-runtime-refit/source/female/pawn_female_refined.glb new file mode 100644 index 00000000..e4dec05f --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/source/female/pawn_female_refined.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bd99fdb32fc40291780d92b1494f07a428c439204b306a2a6d32d2bc403c0c8b +size 1015572 diff --git a/content-pipeline/labs/humanoid-runtime-refit/source/female/pawn_female_refined.glb.manifest.json b/content-pipeline/labs/humanoid-runtime-refit/source/female/pawn_female_refined.glb.manifest.json new file mode 100644 index 00000000..a92b1cd6 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/source/female/pawn_female_refined.glb.manifest.json @@ -0,0 +1,31 @@ +{ + "asset_id": "successor_humanoid_source_pawn_female_refined", + "asset_path": "content-pipeline/labs/humanoid-runtime-refit/source/female/pawn_female_refined.glb", + "asset_hash": "sha256:bd99fdb32fc40291780d92b1494f07a428c439204b306a2a6d32d2bc403c0c8b", + "asset_kind": "model_glb", + "tool": { + "name": "Khronos glTF Blender I/O", + "version": "5.2.39", + "tool_snapshot_id": null + }, + "prompt": { + "text": "Promote the final reviewed Successor female body with its hand-shaped shared-topology head and neck graft, verified runtime hands, and exact 50-joint bind rig.", + "denylist_audit": "passed:tools/denylist/check.sh", + "negative_terms": [] + }, + "seed": null, + "request_id": null, + "human_edits": [ + { + "by": "Successor Bunker humanoid refinement lane", + "date": "2026-08-02T23:11:48Z", + "summary": "Replaced the damaged head and neck with the reviewed shared-topology sculpt, preserved the verified hands, and refined the torso, pelvis, and upper-leg transition." + } + ], + "review": { + "art_director": "Michael Kelly", + "approved_at": "2026-08-02T23:11:48Z", + "notes": "Exact artifact from body-refine-20260802 commit d3fdfa2831bf938c0d9c8bf06f8796a00a935af5; selected over the older rbf_v3 runtime-refit source." + }, + "regeneration_command": null +} diff --git a/content-pipeline/labs/humanoid-runtime-refit/source/female/pawn_female_refined.json b/content-pipeline/labs/humanoid-runtime-refit/source/female/pawn_female_refined.json new file mode 100644 index 00000000..f472e949 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/source/female/pawn_female_refined.json @@ -0,0 +1,29 @@ +{ + "body": "pawn_female_refined.glb", + "source_repository": "ssh://bunker/home/lycaon/git/successor.git", + "source_branch": "body-refine-20260802", + "source_commit": "d3fdfa2831bf938c0d9c8bf06f8796a00a935af5", + "source_sha256": "bd99fdb32fc40291780d92b1494f07a428c439204b306a2a6d32d2bc403c0c8b", + "clips_from": "hash-pinned runtime shell during promotion", + "actions": 0, + "runtime_actions": 47, + "verts": 3262, + "tris": 5882, + "height": 1.7525, + "vertex_groups": 50, + "head": { + "method": "reviewed shared-topology 10-ring, 16-column faceted head and neck graft", + "face_panel_faces": 56, + "art_gates": { + "depth_over_height": true, + "rear_fill": true, + "occiput_peak": true + } + }, + "hands": { + "source": "verified legacy runtime hands", + "unchanged_by_body_refinement": true, + "islands_per_side": 36 + }, + "review": "Final Bunker humanoid refinement; immutable, head, transition, animation-proof, and viewer gates completed before commit." +} diff --git a/content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v2_anim.glb b/content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v2_anim.glb new file mode 100644 index 00000000..9b8919f4 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v2_anim.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:deb4ee8cbacebfee13e3e57cae3f6b7e44aff0ce39bed582fb83f6adcd6f4799 +size 2526920 diff --git a/content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v2_anim.glb.manifest.json b/content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v2_anim.glb.manifest.json new file mode 100644 index 00000000..04d0c024 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v2_anim.glb.manifest.json @@ -0,0 +1,31 @@ +{ + "asset_id": "successor_humanoid_source_rbf_v2_anim", + "asset_path": "content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v2_anim.glb", + "asset_hash": "sha256:deb4ee8cbacebfee13e3e57cae3f6b7e44aff0ce39bed582fb83f6adcd6f4799", + "asset_kind": "model_glb", + "tool": { + "name": "Khronos glTF Blender I/O", + "version": "5.2.39", + "tool_snapshot_id": null + }, + "prompt": { + "text": "Export the stylized Successor female body baseline on the canonical 50-joint runtime rig with the complete 47-clip runtime animation set for measured comparison.", + "denylist_audit": "passed:tools/denylist/check.sh", + "negative_terms": [] + }, + "seed": null, + "request_id": null, + "human_edits": [ + { + "by": "Successor PawnForge body iteration lane", + "date": "2026-08-01T06:24:55Z", + "summary": "Ported the female body to the runtime skeleton and retained it as the measured pre-correction comparison source." + } + ], + "review": { + "art_director": "Michael Kelly", + "approved_at": "2026-08-01T06:24:55Z", + "notes": "Approved for inclusion as the pinned female comparison source; it is not the promoted runtime body." + }, + "regeneration_command": null +} diff --git a/content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v2_anim.json b/content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v2_anim.json new file mode 100644 index 00000000..34f53aba --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v2_anim.json @@ -0,0 +1,59 @@ +{ + "body": "rbf_v2.glb", + "clips_from": "pawn_male.glb", + "actions": 47, + "action_names": [ + "block_hold", + "cast_aoe", + "cast_directed_loop", + "cast_directed_windup", + "crouch_idle", + "crouch_walk", + "death_b", + "death_f", + "deflect_c", + "deflect_l", + "deflect_r", + "gun_grip_trigger_discipline", + "idle", + "kneel_loop", + "meditate_loop", + "melee_draw", + "melee_draw_alt", + "melee_flinch", + "melee_grip", + "melee_idle", + "melee_ready", + "melee_run_f", + "melee_run_v2", + "melee_sheath", + "melee_strafe_l", + "melee_strafe_r", + "melee_turn_180", + "melee_turn_90", + "melee_walk_f", + "melee_walk_v2", + "reload", + "rifle_aim", + "rifle_fire", + "rifle_idle", + "rifle_run_f", + "rifle_walk_f", + "run_f", + "stim_inject", + "strafe_l", + "strafe_r", + "swing_h1", + "swing_h2", + "swing_h3", + "swing_spin_aoe", + "swing_thrust", + "walk_b", + "walk_f" + ], + "worst_rest_delta_m": 3.07e-07, + "verts": 1218, + "tris": 1840, + "height": 1.7525, + "vertex_groups": 50 +} \ No newline at end of file diff --git a/content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v3_anim.glb b/content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v3_anim.glb new file mode 100644 index 00000000..8092f4e0 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v3_anim.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c1aefa701b6f6560cd9cb87f979c25a3bb4227fd9da6cde9f29f464f5da1650 +size 2526920 diff --git a/content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v3_anim.glb.manifest.json b/content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v3_anim.glb.manifest.json new file mode 100644 index 00000000..067abe86 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v3_anim.glb.manifest.json @@ -0,0 +1,31 @@ +{ + "asset_id": "successor_humanoid_source_rbf_v3_anim", + "asset_path": "content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v3_anim.glb", + "asset_hash": "sha256:8c1aefa701b6f6560cd9cb87f979c25a3bb4227fd9da6cde9f29f464f5da1650", + "asset_kind": "model_glb", + "tool": { + "name": "Khronos glTF Blender I/O", + "version": "5.2.39", + "tool_snapshot_id": null + }, + "prompt": { + "text": "Export the approved stylized Successor female body on the canonical 50-joint runtime rig and retain the complete 47-clip runtime animation set.", + "denylist_audit": "passed:tools/denylist/check.sh", + "negative_terms": [] + }, + "seed": null, + "request_id": null, + "human_edits": [ + { + "by": "Successor PawnForge body iteration lane", + "date": "2026-08-01T06:24:55Z", + "summary": "Refined the female silhouette and faceting, then rebound and gate-verified the runtime animation set." + } + ], + "review": { + "art_director": "Michael Kelly", + "approved_at": "2026-08-01T06:24:55Z", + "notes": "Pinned as the approved female refit source; the runtime promotion applies the separately recorded posterior-depth correction." + }, + "regeneration_command": null +} diff --git a/content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v3_anim.json b/content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v3_anim.json new file mode 100644 index 00000000..d38e78d0 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/source/female/rbf_v3_anim.json @@ -0,0 +1,59 @@ +{ + "body": "rbf_v3.glb", + "clips_from": "pawn_male.glb", + "actions": 47, + "action_names": [ + "block_hold", + "cast_aoe", + "cast_directed_loop", + "cast_directed_windup", + "crouch_idle", + "crouch_walk", + "death_b", + "death_f", + "deflect_c", + "deflect_l", + "deflect_r", + "gun_grip_trigger_discipline", + "idle", + "kneel_loop", + "meditate_loop", + "melee_draw", + "melee_draw_alt", + "melee_flinch", + "melee_grip", + "melee_idle", + "melee_ready", + "melee_run_f", + "melee_run_v2", + "melee_sheath", + "melee_strafe_l", + "melee_strafe_r", + "melee_turn_180", + "melee_turn_90", + "melee_walk_f", + "melee_walk_v2", + "reload", + "rifle_aim", + "rifle_fire", + "rifle_idle", + "rifle_run_f", + "rifle_walk_f", + "run_f", + "stim_inject", + "strafe_l", + "strafe_r", + "swing_h1", + "swing_h2", + "swing_h3", + "swing_spin_aoe", + "swing_thrust", + "walk_b", + "walk_f" + ], + "worst_rest_delta_m": 3.07e-07, + "verts": 1218, + "tris": 1840, + "height": 1.7525, + "vertex_groups": 50 +} \ No newline at end of file diff --git a/content-pipeline/labs/humanoid-runtime-refit/source/male/pawn_male_refined.glb b/content-pipeline/labs/humanoid-runtime-refit/source/male/pawn_male_refined.glb new file mode 100644 index 00000000..8aaf9ba4 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/source/male/pawn_male_refined.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d5cb14b321a5f6024820e6c49eaa7f4200dca303336c95758792847f34211401 +size 1009644 diff --git a/content-pipeline/labs/humanoid-runtime-refit/source/male/pawn_male_refined.glb.manifest.json b/content-pipeline/labs/humanoid-runtime-refit/source/male/pawn_male_refined.glb.manifest.json new file mode 100644 index 00000000..19041003 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/source/male/pawn_male_refined.glb.manifest.json @@ -0,0 +1,31 @@ +{ + "asset_id": "successor_humanoid_source_pawn_male_refined", + "asset_path": "content-pipeline/labs/humanoid-runtime-refit/source/male/pawn_male_refined.glb", + "asset_hash": "sha256:d5cb14b321a5f6024820e6c49eaa7f4200dca303336c95758792847f34211401", + "asset_kind": "model_glb", + "tool": { + "name": "Khronos glTF Blender I/O", + "version": "5.2.39", + "tool_snapshot_id": null + }, + "prompt": { + "text": "Promote the final reviewed Successor male body with its hand-shaped shared-topology head and neck graft, verified runtime hands, and exact 50-joint bind rig.", + "denylist_audit": "passed:tools/denylist/check.sh", + "negative_terms": [] + }, + "seed": null, + "request_id": null, + "human_edits": [ + { + "by": "Successor Bunker humanoid refinement lane", + "date": "2026-08-02T23:11:48Z", + "summary": "Replaced the damaged head and neck with the reviewed shared-topology sculpt, preserved the verified hands, and refined male girth and body transitions." + } + ], + "review": { + "art_director": "Michael Kelly", + "approved_at": "2026-08-02T23:11:48Z", + "notes": "Exact artifact from body-refine-20260802 commit d3fdfa2831bf938c0d9c8bf06f8796a00a935af5; selected over the older rb_v4 runtime-refit source." + }, + "regeneration_command": null +} diff --git a/content-pipeline/labs/humanoid-runtime-refit/source/male/pawn_male_refined.json b/content-pipeline/labs/humanoid-runtime-refit/source/male/pawn_male_refined.json new file mode 100644 index 00000000..d0b970f4 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/source/male/pawn_male_refined.json @@ -0,0 +1,29 @@ +{ + "body": "pawn_male_refined.glb", + "source_repository": "ssh://bunker/home/lycaon/git/successor.git", + "source_branch": "body-refine-20260802", + "source_commit": "d3fdfa2831bf938c0d9c8bf06f8796a00a935af5", + "source_sha256": "d5cb14b321a5f6024820e6c49eaa7f4200dca303336c95758792847f34211401", + "clips_from": "hash-pinned runtime shell during promotion", + "actions": 0, + "runtime_actions": 47, + "verts": 3226, + "tris": 5818, + "height": 1.7525, + "vertex_groups": 50, + "head": { + "method": "reviewed shared-topology 10-ring, 16-column faceted head and neck graft", + "face_panel_faces": 56, + "art_gates": { + "depth_over_height": true, + "rear_fill": true, + "occiput_peak": true + } + }, + "hands": { + "source": "verified legacy runtime hands", + "unchanged_by_body_refinement": true, + "islands_per_side": 36 + }, + "review": "Final Bunker humanoid refinement; immutable, head, transition, animation-proof, and viewer gates completed before commit." +} diff --git a/content-pipeline/labs/humanoid-runtime-refit/source/male/rb_v4_anim.glb b/content-pipeline/labs/humanoid-runtime-refit/source/male/rb_v4_anim.glb new file mode 100644 index 00000000..c17ea93f --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/source/male/rb_v4_anim.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd6d99baa5de4cbe585fc7075eb9e5bb3cc0006d809ca2655095a533a43f8be5 +size 2516900 diff --git a/content-pipeline/labs/humanoid-runtime-refit/source/male/rb_v4_anim.glb.manifest.json b/content-pipeline/labs/humanoid-runtime-refit/source/male/rb_v4_anim.glb.manifest.json new file mode 100644 index 00000000..f1e85608 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/source/male/rb_v4_anim.glb.manifest.json @@ -0,0 +1,31 @@ +{ + "asset_id": "successor_humanoid_source_rb_v4_anim", + "asset_path": "content-pipeline/labs/humanoid-runtime-refit/source/male/rb_v4_anim.glb", + "asset_hash": "sha256:dd6d99baa5de4cbe585fc7075eb9e5bb3cc0006d809ca2655095a533a43f8be5", + "asset_kind": "model_glb", + "tool": { + "name": "Khronos glTF Blender I/O", + "version": "5.2.39", + "tool_snapshot_id": null + }, + "prompt": { + "text": "Export the approved stylized Successor male body on the canonical 50-joint runtime rig and retain the complete 47-clip runtime animation set.", + "denylist_audit": "passed:tools/denylist/check.sh", + "negative_terms": [] + }, + "seed": null, + "request_id": null, + "human_edits": [ + { + "by": "Successor PawnForge body iteration lane", + "date": "2026-08-01T06:24:55Z", + "summary": "Refined the body silhouette and faceting, then rebound and gate-verified the runtime animation set." + } + ], + "review": { + "art_director": "Michael Kelly", + "approved_at": "2026-08-01T06:24:55Z", + "notes": "Pinned as the approved male source for the humanoid runtime refit and retained with its measured sidecar." + }, + "regeneration_command": null +} diff --git a/content-pipeline/labs/humanoid-runtime-refit/source/male/rb_v4_anim.json b/content-pipeline/labs/humanoid-runtime-refit/source/male/rb_v4_anim.json new file mode 100644 index 00000000..75a3ad5e --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/source/male/rb_v4_anim.json @@ -0,0 +1,59 @@ +{ + "body": "rb_v4.glb", + "clips_from": "pawn_male.glb", + "actions": 47, + "action_names": [ + "block_hold", + "cast_aoe", + "cast_directed_loop", + "cast_directed_windup", + "crouch_idle", + "crouch_walk", + "death_b", + "death_f", + "deflect_c", + "deflect_l", + "deflect_r", + "gun_grip_trigger_discipline", + "idle", + "kneel_loop", + "meditate_loop", + "melee_draw", + "melee_draw_alt", + "melee_flinch", + "melee_grip", + "melee_idle", + "melee_ready", + "melee_run_f", + "melee_run_v2", + "melee_sheath", + "melee_strafe_l", + "melee_strafe_r", + "melee_turn_180", + "melee_turn_90", + "melee_walk_f", + "melee_walk_v2", + "reload", + "rifle_aim", + "rifle_fire", + "rifle_idle", + "rifle_run_f", + "rifle_walk_f", + "run_f", + "stim_inject", + "strafe_l", + "strafe_r", + "swing_h1", + "swing_h2", + "swing_h3", + "swing_spin_aoe", + "swing_thrust", + "walk_b", + "walk_f" + ], + "worst_rest_delta_m": 3.07e-07, + "verts": 1099, + "tris": 1632, + "height": 1.7525, + "vertex_groups": 50 +} \ No newline at end of file diff --git a/content-pipeline/labs/humanoid-runtime-refit/triage_coverage.py b/content-pipeline/labs/humanoid-runtime-refit/triage_coverage.py new file mode 100644 index 00000000..c4d1ffbb --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/triage_coverage.py @@ -0,0 +1,81 @@ +"""Split the catalogue's zero-mask items into "exposes skin" and "misfits". + +An item with no `hideBodyZones` is not automatically a defect. A tank top, a +vest, shorts, a mohawk and an open-face helmet all correctly declare nothing: +they leave that skin on show. What IS a defect is an item that almost encloses a +zone -- it clearly means to cover it and misses by a few percent, which is +exactly where poke-through lives. + +The line is drawn on the measurement, not the name: `NEAR_MISS_FLOOR` of a zone +enclosed means "intended to cover"; below it, the garment simply does not. + + blender --background --python triage_coverage.py +""" + +from __future__ import annotations + +import json +import os +import sys + +LAB_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, LAB_DIR) + +import body_zone_coverage as COV # noqa: E402 +import refit_config as CFG # noqa: E402 + +GENERATOR = "successor content-pipeline/labs/humanoid-runtime-refit/triage_coverage.py" + +#: A zone this enclosed is one the garment means to cover. Below it the garment +#: is a strap, a crop, an open helmet or hair -- exposure by design. +NEAR_MISS_FLOOR = 0.90 + + +def main() -> None: + with open(os.path.join(CFG.REPORT_DIR, "body_zone_coverage.json"), + encoding="utf-8") as handle: + report = json.load(handle) + buckets: dict[str, list] = {"masked": [], "unattached": [], + "exposes_by_design": [], "near_miss": []} + for item_id, item in report["items"].items(): + if item["hide_body_zones"]: + buckets["masked"].append({"id": item_id, + "zones": item["hide_body_zones"]}) + continue + if not item["attached"]: + buckets["unattached"].append({"id": item_id, "reason": item["reason"]}) + continue + near = {} + for body_id, zones in item["measurement"].items(): + for zone, values in zones.items(): + if values["covered_fraction"] >= NEAR_MISS_FLOOR: + entry = near.setdefault(zone, {}) + entry[body_id] = [values["covered_fraction"], + values["exposed_cm2"], values["worst_pose"]] + record = {"id": item_id, "group": item["group"], "layer": item["layer"]} + if near: + record["near_zones"] = near + buckets["near_miss"].append(record) + else: + record["best_zone_coverage"] = round(max( + values["covered_fraction"] + for zones in item["measurement"].values() + for values in zones.values()), 5) + buckets["exposes_by_design"].append(record) + out = {"generator": GENERATOR, "near_miss_floor": NEAR_MISS_FLOOR, + "counts": {key: len(value) for key, value in buckets.items()}, + **buckets} + destination = os.path.join(CFG.REPORT_DIR, "coverage_triage.json") + with open(destination, "w", encoding="utf-8") as handle: + json.dump(out, handle, indent=2) + handle.write("\n") + print(f"[triage] {out['counts']}") + for record in buckets["near_miss"]: + zones = ", ".join(f"{zone} {min(v[0] for v in bodies.values()):.4f}" + for zone, bodies in record["near_zones"].items()) + print(f"[triage] near-miss {record['id']:32s} {record['group']:20s} {zones}") + print(f"[triage] -> {os.path.relpath(destination, CFG.REPO)}") + + +if __name__ == "__main__": + main() diff --git a/content-pipeline/labs/humanoid-runtime-refit/verify_body_zones.py b/content-pipeline/labs/humanoid-runtime-refit/verify_body_zones.py new file mode 100644 index 00000000..fa56c174 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/verify_body_zones.py @@ -0,0 +1,320 @@ +"""Gate the segmented bodies and the promoted masks against the runtime contract. + +Everything here is a fact about the shipped files, checked against the runtime +source rather than against a copy of it: the zone vocabulary is parsed straight +out of `client-3d/src/assets/pawnPack.ts`, so a rename on either side fails +loudly instead of drifting. + + blender --background --python verify_body_zones.py +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import sys + +import numpy as np + +LAB_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, LAB_DIR) + +import body_zones as BZ # noqa: E402 +import canonical_hands as HANDS # noqa: E402 +import reference_bodies # noqa: E402 +import refit_config as CFG # noqa: E402 +from gltf_io import Glb # noqa: E402 + +GENERATOR = "successor content-pipeline/labs/humanoid-runtime-refit/verify_body_zones.py" + +TS_SOURCE = os.path.join(CFG.REPO, "client-3d/src/assets/pawnPack.ts") +RUST_SOURCE = os.path.join(CFG.REPO, "client-rust/source/app/src/pawn/catalog.rs") + + +def ts_vocabulary() -> list[str]: + """`PAWN_BODY_ZONES` exactly as the Three runtime declares it.""" + with open(TS_SOURCE, encoding="utf-8") as handle: + source = handle.read() + block = re.search(r"export const PAWN_BODY_ZONES = \[(.*?)\] as const;", + source, re.S) + if not block: + raise SystemExit(f"{TS_SOURCE}: PAWN_BODY_ZONES not found") + return re.findall(r'"([a-z_]+)"', block.group(1)) + + +def main() -> None: + CFG.ensure_dirs() + failures: list[str] = [] + report: dict = {"generator": GENERATOR, "checks": {}} + + declared = ts_vocabulary() + report["checks"]["vocabulary"] = {"typescript": declared, "lab": list(BZ.ZONES), + "match": declared == list(BZ.ZONES)} + if declared != list(BZ.ZONES): + failures.append(f"zone vocabulary drift: TS {declared} vs lab {list(BZ.ZONES)}") + with open(RUST_SOURCE, encoding="utf-8") as handle: + rust = handle.read() + missing = [zone for zone in BZ.ZONES if f'"{zone}"' not in rust] + report["checks"]["rust_vocabulary"] = {"missing": missing} + if missing: + failures.append(f"{RUST_SOURCE} does not name {missing}") + + shell = Glb.load(CFG.RUNTIME_SHELL) + shell_joints = [shell.json["nodes"][j].get("name") + for j in shell.json["skins"][0]["joints"]] + shell_clips = [a["name"] for a in shell.json["animations"]] + shell_ibm = shell.accessor(shell.json["skins"][0]["inverseBindMatrices"]) + sources = reference_bodies.promotion_inputs() + references = reference_bodies.materialise() + + bodies = {} + for body_id, path in CFG.RUNTIME_BODY.items(): + glb = Glb.load(path) + primitives = glb.json["meshes"][0]["primitives"] + materials = [glb.json["materials"][p["material"]]["name"] for p in primitives] + expected = ([BZ.material_name(zone) for zone in BZ.ZONES] + + [BZ.material_name("head"), BZ.FACE_MATERIAL]) + overlay_slots = [index for index, name in enumerate(materials) + if name == BZ.FACE_MATERIAL] + skin_primitives = [primitive for index, primitive in enumerate(primitives) + if index not in overlay_slots] + joints = [glb.json["nodes"][j].get("name") + for j in glb.json["skins"][0]["joints"]] + clips = [a["name"] for a in glb.json.get("animations", ())] + ibm = glb.accessor(glb.json["skins"][0]["inverseBindMatrices"]) + index_types = {glb.json["accessors"][p["indices"]]["type"] for p in primitives} + skin_pbr = { + json.dumps(material["pbrMetallicRoughness"], sort_keys=True) + for material in glb.json["materials"] + if material["name"] != BZ.FACE_MATERIAL + } + source = Glb.load(sources[body_id]) + source_primitives = source.json["meshes"][0]["primitives"] + source_materials = [source.json["materials"][p["material"]]["name"] + for p in source_primitives] + source_face = source_primitives[source_materials.index(BZ.FACE_MATERIAL)] + source_skin = [primitive for primitive, material + in zip(source_primitives, source_materials) + if material != BZ.FACE_MATERIAL] + source_position_parts = [] + source_joint_parts = [] + source_weight_parts = [] + source_face_parts = [] + source_base = 0 + for primitive in source_skin: + position_part = source.accessor( + primitive["attributes"]["POSITION"]).astype(np.float64) + source_position_parts.append(position_part) + source_joint_parts.append(source.accessor( + primitive["attributes"]["JOINTS_0"]).astype(np.int64)) + source_weight_parts.append(source.accessor( + primitive["attributes"]["WEIGHTS_0"]).astype(np.float64)) + source_face_parts.append(source.accessor( + primitive["indices"]).astype(np.int64).reshape(-1, 3) + source_base) + source_base += len(position_part) + source_position = np.concatenate(source_position_parts) + source_joint_slots = np.concatenate(source_joint_parts) + source_weights = np.concatenate(source_weight_parts) + source_weights /= source_weights.sum(axis=1, keepdims=True) + source_faces = np.concatenate(source_face_parts) + source_joint_names = [ + source.json["nodes"][joint].get("name", "") + for joint in source.json["skins"][0]["joints"]] + source_labels, _ = BZ.segment( + source_position, source_faces, source_joint_names, + source_joint_slots, source_weights, body_id) + refined_hand_labels = { + BZ.ZONE_INDEX[zone] for zone in HANDS.HAND_ZONES} + source_non_hand_faces = source_faces[ + ~np.isin(source_labels, list(refined_hand_labels))] + source_non_hand_points = source_position[ + np.unique(source_non_hand_faces.reshape(-1))] + + hand_reference = Glb.load(references[body_id]) + canonical_hands = HANDS.extract(hand_reference, shell_joints) + canonical_hand_points = np.concatenate([ + canonical_hands[zone]["POSITION"] for zone in HANDS.HAND_ZONES]) + source_face_points = source.accessor( + source_face["attributes"]["POSITION"]).astype(np.float64) + out_points = np.unique(np.round(np.concatenate( + [glb.accessor(p["attributes"]["POSITION"]).astype(np.float64) + for p in skin_primitives]), 6), axis=0) + expected_points = np.unique(np.round(np.concatenate( + [source_non_hand_points, source_face_points, canonical_hand_points]), 6), + axis=0) + render_tris = sum(glb.json["accessors"][p["indices"]]["count"] + for p in primitives) // 3 + geometry_tris = sum(glb.json["accessors"][p["indices"]]["count"] + for p in skin_primitives) // 3 + refined_source_tris = sum( + source.json["accessors"][p["indices"]]["count"] + for p in source_primitives) // 3 + expected_hybrid_tris = ( + len(source_non_hand_faces) + + source.json["accessors"][source_face["indices"]]["count"] // 3 + + sum(len(canonical_hands[zone]["indices"]) + for zone in HANDS.HAND_ZONES)) + by_material = { + glb.json["materials"][primitive["material"]]["name"]: primitive + for primitive in primitives} + canonical_hands_exact = {} + for zone in HANDS.HAND_ZONES: + primitive = by_material.get(BZ.material_name(zone)) + expected_hand = canonical_hands[zone] + canonical_hands_exact[zone] = bool( + primitive is not None + and all(np.array_equal( + glb.accessor(primitive["attributes"][attribute]), + expected_hand[attribute]) + for attribute in ("POSITION", "NORMAL", "JOINTS_0", "WEIGHTS_0")) + and np.array_equal( + glb.accessor(primitive["indices"]).reshape(-1), + expected_hand["indices"].reshape(-1))) + + overlay_ok = len(overlay_slots) == 1 + overlay_inflate_delta = float("inf") + overlay_omits_colour = False + overlay_alpha_blend = False + if overlay_ok: + overlay = primitives[overlay_slots[0]] + overlay_position = glb.accessor( + overlay["attributes"]["POSITION"]).astype(np.float64) + source_position = source.accessor( + source_face["attributes"]["POSITION"]).astype(np.float64) + source_normal = source.accessor( + source_face["attributes"]["NORMAL"]).astype(np.float64) + expected_overlay = source_position + source_normal * CFG.FACE_OVERLAY_INFLATE + if overlay_position.shape == expected_overlay.shape: + overlay_inflate_delta = float( + np.abs(overlay_position - expected_overlay).max()) + overlay_omits_colour = "COLOR_0" not in overlay["attributes"] + overlay_material = glb.json["materials"][overlay["material"]] + overlay_alpha_blend = overlay_material.get("alphaMode") == "BLEND" + + entry = { + "path": os.path.relpath(path, CFG.REPO), + "sha256": hashlib.sha256(open(path, "rb").read()).hexdigest(), + "materials_exact": materials == expected, + "joints_match_shell": joints == shell_joints, + "clips_match_shell": clips == shell_clips, + "inverse_bind_max_delta": float(np.abs(ibm - shell_ibm).max()), + "index_accessor_types": sorted(index_types), + "skin_material_variants": len(skin_pbr), + "welded_points_match_hybrid_sources": bool( + out_points.shape == expected_points.shape + and np.array_equal(out_points, expected_points)), + "canonical_hands_exact": canonical_hands_exact, + "source_geometry_triangles": geometry_tris, + "expected_hybrid_triangles": expected_hybrid_tris, + "refined_source_triangles": refined_source_tris, + "triangles": render_tris, + "face_overlay_count": len(overlay_slots), + "face_overlay_alpha_blend": overlay_alpha_blend, + "face_overlay_omits_vertex_color": overlay_omits_colour, + "face_overlay_inflate_max_delta_m": overlay_inflate_delta, + "stature_m": round(float(out_points[:, 1].max()), 6), + "expected_stature_m": round(float(expected_points[:, 1].max()), 6), + } + bodies[body_id] = entry + if not entry["materials_exact"]: + failures.append(f"{body_id}: materials {materials} != {expected}") + if not entry["joints_match_shell"]: + failures.append(f"{body_id}: joint order drifted from the shell") + if not entry["clips_match_shell"]: + failures.append(f"{body_id}: clip list drifted from the shell") + if entry["inverse_bind_max_delta"] > 1e-6: + failures.append(f"{body_id}: inverse binds differ from the shell by " + f"{entry['inverse_bind_max_delta']:.2e}") + if entry["index_accessor_types"] != ["SCALAR"]: + failures.append(f"{body_id}: index accessors are " + f"{entry['index_accessor_types']}, must be SCALAR") + if entry["skin_material_variants"] != 1: + failures.append(f"{body_id}: zone materials are not visually identical " + f"({entry['skin_material_variants']} PBR variants)") + if not entry["welded_points_match_hybrid_sources"]: + failures.append( + f"{body_id}: opaque skin vertex set differs from refined body + canonical hands") + if not all(entry["canonical_hands_exact"].values()): + failures.append( + f"{body_id}: canonical hand graft drifted {entry['canonical_hands_exact']}") + if geometry_tris != expected_hybrid_tris: + failures.append( + f"{body_id}: opaque geometry has {geometry_tris} triangles vs hybrid " + f"sources {expected_hybrid_tris}") + if entry["face_overlay_count"] != 1: + failures.append( + f"{body_id}: expected one transparent face overlay, got {entry['face_overlay_count']}") + if not entry["face_overlay_alpha_blend"]: + failures.append(f"{body_id}: face overlay is not alpha-blended") + if not entry["face_overlay_omits_vertex_color"]: + failures.append(f"{body_id}: face overlay still carries darkening vertex colour") + if entry["face_overlay_inflate_max_delta_m"] > 1e-6: + failures.append( + f"{body_id}: face overlay inflate drift " + f"{entry['face_overlay_inflate_max_delta_m']:.2e} m") + if entry["stature_m"] != entry["expected_stature_m"]: + failures.append(f"{body_id}: stature {entry['stature_m']} != expected " + f"{entry['expected_stature_m']}") + report["bodies"] = bodies + + male = hashlib.sha256(open(CFG.RUNTIME_BODY["male"], "rb").read()).hexdigest() + for alias in CFG.RUNTIME_BODY_ALIAS["male"]: + digest = hashlib.sha256(open(alias, "rb").read()).hexdigest() + report["checks"][os.path.basename(alias)] = {"matches_male": digest == male} + if digest != male: + failures.append(f"{os.path.basename(alias)} is not the promoted male") + + with open(os.path.join(CFG.EQUIPMENT, "manifest.json"), encoding="utf-8") as handle: + manifest = json.load(handle) + unknown, bad_order, missing_variant = [], [], [] + for entry in manifest["items"]: + zones = entry.get("hideBodyZones", []) + unknown += [f"{entry['id']}:{zone}" for zone in zones + if zone not in BZ.ZONE_INDEX] + if zones != [zone for zone in BZ.ZONES if zone in set(zones)]: + bad_order.append(entry["id"]) + variant = entry.get("glbFemale") + if variant and not os.path.exists( + os.path.normpath(os.path.join(CFG.EQUIPMENT, variant))): + missing_variant.append(entry["id"]) + report["checks"]["manifest"] = { + "items": len(manifest["items"]), + "with_hide_body_zones": sum(1 for e in manifest["items"] + if e.get("hideBodyZones")), + "with_glb_female": sum(1 for e in manifest["items"] if e.get("glbFemale")), + "unknown_zones": unknown, "non_canonical_order": bad_order, + "missing_female_files": missing_variant, + } + failures += [f"manifest declares unknown zone {value}" for value in unknown] + failures += [f"{item}: hideBodyZones not in canonical order" for item in bad_order] + failures += [f"{item}: glbFemale points at a missing file" + for item in missing_variant] + + report["failures"] = failures + destination = os.path.join(CFG.REPORT_DIR, "body_zone_verification.json") + with open(destination, "w", encoding="utf-8") as handle: + json.dump(report, handle, indent=2) + handle.write("\n") + for body_id, entry in bodies.items(): + print(f"[verify] {body_id}: materials exact {entry['materials_exact']}, " + f"joints {entry['joints_match_shell']}, clips {entry['clips_match_shell']}, " + f"ibm delta {entry['inverse_bind_max_delta']:.2e}, " + f"hybrid points {entry['welded_points_match_hybrid_sources']}, " + f"canonical hands {entry['canonical_hands_exact']}, " + f"opaque tris {entry['source_geometry_triangles']}==" + f"{entry['expected_hybrid_triangles']}, " + f"render tris {entry['triangles']}, " + f"face inflate delta {entry['face_overlay_inflate_max_delta_m']:.2e}, " + f"stature {entry['stature_m']}") + print(f"[verify] vocabulary match {report['checks']['vocabulary']['match']}, " + f"rust missing {report['checks']['rust_vocabulary']['missing']}") + print(f"[verify] manifest {report['checks']['manifest']}") + print(f"[verify] {'PASS' if not failures else 'FAIL'}") + for failure in failures: + print(f"[verify] {failure}") + + +if __name__ == "__main__": + main() diff --git a/content-pipeline/labs/humanoid-runtime-refit/verify_starter_fit.py b/content-pipeline/labs/humanoid-runtime-refit/verify_starter_fit.py new file mode 100644 index 00000000..23e0ca2c --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/verify_starter_fit.py @@ -0,0 +1,295 @@ +"""Prove the authored starter pieces are clean on every surface a player can see. + +Hidden `BodyZone_*` skin is not rendered, so clearance against it is not a +contract -- chasing it only inflates the garment. What has to hold is what is +actually on screen, so this checks exactly that, by triangle overlap rather than +by distance heuristics: + + 1. `suit_excess_depth_mm` -- how much DEEPER than the bare body the suit reaches + into visible skin. Depth, not triangle counts: offset cloth necessarily spans + more triangles than the skin it replaces even when it is no deeper, and the + shipped clips already bury the hands in the thighs at `meditate_loop`. The + counts stay in the report. + 2. `boots_into_suit_away_from_collar_mm` -- the boot collar is MEANT to rest on + the suit cuff, so contact inside `COLLAR_BAND_M` of the boot's own boundary + loop is the design working. A crossing further in is a real fault. + 3. foot coverage -- both foot zones measured as safely enclosed, so + nothing pokes out from under the boot. + 4. `open_rim_gap_mm` -- every garment boundary vertex sits on the skin, so + a collar or cuff terminates against the body instead of leaving a slot. + +`hidden_skin_penetration_mm` is reported for information and is NOT a failure: it +is where the garment sits inside skin its own mask removes. + +Every pose in `pose_probe.POSES` plus the bind pose, on both bodies. Baselines are +measured on the BARE body in the same pose, because the shipped clips already +drive covered skin through visible skin -- `crouch_idle` puts the hands into the +thighs -- and a garment cannot be charged for that. + + blender --background --python verify_starter_fit.py +""" + +from __future__ import annotations + +import json +import os +import sys + +import numpy as np +from mathutils.bvhtree import BVHTree + +LAB_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, LAB_DIR) + +import body_zone_coverage as COV # noqa: E402 +import author_starter_apparel as AUTHOR # noqa: E402 +import body_zones as BZ # noqa: E402 +import pose_probe as POSE # noqa: E402 +import refit_config as CFG # noqa: E402 + +GENERATOR = "successor content-pipeline/labs/humanoid-runtime-refit/verify_starter_fit.py" + +#: How far past its authored landing an opening may drift before it reads as a +#: slot. Zero would forbid the boot collar its own 2 mm standoff over the suit. +OPEN_RIM_LIMIT_MM = 1.0 +#: How much DEEPER than the bare body's own contact a garment may reach into +#: visible skin. Counting triangle pairs over-rejects: offset cloth spans more +#: triangles than the skin it replaces even when it is no deeper, and the shipped +#: clips already bury the hands in the thighs at `meditate_loop`. Depth is the +#: quantity a viewer sees, so depth is the gate; the counts stay in the report. +EXCESS_DEPTH_LIMIT_MM = 1.0 +#: The authored standoffs, from `author_starter_apparel`. Two shells over the same +#: skin can legitimately come within the sum of their standoffs of each other +#: wherever the pose compresses them. +SUIT_STANDOFF_MM = AUTHOR.SUIT_OFFSET_M * 1000.0 +BOOT_STANDOFF_MM = AUTHOR.BOOT_OFFSET_M * 1000.0 +#: A boot collar is MEANT to touch the suit. Contact within this distance of the +#: boot's own boundary loop is the collar doing its job; a crossing further in is +#: a real fault. +COLLAR_BAND_M = 0.020 +#: Zones the starter loadout must hide, so nothing pokes out under the boot. +REQUIRED_HIDDEN = ("left_foot", "right_foot") + +AUTHORED = (("under_bodysuit", "Under/under_bodysuit.glb"), + ("boots_canvas_ankle", "Under/boots_canvas_ankle.glb")) + + +def variant_path(body_id: str, relative: str) -> str: + prefix = "" if body_id == "male" else f"{COV.FEMALE_PREFIX}/" + return os.path.normpath(os.path.join(CFG.EQUIPMENT, prefix + relative)) + + +def tree_for(positions: np.ndarray, faces: np.ndarray) -> BVHTree: + return BVHTree.FromPolygons([tuple(point) for point in positions], + [tuple(face) for face in faces], + all_triangles=True, epsilon=0.0) + + +def overlaps(a: BVHTree, b: BVHTree) -> int: + return len(a.overlap(b)) + + +def hit_set(a: BVHTree, b: BVHTree) -> set[int]: + """Which of `b`'s triangles `a` crosses.""" + return {pair[1] for pair in a.overlap(b)} + + +def boundary_vertices(positions: np.ndarray, faces: np.ndarray) -> np.ndarray: + """Vertices on a real opening. + + Welded first: a glTF export splits one surface into a primitive per material, + so raw index adjacency reports every dye-slot seam as a hole. The boot's + c0/c1/c3 split alone produced a phantom 10 mm "rim gap" in the bind pose. + """ + welded = BZ.weld(positions) + edges = np.concatenate([faces[:, [0, 1]], faces[:, [1, 2]], faces[:, [2, 0]]]) + keys = np.sort(welded[edges], axis=1) + unique, counts = np.unique(keys, axis=0, return_counts=True) + open_points = set(np.unique(unique[counts == 1]).tolist()) + return np.array([slot for slot, point in enumerate(welded) + if int(point) in open_points], dtype=np.int64) + + +def nearest_distance(points: np.ndarray, tree: BVHTree, limit: float) -> np.ndarray: + return np.fromiter( + (found[3] if (found := tree.find_nearest(tuple(point), limit))[0] is not None + else np.inf for point in points), dtype=np.float64, count=len(points)) + + +#: Signed distance only means something near a surface, so the search is capped +#: and misses are dropped rather than signed. +GAP_SEARCH_M = 0.030 + + +def signed_depth(points: np.ndarray, tree: BVHTree) -> np.ndarray: + """Signed distance to the surface along its normal; negative is inside.""" + out = np.full(len(points), np.inf) + for slot, point in enumerate(points): + location, normal, _, _ = tree.find_nearest(tuple(point), GAP_SEARCH_M) + if location is None: + continue + out[slot] = float(np.dot(point - np.array(location), np.array(normal))) + return out + + +def deepest(points: np.ndarray, tree: BVHTree) -> float: + depth = signed_depth(points, tree) + finite = depth[np.isfinite(depth)] + return float(finite.min()) if len(finite) else 0.0 + + +def main() -> None: + CFG.ensure_dirs() + with open(os.path.join(CFG.REPORT_DIR, "body_zone_coverage.json"), + encoding="utf-8") as handle: + coverage = json.load(handle)["items"] + report = {"generator": GENERATOR, "open_rim_limit_mm": OPEN_RIM_LIMIT_MM, + "excess_depth_limit_mm": EXCESS_DEPTH_LIMIT_MM, + "collar_band_mm": COLLAR_BAND_M * 1000.0, + "required_hidden_zones": list(REQUIRED_HIDDEN), "bodies": {}} + failures: list[str] = [] + + for body_id in ("male", "female"): + body = POSE.Body(body_id) + suit = POSE.SkinnedGeometry(variant_path(body_id, AUTHORED[0][1]), + f"under_bodysuit:{body_id}") + boots = POSE.SkinnedGeometry(variant_path(body_id, AUTHORED[1][1]), + f"boots_canvas_ankle:{body_id}") + hidden = set(coverage["under_bodysuit"]["hide_body_zones"]) + loadout = hidden | set(coverage["boots_canvas_ankle"]["hide_body_zones"]) + visible_faces = np.concatenate([f for zone, f in body.zone_faces.items() + if zone not in loadout]) + hidden_faces = np.concatenate([f for zone, f in body.zone_faces.items() + if zone in loadout]) + suit_rim = boundary_vertices(suit.position, suit.faces) + boot_rim = boundary_vertices(boots.position, boots.faces) + rim_points = boots.position[boot_rim] + away = np.array([float(np.linalg.norm(rim_points - point, axis=1).min()) + for point in boots.position]) > COLLAR_BAND_M + + deep = {"suit_excess_depth_mm": 0.0, "suit_depth_mm": 0.0, + "bare_body_depth_mm": 0.0, "bare_leg_contact_mm": 0.0, + "boots_into_suit_away_from_collar_mm": 0.0, + "boots_into_suit_at_collar_mm": 0.0, + "hidden_skin_penetration_mm": 0.0} + high = {"suit_vs_visible_pairs": 0, "bare_vs_visible_pairs": 0, + "boots_vs_suit_pairs": 0, "suit_rim_gap_mm": 0.0, + "boot_rim_gap_mm": 0.0} + where: dict[str, str | None] = {key: None for key in {**deep, **high}} + + for clip, phase in body.poses(): + label = "bind" if clip is None else f"{clip}@{phase:g}" + world, skin = body.pose(clip, phase) + suit_posed, boots_posed = suit.posed(world), boots.posed(world) + visible = tree_for(skin, visible_faces) + hidden_tree = tree_for(skin, hidden_faces) + suit_tree = tree_for(suit_posed, suit.faces) + boots_tree = tree_for(boots_posed, boots.faces) + + bare = min(0.0, deepest(skin[np.unique(hidden_faces)], visible)) + suit_depth = deepest(suit_posed, visible) + depth = signed_depth(boots_posed, suit_tree) + samples = { + "bare_body_depth_mm": bare * 1000.0, + "suit_depth_mm": suit_depth * 1000.0, + "suit_excess_depth_mm": (suit_depth - bare) * 1000.0, + "hidden_skin_penetration_mm": + deepest(suit_posed, hidden_tree) * 1000.0, + } + for key, mask in (("boots_into_suit_away_from_collar_mm", away), + ("boots_into_suit_at_collar_mm", ~away)): + picked = depth[mask & np.isfinite(depth)] + samples[key] = float(picked.min()) * 1000.0 if len(picked) else 0.0 + # Cross-legged and crouched clips already drive one leg's skin into + # the other's, so the boot shell inherits that contact through the + # suit. The baseline is the bare foot/calf skin against the rest of + # the hidden leg skin, measured in the same pose. + foot_calf = np.concatenate([body.zone_faces[f"{side}_{part}"] + for side in ("left", "right") + for part in ("foot", "calf")]) + leg_rest = np.concatenate([f for zone, f in body.zone_faces.items() + if zone in loadout + and not zone.endswith(("_foot", "_calf"))]) + samples["bare_leg_contact_mm"] = min(0.0, deepest( + skin[np.unique(foot_calf)], tree_for(skin, leg_rest))) * 1000.0 + for key, value in samples.items(): + if value < deep[key]: + deep[key], where[key] = round(value, 3), label + + counts = {"suit_vs_visible_pairs": overlaps(suit_tree, visible), + "bare_vs_visible_pairs": overlaps(hidden_tree, visible), + "boots_vs_suit_pairs": overlaps(boots_tree, suit_tree)} + skin_tree = tree_for(skin, body.faces) + # An opening is judged on EXCESS, not on absolute distance: the boot + # collar is authored to land one suit-standoff off the skin, so its + # nominal gap is already 2 mm and comparing that to a 1 mm limit only + # measures the design. + for key, points, against, nominal in ( + ("suit_rim_gap_mm", suit_posed[suit_rim], (skin_tree,), 0.0), + ("boot_rim_gap_mm", boots_posed[boot_rim], + (suit_tree, skin_tree), AUTHOR.BOOT_RIM_ON_SUIT_M * 1000.0)): + gap = np.minimum.reduce([nearest_distance(points, tree, 0.05) + for tree in against]) + finite = gap[np.isfinite(gap)] + counts[key] = (round(max(0.0, float(finite.max()) * 1000.0 - nominal), 3) + if len(finite) else 0.0) + for key, value in counts.items(): + if value > high[key]: + high[key], where[key] = value, label + + masked = [zone for zone in REQUIRED_HIDDEN if zone in loadout] + report["bodies"][body_id] = { + "suit": os.path.relpath(variant_path(body_id, AUTHORED[0][1]), CFG.REPO), + "boots": os.path.relpath(variant_path(body_id, AUTHORED[1][1]), CFG.REPO), + "suit_hidden_zones": sorted(hidden), + "loadout_hidden_zones": sorted(loadout), + "foot_zones_hidden": masked, + "depth_mm": deep, "counts": high, "worst_pose": where} + + if deep["suit_excess_depth_mm"] < -EXCESS_DEPTH_LIMIT_MM: + failures.append(f"{body_id}: suit reaches " + f"{-deep['suit_excess_depth_mm']:.3f} mm deeper into " + f"visible skin than the bare body at " + f"{where['suit_excess_depth_mm']}") + # Allowance: the two shells' own standoffs plus the pose's own limb + # contact plus a millimetre of measurement noise. + allowance = (SUIT_STANDOFF_MM + BOOT_STANDOFF_MM + EXCESS_DEPTH_LIMIT_MM + - deep["bare_leg_contact_mm"]) + report["bodies"][body_id]["boot_suit_allowance_mm"] = round(allowance, 3) + if deep["boots_into_suit_away_from_collar_mm"] < -allowance: + failures.append(f"{body_id}: boots cross the suit " + f"{-deep['boots_into_suit_away_from_collar_mm']:.3f} mm " + f"away from the collar at " + f"{where['boots_into_suit_away_from_collar_mm']}, " + f"against a {allowance:.3f} mm allowance") + for key in ("suit_rim_gap_mm", "boot_rim_gap_mm"): + if high[key] > OPEN_RIM_LIMIT_MM: + failures.append(f"{body_id}: {key} {high[key]:.3f} mm at {where[key]}") + if len(masked) != len(REQUIRED_HIDDEN): + failures.append(f"{body_id}: foot zones not hidden ({masked})") + + print(f"[fit] {body_id:6s} suit excess {deep['suit_excess_depth_mm']:8.3f} mm " + f"(suit {deep['suit_depth_mm']:8.3f} vs bare " + f"{deep['bare_body_depth_mm']:8.3f}, pairs " + f"{high['suit_vs_visible_pairs']}/{high['bare_vs_visible_pairs']})") + print(f"[fit] {'':6s} boot->suit away " + f"{deep['boots_into_suit_away_from_collar_mm']:8.3f} collar " + f"{deep['boots_into_suit_at_collar_mm']:8.3f} mm (pairs " + f"{high['boots_vs_suit_pairs']})") + print(f"[fit] {'':6s} rims {high['suit_rim_gap_mm']:.3f}/" + f"{high['boot_rim_gap_mm']:.3f} mm hidden-skin " + f"{deep['hidden_skin_penetration_mm']:.3f} mm (not visible) " + f"feet {masked}") + + report["failures"] = failures + destination = os.path.join(CFG.REPORT_DIR, "starter_fit.json") + with open(destination, "w", encoding="utf-8") as handle: + json.dump(report, handle, indent=2) + handle.write("\n") + print(f"[fit] {'PASS' if not failures else 'FAIL'}") + for failure in failures: + print(f"[fit] {failure}") + + +if __name__ == "__main__": + main() diff --git a/content-pipeline/labs/humanoid-runtime-refit/verify_wearable_fit.py b/content-pipeline/labs/humanoid-runtime-refit/verify_wearable_fit.py new file mode 100644 index 00000000..b68e5ca2 --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/verify_wearable_fit.py @@ -0,0 +1,579 @@ +"""Prove every hair piece and non-hair wearable actually sits on the body, on BOTH bodies. + +The humanoid refit guarantees: + 1. Every hair style seats within 3.0 mm of the scalp, buries no deeper than + 60 mm, leaves at most 8 un-covered scalp vertices inside its footprint, + and seats within 25.0 mm of its opposite-sex variant. + 2. Non-hair wearables (armor, tops, legs, gloves, boots, helmets) respect + category-specific standoff bands and hard burial limits. + 3. Rigid attachments (11 custom weapons + 3 legacy weapon rigs + hat_field_cap) + resolve their socket transform exactly as pawn/catalog.rs does, and are + measured against both skeletons for clearance, burial depth, and mount + position delta. +""" + +from __future__ import annotations + +import json +import os +import sys +import numpy as np +from mathutils import Vector +from mathutils.bvhtree import BVHTree + +LAB_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, LAB_DIR) + +import pose_probe as POSE # noqa: E402 +import refit_config as CFG # noqa: E402 +from gltf_io import Glb # noqa: E402 + +GENERATOR = "successor content-pipeline/labs/humanoid-runtime-refit/verify_wearable_fit.py" + +REPO = os.path.abspath(os.path.join(LAB_DIR, "..", "..", "..")) +EQUIPMENT = os.path.join(REPO, "client-3d", "public", "assets", + "pawn-pack", "equipment") + +#: Skin a cap vertex must come within to count as resting on the head. +CONTACT_BAND_MM = 2.0 +#: A cap whose closest point never reaches the skin is floating: the skull +#: shows through the gap and the hair reads as a hat hovering above the head. +FLOAT_LIMIT_MM = 3.0 +#: A cap is MEANT to cross the scalp -- a rim that stops at the skin leaves a +#: slot. Across the shipped set the crossing measures 11..46 mm (median 28), +#: so this rejects a piece swallowed well past the authored band rather than +#: pretending shallow is better. +BURIED_LIMIT_MM = 60.0 +#: The male and female variants are refits of one style, so they may differ by +#: a head-size margin. The shipped set differs by at most 9.8 mm; a piece that +#: disagrees by more than this has been fitted to one body only. +VARIANT_DELTA_LIMIT_MM = 25.0 +#: Where the scalp starts, as a fraction of head-zone height. Below the brow +#: the skin is face and jaw, which hair is not answerable for. +BROW_HEIGHT_FRACTION = 0.45 +#: A vertex whose outward normal points this strongly toward the face is part +#: of the face shell, not the scalp, even when it sits above the brow. +FACE_NORMAL_LIMIT = 0.5 +#: How far out from the skin a cover probe looks for hair. Anything further +#: than this is a silhouette the scalp was never behind. +COVER_PROBE_MM = 80.0 +#: A bare skull vertex counts as a poke-through when at least this share of its +#: neighbours ARE covered: the cap is present all around it and stops short +#: only there, which is the hole the player sees. A vertex on the open edge of +#: the style -- a hairline, a shaved back, the gap beside a ponytail -- has bare +#: neighbours too and is left alone. +POKE_NEIGHBOUR_FRACTION = 0.75 +#: The probe samples the scalp at its vertices, so a cap that genuinely +#: envelops still misses a few along the brow edge of the mask. Measured +#: against the helmets, which are built to swallow the skull whole: they leave +#: 5-7. Anything past that is the style itself failing to cover, and the refit +#: target is zero. +POKE_LIMIT = 8 +#: Skin zones that define the skull reference the crown is measured against. +HEAD_ZONES = ("head", "neck") + +#: Non-hair wearable category seating rules: standoff band [min, max] and hard burial limit. +#: +#: Category justifications: +#: - boots: Footwear wraps tightly around feet and ankles; standoff > 10.0 mm hovers off the foot, +#: while burial depth > 10.0 mm swallows foot/ankle skin. +#: - gloves: Enclose fingers and wrists closely; standoff > 15.0 mm leaves hover gaps, +#: while burial depth > 20.0 mm causes hand skin poke-through. +#: - legs: Trousers and skirts drape over legs; standoff > 15.0 mm creates ballooning gaps, +#: while burial depth > 10.0 mm swallows thigh/leg skin. +#: - tops: Torso vests and shirts sit on chest and waist; standoff > 15.0 mm hovers off torso, +#: while burial depth > 12.0 mm swallows chest/stomach skin. +#: - armor: Hard shoulder pads, back packs, and chest harnesses carry thicker plate geometry and tight straps; +#: standoff > 30.0 mm detaches from torso frame, while burial depth > 30.0 mm buries chest/hip plates. +#: - helmets: Full-head caps and helmets envelop the skull whole (depth up to 65.0 mm), +#: but must not float > 20.0 mm off the skull or penetrate past 65.0 mm. +CATEGORY_RULES = { + "boots": {"standoff_min_mm": -10.0, "standoff_max_mm": 10.0, "burial_limit_mm": 10.0}, + "gloves": {"standoff_min_mm": -20.0, "standoff_max_mm": 15.0, "burial_limit_mm": 20.0}, + "legs": {"standoff_min_mm": -10.0, "standoff_max_mm": 15.0, "burial_limit_mm": 10.0}, + "tops": {"standoff_min_mm": -12.0, "standoff_max_mm": 15.0, "burial_limit_mm": 12.0}, + "armor": {"standoff_min_mm": -30.0, "standoff_max_mm": 30.0, "burial_limit_mm": 30.0}, + "helmets": {"standoff_min_mm": -65.0, "standoff_max_mm": 20.0, "burial_limit_mm": 65.0}, +} + + +def variant_path(body_id: str, relative: str) -> str: + root = EQUIPMENT if body_id == "male" else os.path.join(EQUIPMENT, "Female") + return os.path.join(root, relative) + + +def wearables() -> list[str]: + """Every authored piece that has to fit both bodies.""" + found: list[str] = [] + for folder in ("Under", "Armor"): + directory = os.path.join(EQUIPMENT, folder) + if not os.path.isdir(directory): + continue + for name in sorted(os.listdir(directory)): + if name.endswith(".glb"): + found.append(f"{folder}/{name}") + return found + + +def is_hair(relative: str) -> bool: + return "hair" in os.path.basename(relative).lower() + + +def get_category(relative: str) -> str: + name = os.path.splitext(os.path.basename(relative))[0] + lname = name.lower() + if is_hair(relative): + return "hair" + elif lname.startswith("boots"): + return "boots" + elif lname.startswith("gloves"): + return "gloves" + elif lname.startswith("legs") or name in ("Shorts", "SCFI_Pants_Refit"): + return "legs" + elif lname.startswith("top") or name in ("Tank", "under_bodysuit", "SCFI_Top_Refit"): + return "tops" + elif lname.startswith("armor") or lname.startswith("trial_hip") or lname.startswith("trial_back") or name in ("Bicep_L", "Bicep_R", "Gorget", "Harness", "Nape_Reinforcement", "Reinforcement"): + return "armor" + elif lname.startswith("helmet") or lname.startswith("trial_helm") or lname.startswith("trial_hood") or lname.startswith("trial_visor") or lname.startswith("trial_mask") or name in ("Hat_Warm", "field_cap"): + return "helmets" + else: + return "other" + + +def vertex_normals(position, faces, indices): + """Area-weighted outward normal per skull vertex, in body space.""" + accumulated = np.zeros((len(position), 3), dtype=np.float64) + corners = position[faces] + cross = np.cross(corners[:, 1] - corners[:, 0], corners[:, 2] - corners[:, 0]) + for slot in range(3): + np.add.at(accumulated, faces[:, slot], cross) + lengths = np.linalg.norm(accumulated, axis=1) + lengths[lengths == 0.0] = 1.0 + return accumulated[indices] / lengths[indices, None] + + +def vertex_neighbours(faces, indices): + """Adjacency among the given vertices, from the faces that share them.""" + wanted = set(int(index) for index in indices) + adjacency: dict[int, set[int]] = {index: set() for index in wanted} + for face in faces: + for slot, vertex in enumerate(face): + vertex = int(vertex) + if vertex not in wanted: + continue + for other in (face[(slot + 1) % 3], face[(slot + 2) % 3]): + other = int(other) + if other in wanted: + adjacency[vertex].add(other) + return adjacency + + +def scalp_vertices(position, indices, normals): + """The part of the skull hair is responsible for covering. + + Not the whole head. The face is bare by design and the neck is nobody's + job, so probing them reports the open front of a helm as a hole. The scalp + is what remains: the cranium above the brow, minus the forward-facing + shell that carries the face. + """ + points = position[indices] + low, high = float(points[:, 1].min()), float(points[:, 1].max()) + brow = low + (high - low) * BROW_HEIGHT_FRACTION + # Whichever way the face points, it is the axis the head is deepest along. + middle = float(np.median(points[:, 2])) + forward = 1.0 if float(points[:, 2].max()) - middle >= middle - float(points[:, 2].min()) else -1.0 + facing = normals[:, 2] * forward + return np.where((points[:, 1] >= brow) & (facing < FACE_NORMAL_LIMIT))[0] + + +def scalp_exposure(cap, skull_indices, scalp_slots, position, normals, adjacency): + """Skull left bare inside the cap's own footprint. + + The seating measures above all run cap-to-body: they prove the hair TOUCHES + the head. None of them can see the opposite defect, where the cap is too + small or too tight and the skull pushes out through it -- every cap vertex + still rests on skin while the crown pokes into open air. This probes the + other direction, skull outward, and reports the holes. + """ + cap_tree = BVHTree.FromPolygons( + [tuple(point) for point in cap.position], + [tuple(face) for face in cap.faces]) + reach = COVER_PROBE_MM / 1000.0 + covered: dict[int, bool] = {} + for slot in scalp_slots: + index = skull_indices[slot] + point = position[index] + normal = normals[slot] + origin = Vector((float(point[0] + normal[0] * 1.0e-5), + float(point[1] + normal[1] * 1.0e-5), + float(point[2] + normal[2] * 1.0e-5))) + hit, _, _, _ = cap_tree.ray_cast( + origin, Vector((float(normal[0]), float(normal[1]), float(normal[2]))), reach) + covered[int(index)] = hit is not None + holes = 0 + for index, is_covered in covered.items(): + if is_covered: + continue + neighbours = adjacency.get(index) or () + if not neighbours: + continue + share = sum(1 for other in neighbours if covered.get(other)) / len(neighbours) + if share >= POKE_NEIGHBOUR_FRACTION: + holes += 1 + return holes, sum(1 for value in covered.values() if value), len(covered) + + +def quat_matrix(q: list[float] | np.ndarray) -> np.ndarray: + x, y, z, w = q + return np.array([ + [1 - 2*(y**2 + z**2), 2*(x*y - z*w), 2*(x*z + y*w), 0], + [2*(x*y + z*w), 1 - 2*(x**2 + z**2), 2*(y*z - x*w), 0], + [2*(x*z - y*w), 2*(y*z + x*w), 1 - 2*(x**2 + y**2), 0], + [0, 0, 0, 1] + ], dtype=np.float64) + + +def trs_matrix(pos: list[float], quat: list[float], scale: float = 1.0) -> np.ndarray: + m = quat_matrix(quat) + m[:3, :3] *= scale + m[:3, 3] = pos + return m + + +def load_glb_vertices(glb_path: str, model_scale: float = 1.0) -> np.ndarray: + g = Glb.load(glb_path) + nodes = g.json["nodes"] + count = len(nodes) + parent: dict[int, int] = {} + for idx, node in enumerate(nodes): + for child in node.get("children", ()): + parent[child] = idx + + roots = g.json.get("scenes", [{}])[0].get("nodes", []) if "scenes" in g.json else [] + if not roots: + has_p = set(parent.keys()) + roots = [i for i in range(count) if i not in has_p] + + def get_local(n: dict) -> np.ndarray: + if "matrix" in n: + return np.array(n["matrix"], dtype=np.float64).reshape(4, 4).T + t = np.array(n.get("translation", [0, 0, 0]), dtype=np.float64) + r = np.array(n.get("rotation", [0, 0, 0, 1]), dtype=np.float64) + s = np.array(n.get("scale", [1, 1, 1]), dtype=np.float64) + m = np.eye(4) + m[:3, :3] = quat_matrix(r)[:3, :3] * s + m[:3, 3] = t + return m + + cache: dict[int, np.ndarray] = {} + + def get_global(idx: int) -> np.ndarray: + if idx not in cache: + l = get_local(nodes[idx]) + p = parent.get(idx) + cache[idx] = l if p is None else get_global(p) @ l + return cache[idx] + + all_verts = [] + for idx, node in enumerate(nodes): + if "mesh" in node: + mesh_idx = node["mesh"] + mesh = g.json["meshes"][mesh_idx] + g_mat = get_global(idx) + if abs(model_scale - 1.0) > 1e-5: + s_mat = np.eye(4) + s_mat[0, 0] = s_mat[1, 1] = s_mat[2, 2] = model_scale + g_mat = s_mat @ g_mat + for prim in mesh["primitives"]: + pos_acc = prim["attributes"]["POSITION"] + pos_data = g.accessor(pos_acc).astype(np.float64) + ones = np.ones((len(pos_data), 1)) + pos_h = np.hstack([pos_data, ones]) + pos_world = (g_mat @ pos_h.T).T[:, :3] + all_verts.append(pos_world) + if not all_verts: + return np.zeros((0, 3)) + return np.vstack(all_verts) + + +def probe_rigid_mounts(male: POSE.Body, female: POSE.Body) -> tuple[dict[str, dict], list[str]]: + """Probe rigid attachments and weapons across male and female skeletons.""" + male_world = male.rig.world(None, 0.0) + female_world = female.rig.world(None, 0.0) + + male_tree = BVHTree.FromPolygons([tuple(p) for p in male.position], [tuple(f) for f in male.faces]) + female_tree = BVHTree.FromPolygons([tuple(p) for p in female.position], [tuple(f) for f in female.faces]) + + rigid_items = [] + field_cap_path = os.path.normpath(os.path.join(CFG.ASSETS, "items/custom/accessories/field_cap.glb")) + rigid_items.append({ + "id": "hat_field_cap", + "glb": field_cap_path, + "bone": "head", + "mount_pos": [0.0, 0.0, 0.0], + "mount_quat": [0.0, 0.0, 0.0, 1.0], + "scale": 1.0 + }) + + legacy_weapons = [ + ("slugthrower", "slugthrower.glb", "slugthrower_attach.json"), + ("vibrosword", "vibrosword.glb", "vibrosword_attach.json"), + ("plasma_hilt", "plasma_hilt.glb", "vibrosword_attach.json"), + ] + for item_id, glb_name, attach_name in legacy_weapons: + gpath = os.path.normpath(os.path.join(CFG.ASSETS, "pawn-pack", glb_name)) + apath = os.path.normpath(os.path.join(CFG.ASSETS, "pawn-pack", attach_name)) + with open(apath) as f: + spec = json.load(f) + m = spec["mount_hand_r_local"] + rigid_items.append({ + "id": item_id, + "glb": gpath, + "bone": "hand_r", + "mount_pos": m["pos"], + "mount_quat": m["quat"], + "scale": spec.get("scale_to_pawn", 1.0) + }) + + weapons_manifest_path = os.path.join(CFG.ASSETS, "pawn-pack/weapons/weapons_manifest.json") + with open(weapons_manifest_path) as f: + cmanifest = json.load(f) + + for item in cmanifest["items"]: + item_id = item["id"] + gpath = os.path.normpath(os.path.join(CFG.ASSETS, "pawn-pack/weapons", item["glb"])) + apath = os.path.normpath(os.path.join(CFG.ASSETS, "pawn-pack/weapons", item["attach"])) + with open(apath) as f: + spec = json.load(f) + m = spec["mount_hand_r_local"] + m_scale = spec.get("scale_to_pawn", item.get("scale", 1.0)) + rigid_items.append({ + "id": item_id, + "glb": gpath, + "bone": "hand_r", + "mount_pos": m["pos"], + "mount_quat": m["quat"], + "scale": m_scale + }) + + results = {} + failures = [] + + for item in rigid_items: + item_id = item["id"] + verts_local = load_glb_vertices(item["glb"], item["scale"]) + mount_mat = trs_matrix(item["mount_pos"], item["mount_quat"]) + + m_bone = male_world[item["bone"]] + m_mount_world_mat = m_bone @ mount_mat + m_mount_pos = m_mount_world_mat[:3, 3] + + f_bone = female_world[item["bone"]] + f_mount_world_mat = f_bone @ mount_mat + f_mount_pos = f_mount_world_mat[:3, 3] + + pos_delta_mm = float(np.linalg.norm(m_mount_pos - f_mount_pos) * 1000.0) + + m_signed = [] + if len(verts_local) > 0: + verts_h = np.hstack([verts_local, np.ones((len(verts_local), 1))]) + m_verts_world = (m_mount_world_mat @ verts_h.T).T[:, :3] + for pt in m_verts_world: + loc, norm, _, dist = male_tree.find_nearest(Vector((float(pt[0]), float(pt[1]), float(pt[2])))) + if loc is None: + continue + outward = (float(pt[0]) - loc.x, float(pt[1]) - loc.y, float(pt[2]) - loc.z) + inside = (outward[0]*norm.x + outward[1]*norm.y + outward[2]*norm.z) < 0.0 + m_signed.append(dist * 1000.0 * (-1.0 if inside else 1.0)) + + m_clear = float(np.min(m_signed)) if m_signed else 0.0 + m_depth = max(0.0, -m_clear) + + f_signed = [] + if len(verts_local) > 0: + verts_h = np.hstack([verts_local, np.ones((len(verts_local), 1))]) + f_verts_world = (f_mount_world_mat @ verts_h.T).T[:, :3] + for pt in f_verts_world: + loc, norm, _, dist = female_tree.find_nearest(Vector((float(pt[0]), float(pt[1]), float(pt[2])))) + if loc is None: + continue + outward = (float(pt[0]) - loc.x, float(pt[1]) - loc.y, float(pt[2]) - loc.z) + inside = (outward[0]*norm.x + outward[1]*norm.y + outward[2]*norm.z) < 0.0 + f_signed.append(dist * 1000.0 * (-1.0 if inside else 1.0)) + + f_clear = float(np.min(f_signed)) if f_signed else 0.0 + f_depth = max(0.0, -f_clear) + + # Check for rigid mount fit deviations + if item_id == "hat_field_cap" and f_clear > FLOAT_LIMIT_MM: + failures.append(f"rigid: {item_id} female mount floats {f_clear:.1f} mm above scalp (male clearance {m_clear:.1f} mm)") + + results[item_id] = { + "bone": item["bone"], + "male": { + "clearance_mm": round(m_clear, 3), + "max_depth_mm": round(m_depth, 3), + "mount_world_pos": [round(x, 5) for x in m_mount_pos], + }, + "female": { + "clearance_mm": round(f_clear, 3), + "max_depth_mm": round(f_depth, 3), + "mount_world_pos": [round(x, 5) for x in f_mount_pos], + }, + "delta_pos_mm": round(pos_delta_mm, 3), + "delta_clearance_mm": round(abs(m_clear - f_clear), 3) + } + + return results, failures + + +def main() -> None: + CFG.ensure_dirs() + report = {"generator": GENERATOR, + "contact_band_mm": CONTACT_BAND_MM, + "float_limit_mm": FLOAT_LIMIT_MM, + "buried_limit_mm": BURIED_LIMIT_MM, + "variant_delta_limit_mm": VARIANT_DELTA_LIMIT_MM, + "category_rules": CATEGORY_RULES, + "bodies": {}} + failures: list[str] = [] + pieces = wearables() + + male_body = None + female_body = None + + for body_id in ("male", "female"): + body = POSE.Body(body_id) + if body_id == "male": + male_body = body + else: + female_body = body + + head_faces = np.concatenate([faces for zone, faces + in body.zone_faces.items() + if zone in HEAD_ZONES]) + skull_indices = np.unique(head_faces.reshape(-1)) + skin = body.position[skull_indices] + skull_top = float(skin[:, 1].max()) + skull_normals = vertex_normals(body.position, body.faces, skull_indices) + scalp_slots = scalp_vertices(body.position, skull_indices, skull_normals) + skull_adjacency = vertex_neighbours(head_faces, skull_indices) + + body_tree = BVHTree.FromPolygons( + [tuple(point) for point in body.position], + [tuple(face) for face in body.faces]) + items: dict[str, dict[str, float | str | bool]] = {} + + for relative in pieces: + path = variant_path(body_id, relative) + if not os.path.exists(path): + failures.append(f"{body_id}: {relative} has no variant") + continue + cap = POSE.SkinnedGeometry(path, f"{relative}:{body_id}") + points = cap.position + signed_mm = np.empty(len(points), dtype=np.float64) + for slot, point in enumerate(points): + location, normal, _, distance = body_tree.find_nearest( + Vector((float(point[0]), float(point[1]), float(point[2])))) + if location is None: + signed_mm[slot] = np.inf + continue + outward = (float(point[0]) - location.x, + float(point[1]) - location.y, + float(point[2]) - location.z) + inside = (outward[0] * normal.x + outward[1] * normal.y + + outward[2] * normal.z) < 0.0 + signed_mm[slot] = distance * 1000.0 * (-1.0 if inside else 1.0) + + nearest_mm = float(signed_mm.min()) + contact = float((np.abs(signed_mm) <= CONTACT_BAND_MM).mean()) + crown_mm = (float(points[:, 1].max()) - skull_top) * 1000.0 + name = os.path.splitext(os.path.basename(relative))[0] + depth_mm = max(0.0, -nearest_mm) + holes, covered, probed = scalp_exposure( + cap, skull_indices, scalp_slots, body.position, skull_normals, + skull_adjacency) + cat = get_category(relative) + + items[name] = {"nearest_surface_mm": round(nearest_mm, 3), + "max_depth_mm": round(depth_mm, 3), + "contact_fraction": round(contact, 4), + "crown_clearance_mm": round(crown_mm, 2), + "scalp_poke_vertices": holes, + "scalp_covered_vertices": covered, + "scalp_probed_vertices": probed, + "hair": is_hair(relative), + "category": cat, + "vertices": int(len(points))} + + # Category seating checks + if cat == "hair": + if nearest_mm > FLOAT_LIMIT_MM: + failures.append(f"{body_id}: {name} never reaches the " + f"scalp ({nearest_mm:.2f} mm clear)") + elif depth_mm > BURIED_LIMIT_MM: + failures.append(f"{body_id}: {name} is buried " + f"{depth_mm:.1f} mm into the body") + if holes > POKE_LIMIT: + failures.append(f"{body_id}: {name} lets the skull through " + f"at {holes} points inside its own cap") + else: + rules = CATEGORY_RULES[cat] + if nearest_mm > rules["standoff_max_mm"]: + failures.append(f"{body_id}: {name} ({cat}) standoff {nearest_mm:.1f} mm " + f"exceeds max standoff {rules['standoff_max_mm']:.1f} mm") + elif nearest_mm < rules["standoff_min_mm"]: + failures.append(f"{body_id}: {name} ({cat}) standoff {nearest_mm:.1f} mm " + f"below min standoff {rules['standoff_min_mm']:.1f} mm") + if depth_mm > rules["burial_limit_mm"]: + failures.append(f"{body_id}: {name} ({cat}) burial depth {depth_mm:.1f} mm " + f"exceeds hard limit {rules['burial_limit_mm']:.1f} mm") + + report["bodies"][body_id] = {"skull_top_m": round(skull_top, 5), + "items": items} + hairs = [v for v in items.values() if v["hair"]] + seated = sum(1 for v in hairs + if v["nearest_surface_mm"] <= FLOAT_LIMIT_MM) + leaking = sum(1 for v in hairs + if v["scalp_poke_vertices"] > POKE_LIMIT) + print(f"[fitcheck] {body_id}: {len(items)} pieces measured, " + f"{seated}/{len(hairs)} hairs seated, " + f"{leaking}/{len(hairs)} leaking scalp") + + male_items = report["bodies"]["male"]["items"] + female_items = report["bodies"]["female"]["items"] + for name, male_fit in sorted(male_items.items()): + female_fit = female_items.get(name) + if female_fit is None: + failures.append(f"{name} has no female variant") + continue + delta = abs(male_fit["max_depth_mm"] - female_fit["max_depth_mm"]) + if delta > VARIANT_DELTA_LIMIT_MM: + failures.append(f"{name}: male and female seat {delta:.1f} mm " + f"apart ({male_fit['max_depth_mm']:.1f} vs " + f"{female_fit['max_depth_mm']:.1f})") + + if male_body and female_body: + rigid_results, rigid_failures = probe_rigid_mounts(male_body, female_body) + report["rigid_mounts"] = rigid_results + failures.extend(rigid_failures) + print(f"[fitcheck] {len(rigid_results)} rigid weapon/accessory mounts probed") + + report["failures"] = failures + out = os.path.join(CFG.REPORT_DIR, "wearable_fit.json") + with open(out, "w", encoding="utf-8") as handle: + json.dump(report, handle, indent=2) + handle.write("\n") + + print(f"[fitcheck] Report written to {out}") + + if failures: + print(f"[fitcheck] FAIL ({len(failures)})") + for failure in failures: + print(f"[fitcheck] {failure}") + raise SystemExit(1) + print("[fitcheck] PASS") + + +if __name__ == "__main__": + main() diff --git a/content-pipeline/labs/humanoid-runtime-refit/write_provenance.py b/content-pipeline/labs/humanoid-runtime-refit/write_provenance.py new file mode 100644 index 00000000..fc4e454b --- /dev/null +++ b/content-pipeline/labs/humanoid-runtime-refit/write_provenance.py @@ -0,0 +1,154 @@ +"""Regenerate the runtime bodies' provenance sidecars from the actual build. + +The checked-in `pawn_male.provenance.json` still described the 2026-07-21 +accessory body (2752 body vertices, hash 6eaac906...) and there was no female +sidecar at all, so the tree documented a lineage that no longer ships. Every +field here is read back out of this run's reports and the files on disk. + + blender --background --python write_provenance.py +""" + +from __future__ import annotations + +import hashlib +import json +import os +import sys + +LAB_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, LAB_DIR) + +import body_zones as BZ # noqa: E402 +import canonical_hands as HANDS # noqa: E402 +import refit_config as CFG # noqa: E402 + +GENERATOR = "successor content-pipeline/labs/humanoid-runtime-refit/write_provenance.py" +SCHEMA = "successor-asset-provenance/1" + + +def sha256_file(path: str) -> str: + with open(path, "rb") as handle: + return "sha256:" + hashlib.sha256(handle.read()).hexdigest() + + +def load(name: str) -> dict: + with open(os.path.join(CFG.REPORT_DIR, name), encoding="utf-8") as handle: + return json.load(handle) + + +def main() -> None: + promotion = load("promotion.json") + verification = load("body_zone_verification.json") + coverage = load("body_zone_coverage.json") + + written = [] + for body_id in ("male", "female"): + entry = promotion[body_id] + checks = verification["bodies"][body_id] + zones = json.loads(json.dumps(entry["zone_segmentation"]["zones"])) + for primitive in entry["primitives"]: + zone = primitive.get("zone") + if zone in HANDS.HAND_ZONES: + zones[zone].update({ + "vertices": primitive["vertices"], + "triangles": primitive["triangles"], + "area_cm2": primitive["area_cm2"], + "geometry_source": primitive["geometry_source"], + }) + targets = [(body_id, CFG.RUNTIME_BODY[body_id], None)] + for alias in CFG.RUNTIME_BODY_ALIAS[body_id]: + targets.append((f"{body_id}_bare", alias, CFG.RUNTIME_BODY[body_id])) + for asset_id, path, alias_of in targets: + source = { + "approved_body": promotion["approved_sources"][body_id]["path"], + "approved_body_hash": "sha256:" + promotion["approved_sources"][body_id]["sha256"], + "promotion_input": entry["source"], + "promotion_input_hash": "sha256:" + entry["source_sha256"], + "runtime_shell": os.path.relpath(CFG.RUNTIME_SHELL, CFG.REPO), + "runtime_shell_hash": "sha256:" + promotion["shell_sha256"], + "sidecar": entry["source_sidecar"], + "canonical_hands": entry["canonical_hand_source"]["path"], + "canonical_hands_hash": + "sha256:" + entry["canonical_hand_source"]["sha256"], + "generator": promotion["generator"], + "pipeline": [ + "reference_bodies.py (hash-pinned shell + approved sources)", + "bake_body_shading.py (per-vertex AO)", + "bake_face_texture.py (background-erased RGBA components)", + "promote_bodies.py (refined body + canonical animated hands + zone split + face skin base/overlay + accessor transplant)", + ], + } + if alias_of: + source["alias_of"] = os.path.relpath(alias_of, CFG.REPO) + source["reason"] = ( + "game_pack.json pawns.male.bare_file; the refit unified the " + "accommodation body and its full-volume sibling, so the bare " + "path is the same mesh and must carry the same zone primitives") + document = { + "schema": SCHEMA, + "asset_id": f"pawn_{asset_id}", + "asset_path": os.path.relpath(path, CFG.REPO), + "asset_hash": sha256_file(path), + "asset_kind": "pawn_body_glb", + "source": source, + "segmentation": { + "vocabulary": list(BZ.ZONES), + "material_pattern": f"{BZ.MATERIAL_PREFIX}", + "face_overlay_primitive": BZ.FACE_MATERIAL, + "face_skin_base_material": BZ.material_name("head"), + "method": ("per-tree-edge weight cuts, relaxed over the welded " + "dual graph, descended from the pelvis"), + "seams": entry["zone_segmentation"]["seams"], + "specks_absorbed": entry["zone_segmentation"]["specks_absorbed"], + "zones": zones, + }, + "metrics": { + "bytes": entry["bytes"], + "joints": entry["joints"], + "animations": entry["animations"], + "primitives": len(entry["primitives"]), + "triangles": checks["triangles"], + "expected_hybrid_triangles": checks["expected_hybrid_triangles"], + "refined_source_triangles": checks["refined_source_triangles"], + "height_max_y_m": checks["stature_m"], + }, + "validation": { + "materials_exact": checks["materials_exact"], + "joint_order_identical_to_shell": checks["joints_match_shell"], + "clip_names_identical_to_shell": checks["clips_match_shell"], + "inverse_bind_max_delta": checks["inverse_bind_max_delta"], + "index_accessor_types": checks["index_accessor_types"], + "zone_material_pbr_variants": checks["skin_material_variants"], + "welded_points_match_hybrid_sources": + checks["welded_points_match_hybrid_sources"], + "canonical_hands_exact": checks["canonical_hands_exact"], + "canonical_hand_compatibility": + entry["canonical_hand_source"]["compatibility"], + "gate": "content-pipeline/labs/humanoid-runtime-refit/verify_body_zones.py", + "reports": sorted(os.path.relpath( + os.path.join(CFG.REPORT_DIR, name), CFG.REPO) + for name in os.listdir(CFG.REPORT_DIR) + if name.endswith(".json")), + }, + "coverage": { + "manifest": coverage["manifest"]["path"], + "manifest_hash": "sha256:" + coverage["manifest"]["sha256"], + "items": len(coverage["items"]), + "items_with_coverage": coverage["manifest"]["items_with_coverage"], + "items_with_female_variant": + coverage["manifest"]["items_with_female_variant"], + }, + "generator": GENERATOR, + } + destination = os.path.splitext(path)[0] + ".provenance.json" + with open(destination, "w", encoding="utf-8") as handle: + json.dump(document, handle, indent=2) + handle.write("\n") + written.append(os.path.relpath(destination, CFG.REPO)) + print(f"[provenance] {document['asset_id']:12s} {document['asset_hash'][:19]} " + f"-> {written[-1]}") + print(f"[provenance] {len(written)} sidecars") + + +if __name__ == "__main__": + main() diff --git a/crates/successor-movement/Cargo.toml b/crates/successor-movement/Cargo.toml new file mode 100644 index 00000000..fc5566a0 --- /dev/null +++ b/crates/successor-movement/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "successor-movement" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Dependency-free deterministic swept-circle movement shared by authority and clients." + +[dependencies] diff --git a/crates/successor-movement/src/lib.rs b/crates/successor-movement/src/lib.rs new file mode 100755 index 00000000..e9b01d96 --- /dev/null +++ b/crates/successor-movement/src/lib.rs @@ -0,0 +1,8 @@ +#![no_std] + +mod solver; + +pub use solver::{ + circle_intersects_aabb, resolve_circle_move_milli, CircleAabb, CirclePoint, + CIRCLE_COLLISION_RADIUS_MILLI, CIRCLE_TRACE_SKIN_MILLI, +}; diff --git a/crates/successor-movement/src/solver.rs b/crates/successor-movement/src/solver.rs new file mode 100644 index 00000000..debafd45 --- /dev/null +++ b/crates/successor-movement/src/solver.rs @@ -0,0 +1,912 @@ +//! Integer swept circle-vs-AABB resolver shared with the client-side predictor. +//! +//! The TypeScript mirror lives in `client/src/slice-core/movementSystem.ts` +//! (`resolveCircleMove`). Keep the constants and scenario table in these two +//! files aligned: player center is swept in milli-cells, radius is 300 milli, +//! skin is 2 milli, and at most three slide iterations are performed. + +use core::cmp::Ordering; + +pub const CIRCLE_COLLISION_RADIUS_MILLI: i32 = 300; +pub const CIRCLE_TRACE_SKIN_MILLI: i32 = 2; +const CIRCLE_DEPENETRATION_EPSILON_MILLI: i32 = 1; +const MAX_INITIAL_DEPENETRATION_ITERATIONS: usize = 4; +const MAX_TRACE_SLIDE_ITERATIONS: usize = 3; +const TOI_SCALE: i64 = 1_000_000; +const NORMAL_SCALE_MILLI: i32 = 1_000; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct CirclePoint { + pub x: i32, + pub y: i32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CircleAabb { + pub left: i32, + pub top: i32, + pub right: i32, + pub bottom: i32, +} + +impl CircleAabb { + pub const fn new(left: i32, top: i32, right: i32, bottom: i32) -> Self { + Self { + left, + top, + right, + bottom, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct RationalToi { + num: i64, + den: i64, +} + +impl RationalToi { + const ONE: Self = Self { num: 1, den: 1 }; + + fn new(num: i64, den: i64) -> Self { + debug_assert!(den > 0); + Self { num, den } + } + + fn cmp(self, other: Self) -> Ordering { + (i128::from(self.num) * i128::from(other.den)) + .cmp(&(i128::from(other.num) * i128::from(self.den))) + } + + fn ge(self, other: Self) -> bool { + self.cmp(other) != Ordering::Less + } + + fn scaled_floor(self) -> i64 { + if self.num <= 0 { + return 0; + } + if self.ge(Self::ONE) { + return TOI_SCALE; + } + ((i128::from(self.num) * i128::from(TOI_SCALE)) / i128::from(self.den)) + .clamp(0, i128::from(TOI_SCALE)) as i64 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CircleTraceHit { + t_scaled: i64, + normal_x_milli: i32, + normal_y_milli: i32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CircleOverlapPush { + depth: i32, + push_x: i32, + push_y: i32, +} + +/// Resolve a center sweep against stable-order AABB faces plus rounded corner caps. +/// +/// TOI is represented as rational/fixed-point integer values: face hits use +/// exact rational slab times, corner hits solve the circle quadratic with +/// integer square roots, and the selected impact is projected onto a 1e6 +/// fixed-point scale for the skin/slide step. This keeps comparisons +/// deterministic without floats while staying below the 2-milli parity +/// tolerance used by the client/Rust shared scenario table. +pub fn resolve_circle_move_milli( + origin: CirclePoint, + delta_x: i32, + delta_y: i32, + radius_milli: i32, + blockers: &[CircleAabb], +) -> CirclePoint { + let mut position = origin; + let radius_milli = radius_milli.max(0); + if blockers.is_empty() { + position.x = position.x.saturating_add(delta_x); + position.y = position.y.saturating_add(delta_y); + return position; + } + + depenetrate_initial_circle_position(&mut position, radius_milli, blockers); + + let mut remaining_x = i64::from(delta_x); + let mut remaining_y = i64::from(delta_y); + for _ in 0..MAX_TRACE_SLIDE_ITERATIONS { + let distance = integer_sqrt_ceil( + remaining_x + .saturating_mul(remaining_x) + .saturating_add(remaining_y.saturating_mul(remaining_y)) as u64, + ) as i64; + if distance <= 0 { + break; + } + let Some(hit) = trace_circle_against_blockers( + position, + remaining_x, + remaining_y, + radius_milli, + blockers, + ) else { + position.x = saturating_i64_to_i32(i64::from(position.x).saturating_add(remaining_x)); + position.y = saturating_i64_to_i32(i64::from(position.y).saturating_add(remaining_y)); + break; + }; + + let t_scaled = hit.t_scaled; + let skin_scaled = div_ceil_i64(i64::from(CIRCLE_TRACE_SKIN_MILLI) * TOI_SCALE, distance); + let travel_scaled = t_scaled.saturating_sub(skin_scaled).clamp(0, TOI_SCALE); + let travel_x = scale_i64(remaining_x, travel_scaled, TOI_SCALE); + let travel_y = scale_i64(remaining_y, travel_scaled, TOI_SCALE); + position.x = saturating_i64_to_i32(i64::from(position.x).saturating_add(travel_x)); + position.y = saturating_i64_to_i32(i64::from(position.y).saturating_add(travel_y)); + + let leftover_scale = TOI_SCALE.saturating_sub(travel_scaled).clamp(0, TOI_SCALE); + remaining_x = scale_i64(remaining_x, leftover_scale, TOI_SCALE); + remaining_y = scale_i64(remaining_y, leftover_scale, TOI_SCALE); + let normal_x = i64::from(hit.normal_x_milli); + let normal_y = i64::from(hit.normal_y_milli); + let normal_len_sq = normal_x + .saturating_mul(normal_x) + .saturating_add(normal_y.saturating_mul(normal_y)) + .max(1); + let normal_dot = remaining_x + .saturating_mul(normal_x) + .saturating_add(remaining_y.saturating_mul(normal_y)); + if normal_dot < 0 { + let remove_x = div_round_i128( + i128::from(normal_x) * i128::from(normal_dot), + i128::from(normal_len_sq), + ); + let remove_y = div_round_i128( + i128::from(normal_y) * i128::from(normal_dot), + i128::from(normal_len_sq), + ); + remaining_x = saturating_i128_to_i64(i128::from(remaining_x).saturating_sub(remove_x)); + remaining_y = saturating_i128_to_i64(i128::from(remaining_y).saturating_sub(remove_y)); + } + } + + position +} + +pub fn circle_intersects_aabb(center: CirclePoint, radius_milli: i32, blocker: CircleAabb) -> bool { + circle_aabb_overlap_push(center, radius_milli.max(0), blocker).is_some() +} + +fn depenetrate_initial_circle_position( + position: &mut CirclePoint, + radius_milli: i32, + blockers: &[CircleAabb], +) { + for _ in 0..MAX_INITIAL_DEPENETRATION_ITERATIONS { + let mut best: Option = None; + for blocker in blockers { + let Some(push) = circle_aabb_overlap_push(*position, radius_milli, *blocker) else { + continue; + }; + if best.is_none_or(|candidate| push.depth > candidate.depth) { + best = Some(push); + } + } + let Some(push) = best else { break }; + if push.depth <= CIRCLE_DEPENETRATION_EPSILON_MILLI { + break; + } + position.x = position.x.saturating_add(push.push_x); + position.y = position.y.saturating_add(push.push_y); + } +} + +fn trace_circle_against_blockers( + position: CirclePoint, + dx: i64, + dy: i64, + radius_milli: i32, + blockers: &[CircleAabb], +) -> Option { + let mut best: Option = None; + for blocker in blockers { + let Some(hit) = + sweep_segment_against_rounded_aabb(position, dx, dy, radius_milli, *blocker) + else { + continue; + }; + if best.is_none_or(|candidate| hit.t_scaled < candidate.t_scaled) { + best = Some(hit); + } + } + best +} + +fn sweep_segment_against_rounded_aabb( + position: CirclePoint, + dx: i64, + dy: i64, + radius_milli: i32, + blocker: CircleAabb, +) -> Option { + if let Some(hit) = overlapping_circle_trace_hit(position, dx, dy, radius_milli, blocker) { + return Some(hit); + } + + let mut best: Option = None; + trace_vertical_face( + &mut best, + position, + dx, + dy, + i64::from(blocker.left).saturating_sub(i64::from(radius_milli)), + (blocker.top, blocker.bottom), + -NORMAL_SCALE_MILLI, + ); + trace_vertical_face( + &mut best, + position, + dx, + dy, + i64::from(blocker.right).saturating_add(i64::from(radius_milli)), + (blocker.top, blocker.bottom), + NORMAL_SCALE_MILLI, + ); + trace_horizontal_face( + &mut best, + position, + dx, + dy, + i64::from(blocker.top).saturating_sub(i64::from(radius_milli)), + (blocker.left, blocker.right), + -NORMAL_SCALE_MILLI, + ); + trace_horizontal_face( + &mut best, + position, + dx, + dy, + i64::from(blocker.bottom).saturating_add(i64::from(radius_milli)), + (blocker.left, blocker.right), + NORMAL_SCALE_MILLI, + ); + + trace_corner( + &mut best, + position, + dx, + dy, + radius_milli, + blocker.left, + blocker.top, + ); + trace_corner( + &mut best, + position, + dx, + dy, + radius_milli, + blocker.right, + blocker.top, + ); + trace_corner( + &mut best, + position, + dx, + dy, + radius_milli, + blocker.left, + blocker.bottom, + ); + trace_corner( + &mut best, + position, + dx, + dy, + radius_milli, + blocker.right, + blocker.bottom, + ); + best +} + +fn overlapping_circle_trace_hit( + position: CirclePoint, + dx: i64, + dy: i64, + radius_milli: i32, + blocker: CircleAabb, +) -> Option { + let push = circle_aabb_overlap_push(position, radius_milli, blocker)?; + let (normal_x_milli, normal_y_milli) = + normal_milli_from_vector(i128::from(push.push_x), i128::from(push.push_y)); + let normal_dot = dx + .saturating_mul(i64::from(normal_x_milli)) + .saturating_add(dy.saturating_mul(i64::from(normal_y_milli))); + if normal_dot >= 0 { + return None; + } + Some(CircleTraceHit { + t_scaled: 0, + normal_x_milli, + normal_y_milli, + }) +} + +fn trace_vertical_face( + best: &mut Option, + position: CirclePoint, + dx: i64, + dy: i64, + plane_x: i64, + span: (i32, i32), + normal_x_milli: i32, +) { + let den = if normal_x_milli < 0 { dx } else { -dx }; + if den <= 0 { + return; + } + let x = i64::from(position.x); + let t_num = if normal_x_milli < 0 { + plane_x.saturating_sub(x) + } else { + x.saturating_sub(plane_x) + }; + if t_num < 0 || t_num > den { + return; + } + let contact_y_num = + i128::from(position.y) * i128::from(den) + i128::from(dy) * i128::from(t_num); + let top = i128::from(span.0) * i128::from(den); + let bottom = i128::from(span.1) * i128::from(den); + if contact_y_num < top || contact_y_num > bottom { + return; + } + keep_earliest_hit( + best, + CircleTraceHit { + t_scaled: RationalToi::new(t_num, den).scaled_floor(), + normal_x_milli, + normal_y_milli: 0, + }, + ); +} + +fn trace_horizontal_face( + best: &mut Option, + position: CirclePoint, + dx: i64, + dy: i64, + plane_y: i64, + span: (i32, i32), + normal_y_milli: i32, +) { + let den = if normal_y_milli < 0 { dy } else { -dy }; + if den <= 0 { + return; + } + let y = i64::from(position.y); + let t_num = if normal_y_milli < 0 { + plane_y.saturating_sub(y) + } else { + y.saturating_sub(plane_y) + }; + if t_num < 0 || t_num > den { + return; + } + let contact_x_num = + i128::from(position.x) * i128::from(den) + i128::from(dx) * i128::from(t_num); + let left = i128::from(span.0) * i128::from(den); + let right = i128::from(span.1) * i128::from(den); + if contact_x_num < left || contact_x_num > right { + return; + } + keep_earliest_hit( + best, + CircleTraceHit { + t_scaled: RationalToi::new(t_num, den).scaled_floor(), + normal_x_milli: 0, + normal_y_milli, + }, + ); +} + +fn trace_corner( + best: &mut Option, + position: CirclePoint, + dx: i64, + dy: i64, + radius_milli: i32, + corner_x: i32, + corner_y: i32, +) { + let ox = i64::from(position.x).saturating_sub(i64::from(corner_x)); + let oy = i64::from(position.y).saturating_sub(i64::from(corner_y)); + let a = i128::from(dx) + .saturating_mul(i128::from(dx)) + .saturating_add(i128::from(dy).saturating_mul(i128::from(dy))); + if a <= 0 { + return; + } + let b = 2_i128.saturating_mul( + i128::from(ox) + .saturating_mul(i128::from(dx)) + .saturating_add(i128::from(oy).saturating_mul(i128::from(dy))), + ); + let radius = i128::from(radius_milli.max(0)); + let c = i128::from(ox) + .saturating_mul(i128::from(ox)) + .saturating_add(i128::from(oy).saturating_mul(i128::from(oy))) + .saturating_sub(radius.saturating_mul(radius)); + if c <= 0 { + return; + } + let discriminant = b + .saturating_mul(b) + .saturating_sub(4_i128.saturating_mul(a).saturating_mul(c)); + if discriminant < 0 { + return; + } + let sqrt_discriminant = integer_sqrt_ceil_u128(discriminant as u128) as i128; + let numerator = b.saturating_neg().saturating_sub(sqrt_discriminant); + let denominator = 2_i128.saturating_mul(a); + if numerator < 0 || numerator > denominator { + return; + } + let t_scaled = ((numerator.saturating_mul(i128::from(TOI_SCALE))) / denominator) + .clamp(0, i128::from(TOI_SCALE)) as i64; + let offset_x_scaled = i128::from(ox) + .saturating_mul(i128::from(TOI_SCALE)) + .saturating_add(i128::from(dx).saturating_mul(i128::from(t_scaled))); + let offset_y_scaled = i128::from(oy) + .saturating_mul(i128::from(TOI_SCALE)) + .saturating_add(i128::from(dy).saturating_mul(i128::from(t_scaled))); + let (normal_x_milli, normal_y_milli) = + normal_milli_from_vector(offset_x_scaled, offset_y_scaled); + let normal_dot = i128::from(dx) + .saturating_mul(i128::from(normal_x_milli)) + .saturating_add(i128::from(dy).saturating_mul(i128::from(normal_y_milli))); + if normal_dot >= 0 { + return; + } + keep_earliest_hit( + best, + CircleTraceHit { + t_scaled, + normal_x_milli, + normal_y_milli, + }, + ); +} + +fn keep_earliest_hit(best: &mut Option, hit: CircleTraceHit) { + if !(0..=TOI_SCALE).contains(&hit.t_scaled) { + return; + } + if best.is_none_or(|candidate| hit.t_scaled < candidate.t_scaled) { + *best = Some(hit); + } +} + +fn circle_aabb_overlap_push( + center: CirclePoint, + radius_milli: i32, + blocker: CircleAabb, +) -> Option { + let x = i64::from(center.x); + let y = i64::from(center.y); + let left = i64::from(blocker.left); + let right = i64::from(blocker.right); + let top = i64::from(blocker.top); + let bottom = i64::from(blocker.bottom); + let radius = i64::from(radius_milli.max(0)); + + if x >= left && x <= right && y >= top && y <= bottom { + let left_distance = (x - left).max(0); + let right_distance = (right - x).max(0); + let top_distance = (y - top).max(0); + let bottom_distance = (bottom - y).max(0); + let mut face_distance = left_distance; + let mut normal_x = -1; + let mut normal_y = 0; + if right_distance < face_distance { + face_distance = right_distance; + normal_x = 1; + normal_y = 0; + } + if top_distance < face_distance { + face_distance = top_distance; + normal_x = 0; + normal_y = -1; + } + if bottom_distance < face_distance { + face_distance = bottom_distance; + normal_x = 0; + normal_y = 1; + } + let depth = radius.saturating_add(face_distance); + let push = depth.saturating_add(i64::from(CIRCLE_DEPENETRATION_EPSILON_MILLI)); + return Some(CircleOverlapPush { + depth: saturating_i64_to_i32(depth), + push_x: saturating_i64_to_i32(i64::from(normal_x).saturating_mul(push)), + push_y: saturating_i64_to_i32(i64::from(normal_y).saturating_mul(push)), + }); + } + + let closest_x = x.clamp(left, right); + let closest_y = y.clamp(top, bottom); + let offset_x = x.saturating_sub(closest_x); + let offset_y = y.saturating_sub(closest_y); + let dist_sq = offset_x + .saturating_mul(offset_x) + .saturating_add(offset_y.saturating_mul(offset_y)); + let min_clearance = radius.saturating_sub(i64::from(CIRCLE_DEPENETRATION_EPSILON_MILLI)); + if dist_sq >= min_clearance.saturating_mul(min_clearance) { + return None; + } + if dist_sq <= 0 { + return Some(CircleOverlapPush { + depth: radius_milli, + push_x: radius_milli.saturating_add(CIRCLE_DEPENETRATION_EPSILON_MILLI), + push_y: 0, + }); + } + let distance = integer_sqrt_floor(dist_sq as u64).max(1) as i64; + let depth = radius.saturating_sub(distance).max(0); + let push_distance = depth.saturating_add(i64::from(CIRCLE_DEPENETRATION_EPSILON_MILLI)); + let mut push_x = scale_i64(offset_x, push_distance, distance); + let mut push_y = scale_i64(offset_y, push_distance, distance); + if push_x == 0 && offset_x != 0 { + push_x = offset_x.signum(); + } + if push_y == 0 && offset_y != 0 { + push_y = offset_y.signum(); + } + Some(CircleOverlapPush { + depth: saturating_i64_to_i32(depth), + push_x: saturating_i64_to_i32(push_x), + push_y: saturating_i64_to_i32(push_y), + }) +} + +fn scale_i64(value: i64, scale: i64, denom: i64) -> i64 { + if denom <= 0 || value == 0 || scale == 0 { + return 0; + } + let numerator = i128::from(value) * i128::from(scale); + let denom = i128::from(denom); + if numerator >= 0 { + ((numerator + denom / 2) / denom).clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64 + } else { + ((numerator - denom / 2) / denom).clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64 + } +} + +fn div_round_i128(value: i128, denom: i128) -> i128 { + if denom <= 0 || value == 0 { + return 0; + } + if value >= 0 { + (value + denom / 2) / denom + } else { + (value - denom / 2) / denom + } +} + +fn normal_milli_from_vector(x: i128, y: i128) -> (i32, i32) { + let dist_sq = x.saturating_mul(x).saturating_add(y.saturating_mul(y)); + if dist_sq <= 0 { + return (NORMAL_SCALE_MILLI, 0); + } + let distance = integer_sqrt_floor_u128(dist_sq as u128).max(1) as i128; + let normal_x = div_round_i128(x.saturating_mul(i128::from(NORMAL_SCALE_MILLI)), distance).clamp( + i128::from(-NORMAL_SCALE_MILLI), + i128::from(NORMAL_SCALE_MILLI), + ) as i32; + let normal_y = div_round_i128(y.saturating_mul(i128::from(NORMAL_SCALE_MILLI)), distance).clamp( + i128::from(-NORMAL_SCALE_MILLI), + i128::from(NORMAL_SCALE_MILLI), + ) as i32; + if normal_x == 0 && normal_y == 0 { + (NORMAL_SCALE_MILLI, 0) + } else { + (normal_x, normal_y) + } +} + +fn div_ceil_i64(value: i64, denom: i64) -> i64 { + if denom <= 0 { + return 0; + } + if value <= 0 { + return value / denom; + } + (value + denom - 1) / denom +} + +fn integer_sqrt_floor(value: u64) -> u64 { + if value <= 1 { + return value; + } + let mut low = 1_u64; + let mut high = value.min(u64::from(u32::MAX) * u64::from(u32::MAX)); + while low <= high { + let mid = low + (high - low) / 2; + let sq = u128::from(mid) * u128::from(mid); + match sq.cmp(&u128::from(value)) { + Ordering::Equal => return mid, + Ordering::Less => low = mid.saturating_add(1), + Ordering::Greater => high = mid.saturating_sub(1), + } + } + high +} + +fn integer_sqrt_ceil(value: u64) -> u64 { + let floor = integer_sqrt_floor(value); + if floor.saturating_mul(floor) == value { + floor + } else { + floor.saturating_add(1) + } +} + +fn integer_sqrt_floor_u128(value: u128) -> u128 { + if value <= 1 { + return value; + } + let mut low = 1_u128; + let mut high = value; + while low <= high { + let mid = low + (high - low) / 2; + let sq = mid.saturating_mul(mid); + match sq.cmp(&value) { + Ordering::Equal => return mid, + Ordering::Less => low = mid.saturating_add(1), + Ordering::Greater => high = mid.saturating_sub(1), + } + } + high +} + +fn integer_sqrt_ceil_u128(value: u128) -> u128 { + let floor = integer_sqrt_floor_u128(value); + if floor.saturating_mul(floor) == value { + floor + } else { + floor.saturating_add(1) + } +} + +fn saturating_i128_to_i64(value: i128) -> i64 { + value.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64 +} + +fn saturating_i64_to_i32(value: i64) -> i32 { + value.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32 +} + +#[cfg(test)] +mod tests { + use super::*; + + const WALL: CircleAabb = CircleAabb::new(5_000, 0, 5_295, 10_000); + const DOOR_LEFT_JAMB: CircleAabb = CircleAabb::new(1_000, 5_000, 2_442, 5_295); + const DOOR_RIGHT_JAMB: CircleAabb = CircleAabb::new(3_558, 5_000, 5_000, 5_295); + const HOUSE_LEFT_LOWER_SILL: CircleAabb = CircleAabb::new(502_658, 511_895, 502_921, 511_921); + const HOUSE_RIGHT_LOWER_SILL: CircleAabb = CircleAabb::new(504_026, 511_895, 504_158, 511_921); + + fn resolve( + origin: CirclePoint, + delta_x: i32, + delta_y: i32, + blockers: &[CircleAabb], + ) -> CirclePoint { + resolve_circle_move_milli( + origin, + delta_x, + delta_y, + CIRCLE_COLLISION_RADIUS_MILLI, + blockers, + ) + } + + #[test] + fn sprint_length_swept_move_stops_radius_plus_skin_outside_thin_band() { + let result = resolve(CirclePoint { x: 2_000, y: 5_000 }, 10_000, 0, &[WALL]); + assert_eq!( + result.x, + WALL.left - CIRCLE_COLLISION_RADIUS_MILLI - CIRCLE_TRACE_SKIN_MILLI + ); + assert_eq!(result.y, 5_000); + assert!(!circle_intersects_aabb( + result, + CIRCLE_COLLISION_RADIUS_MILLI, + WALL + )); + } + + #[test] + fn diagonal_slide_preserves_tangential_travel() { + let result = resolve(CirclePoint { x: 4_600, y: 2_000 }, 1_000, 1_000, &[WALL]); + assert!( + (WALL.left - CIRCLE_COLLISION_RADIUS_MILLI - CIRCLE_TRACE_SKIN_MILLI + ..=WALL.left - CIRCLE_COLLISION_RADIUS_MILLI) + .contains(&result.x) + ); + assert!((2_998..=3_002).contains(&result.y), "slide y={}", result.y); + assert!(!circle_intersects_aabb( + result, + CIRCLE_COLLISION_RADIUS_MILLI, + WALL + )); + } + + #[test] + fn origin_inside_recovers_outward_without_crossing_far_face() { + let result = resolve(CirclePoint { x: 5_150, y: 6_000 }, 0, 0, &[WALL]); + assert_eq!( + result.x, + WALL.right + CIRCLE_COLLISION_RADIUS_MILLI + CIRCLE_DEPENETRATION_EPSILON_MILLI + ); + assert_eq!(result.y, 6_000); + assert!(result.x > WALL.right); + assert!(!circle_intersects_aabb( + result, + CIRCLE_COLLISION_RADIUS_MILLI, + WALL + )); + } + + #[test] + fn closed_door_blocker_stops_gap_and_open_door_passes() { + let closed = [CircleAabb::new(2_442, 5_000, 3_558, 5_295)]; + let stopped = resolve(CirclePoint { x: 3_000, y: 5_900 }, 0, -1_700, &closed); + assert!(stopped.y >= 5_295 + CIRCLE_COLLISION_RADIUS_MILLI); + assert!(!circle_intersects_aabb( + stopped, + CIRCLE_COLLISION_RADIUS_MILLI, + closed[0] + )); + + let open = resolve(CirclePoint { x: 3_000, y: 5_900 }, 0, -1_700, &[]); + assert_eq!(open, CirclePoint { x: 3_000, y: 4_200 }); + } + + #[test] + fn rounded_corner_trace_stops_on_arc_instead_of_square_corner_face() { + let box_blocker = CircleAabb::new(5_000, 5_000, 6_000, 6_000); + let result = resolve( + CirclePoint { x: 4_000, y: 4_000 }, + 2_000, + 2_000, + &[box_blocker], + ); + + assert!( + (4_784..=4_788).contains(&result.x) && (4_784..=4_788).contains(&result.y), + "corner arc stop should be near the true circle-vs-corner TOI, got {:?}", + result + ); + assert!(!circle_intersects_aabb( + result, + CIRCLE_COLLISION_RADIUS_MILLI, + box_blocker + )); + } + + #[test] + fn doorway_sill_brush_keeps_northward_progress() { + let blockers = [HOUSE_LEFT_LOWER_SILL, HOUSE_RIGHT_LOWER_SILL]; + let origin = CirclePoint { + x: 503_150, + y: 512_350, + }; + let result = resolve(origin, 0, -3_000, &blockers); + + assert!( + result.y < 511_000, + "rounded corner response should glide through a door-sill brush instead of stopping at the square inflated bottom face: {:?}", + result + ); + assert!( + result.x > origin.x, + "left sill brush should push toward the open doorway, got {:?}", + result + ); + for blocker in blockers { + assert!(!circle_intersects_aabb( + result, + CIRCLE_COLLISION_RADIUS_MILLI, + blocker + )); + } + } + + #[test] + fn adjacent_aabb_seam_does_not_snag_tangential_motion() { + let blockers = [ + CircleAabb::new(5_000, 0, 5_200, 5_000), + CircleAabb::new(5_000, 5_000, 5_200, 10_000), + ]; + let result = resolve(CirclePoint { x: 4_698, y: 2_000 }, 500, 6_000, &blockers); + assert!( + result.x <= 4_700, + "wall normal must remain closed: {result:?}" + ); + assert!( + result.y >= 7_998, + "seam must preserve tangential travel: {result:?}" + ); + } + + #[test] + fn inside_corner_manifold_stops_without_tunneling() { + let blockers = [ + CircleAabb::new(5_000, 0, 5_200, 10_000), + CircleAabb::new(0, 5_000, 10_000, 5_200), + ]; + let result = resolve(CirclePoint { x: 4_000, y: 4_000 }, 3_000, 3_000, &blockers); + assert!(result.x <= 4_700 && result.y <= 4_700, "{result:?}"); + for blocker in blockers { + assert!(!circle_intersects_aabb( + result, + CIRCLE_COLLISION_RADIUS_MILLI, + blocker, + )); + } + } + + #[allow(clippy::type_complexity)] + #[test] + fn shared_client_parity_scenario_table() { + let cases: &[(&str, CirclePoint, (i32, i32), &[CircleAabb], CirclePoint)] = &[ + ( + "head-on stop", + CirclePoint { x: 2_000, y: 5_000 }, + (10_000, 0), + &[WALL], + CirclePoint { x: 4_698, y: 5_000 }, + ), + ( + "diagonal slide", + CirclePoint { x: 4_600, y: 2_000 }, + (1_000, 1_000), + &[WALL], + CirclePoint { x: 4_699, y: 3_000 }, + ), + ( + "corner", + CirclePoint { x: 4_000, y: 4_000 }, + (2_000, 2_000), + &[CircleAabb::new(5_000, 5_000, 6_000, 6_000)], + CirclePoint { x: 4_786, y: 4_786 }, + ), + ( + "door-gap pass", + CirclePoint { x: 3_000, y: 5_900 }, + (0, -1_700), + &[DOOR_LEFT_JAMB, DOOR_RIGHT_JAMB], + CirclePoint { x: 3_000, y: 4_200 }, + ), + ( + "origin-inside recovery", + CirclePoint { x: 5_150, y: 6_000 }, + (0, 0), + &[WALL], + CirclePoint { x: 5_596, y: 6_000 }, + ), + ]; + + for (label, origin, (dx, dy), blockers, expected) in cases { + let actual = resolve(*origin, *dx, *dy, blockers); + assert!( + actual.x.abs_diff(expected.x) <= 2 && actual.y.abs_diff(expected.y) <= 2, + "{label}: expected {:?}, got {:?}", + expected, + actual + ); + } + } +} diff --git a/crates/successor-net/src/lib.rs b/crates/successor-net/src/lib.rs index 6ae17fa6..e9ff2d43 100644 --- a/crates/successor-net/src/lib.rs +++ b/crates/successor-net/src/lib.rs @@ -306,6 +306,32 @@ pub enum ClientCommand { variant_id: u32, quantity: i32, }, + /// Coordinator-owned travel ticket purchase. Execution remains in the + /// TypeScript shard coordinator; the Rust client only submits the command. + PurchaseTravelTicket { + terminal_prop_id: String, + to_planet_id: String, + to_city_id: String, + }, + /// Consume a coordinator-issued travel ticket. + UseTravelTicket { + #[serde(default, skip_serializing_if = "Option::is_none")] + container: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + stack_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + ticket_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + item_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + item_numeric_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + variant_id: Option, + }, + /// Coordinator-owned door toggle. + ToggleDoor { + prop_id: String, + }, CraftItem { schematic_id: String, experiment_power: u8, @@ -416,6 +442,11 @@ pub enum ClientCommand { DebugGrantSkillBoxes { skill_box_ids: Vec, }, + /// Debug-only wallet grant. Negative amounts subtract and clamp at zero + /// rather than wrapping, so a tester can drain a wallet as well as fill it. + DebugGiveCredits { + amount: i64, + }, GroupInvite { target_actor_id: String, }, @@ -632,7 +663,7 @@ impl ClientCommand { Self::RefillAmmo { .. } => 7, Self::ApplyServiceBuff { .. } => 5, Self::CloneRespawn { .. } => 6, - Self::ReviveActor { .. } => 24, + Self::ReviveActor { .. } => 10, Self::BankStoreItem { .. } => 130, Self::BankRetrieveItem { .. } => 131, Self::BankDepositCredits { .. } => 132, @@ -656,6 +687,9 @@ impl ClientCommand { Self::RedeemCreditChip { .. } => 94, Self::HarvestCorpse { .. } => 9, Self::TakeLootItem { .. } => 37, + Self::PurchaseTravelTicket { .. } => 155, + Self::UseTravelTicket { .. } => 156, + Self::ToggleDoor { .. } => 157, Self::CraftItem { .. } => 11, Self::CraftBegin { .. } => 48, Self::CraftAssignSlot { .. } => 49, @@ -683,6 +717,7 @@ impl ClientCommand { Self::ConfirmTrade { .. } => 66, Self::DebugGiveItem { .. } => 38, Self::DebugGrantSkillBoxes { .. } => 39, + Self::DebugGiveCredits { .. } => 98, Self::GroupInvite { .. } => 57, Self::GroupAccept {} => 58, Self::GroupDecline {} => 59, @@ -735,6 +770,15 @@ impl ClientCommand { Self::GuildDisband {} => 149, } } + /// Debug-only commands are never valid on a production capability path. + pub const fn is_debug_only(&self) -> bool { + matches!( + self, + Self::DebugGiveItem { .. } + | Self::DebugGrantSkillBoxes { .. } + | Self::DebugGiveCredits { .. } + ) + } pub fn write_to(&self, w: &mut StateWriter) { match self { Self::Move { @@ -982,6 +1026,36 @@ impl ClientCommand { .write_u32(*variant_id) .write_i64(i64::from(*quantity)); } + Self::PurchaseTravelTicket { + terminal_prop_id, + to_planet_id, + to_city_id, + } => { + w.write_u32(self.wire_tag()); + write_string(w, terminal_prop_id); + write_string(w, to_planet_id); + write_string(w, to_city_id); + } + Self::UseTravelTicket { + container, + stack_id, + ticket_id, + item_id, + item_numeric_id, + variant_id, + } => { + w.write_u32(self.wire_tag()); + write_optional_string(w, container.as_deref()); + write_optional_string(w, stack_id.as_deref()); + write_optional_string(w, ticket_id.as_deref()); + write_optional_string(w, item_id.as_deref()); + w.write_u32(item_numeric_id.unwrap_or(0)); + w.write_u32(variant_id.map(|v| v.saturating_add(1)).unwrap_or(0)); + } + Self::ToggleDoor { prop_id } => { + w.write_u32(self.wire_tag()); + write_string(w, prop_id); + } Self::CraftItem { schematic_id, experiment_power, @@ -1167,6 +1241,9 @@ impl ClientCommand { w.write_u32(self.wire_tag()); write_string(w, target_actor_id); } + Self::DebugGiveCredits { amount } => { + w.write_u32(self.wire_tag()).write_i64(*amount); + } Self::GroupAccept {} => { w.write_u32(self.wire_tag()); } @@ -2243,9 +2320,9 @@ mod tests { 119, 54, 55, 56, 18, 97, 19, 25, 12, 13, 14, 15, 16, 63, 64, 65, 66, 38, 39, 57, 58, 59, 60, 61, 62, 90, 91, 92, 93, 136, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 150, 96, 137, 138, 139, 140, 141, 142, 143, 144, - 145, 146, 147, 148, 149, + 145, 146, 147, 148, 149, 155, 156, 157, ]; - assert_eq!(EXPECTED_WIRE_TAGS.len(), 110); + assert_eq!(EXPECTED_WIRE_TAGS.len(), 113); let unique = EXPECTED_WIRE_TAGS .iter() .copied() diff --git a/crates/successor-sim/Cargo.toml b/crates/successor-sim/Cargo.toml index 496b1aed..d2b9bb31 100644 --- a/crates/successor-sim/Cargo.toml +++ b/crates/successor-sim/Cargo.toml @@ -8,6 +8,7 @@ description = "Top-level simulation: hosts subsystems, drives the tick." [dependencies] successor-core = { path = "../successor-core" } successor-inventory = { path = "../successor-inventory" } +successor-movement = { path = "../successor-movement" } successor-net = { path = "../successor-net" } serde.workspace = true serde_json.workspace = true diff --git a/crates/successor-sim/src/authority.rs b/crates/successor-sim/src/authority.rs index 4c8ab0fa..fdb070a5 100644 --- a/crates/successor-sim/src/authority.rs +++ b/crates/successor-sim/src/authority.rs @@ -99,6 +99,7 @@ mod actors; mod ai; mod combat_roll; mod commands; +pub mod debug_catalog; use self::combat_roll::*; use self::commands::normalize_command_key; mod camps; @@ -379,6 +380,12 @@ const COMBAT_HELM_ITEM_ID: u32 = 7_103; const STEN_MK2_ITEM_ID: u32 = 3_111; const KILN_ENERGY_CELL_ITEM_ID: u32 = 3_112; const LIGHTNING_CARBINE_ITEM_ID: u32 = 3_121; +const BADGE_BOLT_PISTOL_ITEM_ID: u32 = 3_122; +const SLAGRAIL_VANGUARD_ITEM_ID: u32 = 3_123; +const COILGATE_SCATTER_ITEM_ID: u32 = 3_124; +const KILN_LONG_PATTERN_ITEM_ID: u32 = 3_125; +const BASTION_LMG_ITEM_ID: u32 = 3_126; +const FLARE_NET_LAUNCHER_ITEM_ID: u32 = 3_127; // Credit Chip: a PHYSICAL, lootable/tradeable currency item whose `quantity` IS // its credit value (owner ruling 2026-07-08). Redeemed into the scalar // `professions.credits` balance via RedeemCreditChip; split/merge like any stack. diff --git a/crates/successor-sim/src/authority/actor_state.rs b/crates/successor-sim/src/authority/actor_state.rs index 71e76bbe..9e6e29fe 100644 --- a/crates/successor-sim/src/authority/actor_state.rs +++ b/crates/successor-sim/src/authority/actor_state.rs @@ -1066,6 +1066,10 @@ pub struct AuthorityActorSnapshot { #[serde(default)] pub shot_spread_degrees_milli: i32, #[serde(default)] + pub walk_speed_milli_per_second: i32, + #[serde(default)] + pub sprint_speed_milli_per_second: i32, + #[serde(default)] pub credits: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub combat_queue: Option, @@ -1161,6 +1165,20 @@ impl AuthorityActorSnapshot { ) }) .unwrap_or(0), + walk_speed_milli_per_second: scaled_milli( + PLAYER_SPEED_MILLI_CELLS_PER_SECOND, + movement_speed_multiplier_milli_for_actor(actor), + ), + sprint_speed_milli_per_second: scaled_milli( + PLAYER_SPEED_MILLI_CELLS_PER_SECOND, + scaled_milli( + movement_speed_multiplier_milli_for_actor(actor), + scaled_milli( + SPRINT_SPEED_MULTIPLIER_MILLI, + sprint_speed_multiplier_milli_for_actor(actor), + ), + ), + ), credits: actor.professions.credits, combat_queue: AuthorityCombatQueueSnapshot::from_actor(actor), ability_queue: AuthorityAbilityQueueSnapshot::from_actor(actor), diff --git a/crates/successor-sim/src/authority/commands.rs b/crates/successor-sim/src/authority/commands.rs index 3ce74cdb..dd9706e8 100644 --- a/crates/successor-sim/src/authority/commands.rs +++ b/crates/successor-sim/src/authority/commands.rs @@ -501,6 +501,9 @@ impl SliceAuthorityState { ClientCommand::DebugGrantSkillBoxes { skill_box_ids } => { self.apply_debug_grant_skill_boxes(config, skill_box_ids) } + ClientCommand::DebugGiveCredits { amount } => { + self.apply_debug_give_credits(config, *amount) + } ClientCommand::GroupInvite { target_actor_id } => { self.apply_group_invite(config, target_actor_id) } @@ -697,6 +700,9 @@ impl SliceAuthorityState { self.apply_guild_rescind_war(config, opposing_guild_id) } ClientCommand::GuildDisband {} => self.apply_guild_disband(config), + ClientCommand::PurchaseTravelTicket { .. } + | ClientCommand::UseTravelTicket { .. } + | ClientCommand::ToggleDoor { .. } => Err(AuthorityRejectReason::TargetUnavailable), } } @@ -830,6 +836,35 @@ impl SliceAuthorityState { Ok(()) } + /// Debug-only wallet adjustment. + /// + /// Signed on purpose: a tester needs to prove the empty-wallet paths as + /// often as the rich ones, and the only other way to spend a debug balance + /// down is to find something to buy. Both directions saturate, so no amount + /// can wrap the balance. + pub(super) fn apply_debug_give_credits( + &mut self, + config: &SliceAuthorityConfig, + amount: i64, + ) -> Result<(), AuthorityRejectReason> { + let actor = self + .runtime + .durable + .actors + .get_mut(&config.player_actor_id) + .ok_or(AuthorityRejectReason::UnknownActor)?; + if amount >= 0 { + actor.professions.add_credits(amount.unsigned_abs()); + } else { + let drained = actor + .professions + .credits + .saturating_sub(amount.unsigned_abs()); + actor.professions.set_credits(Some(drained)); + } + Ok(()) + } + pub(super) fn apply_set_move_intent( &mut self, config: &SliceAuthorityConfig, @@ -950,6 +985,10 @@ impl SliceAuthorityState { && is_player_like_role(&actor.role) && !actor.sprint_recovery_locked && sprint_available_milli >= sprint_cost_milli; + let sprint_exhausted = sprint_requested + && is_player_like_role(&actor.role) + && !actor.sprint_recovery_locked + && sprint_available_milli < sprint_cost_milli; let movement_multiplier_milli = if sprinting { scaled_milli( movement_speed_multiplier_milli_for_actor(actor), @@ -996,6 +1035,13 @@ impl SliceAuthorityState { moved_milli, ); actor.last_moved_tick = Some(movement_tick); + if sprint_exhausted { + actor.vitals.action = 0; + actor.sprint_action_drain_milli = 0; + actor.sprint_recovery_locked = true; + actor.sprint_recovery_regen_carry = 0; + actor.passive_regen_milli.action = 0; + } if sprinting { let sprint_action_cost = actor_sprint_action_cost_milli( actor, diff --git a/crates/successor-sim/src/authority/debug_catalog.rs b/crates/successor-sim/src/authority/debug_catalog.rs new file mode 100644 index 00000000..f1746575 --- /dev/null +++ b/crates/successor-sim/src/authority/debug_catalog.rs @@ -0,0 +1,88 @@ +//! Everything the debug character builder can hand a player. +//! +//! Both entries are derived by ASKING THE RUNTIME rather than by keeping a +//! parallel list. Items are discovered by probing the id space against +//! [`inventory_item_name`], and skill boxes by expanding every profession +//! through [`authority_skill_box_tracks`] and resolving each id through +//! [`authority_skill_box_definition`]. A hand-maintained table would drift the +//! first time someone adds an item and forgets this file; a probe cannot. + +use super::inventory::inventory_item_name; +use super::progression::{ + authority_skill_box_definition, authority_skill_box_tracks, AuthorityProfessionKind, +}; + +/// Ceiling for the item id probe. The runtime's ids are banded well under this +/// (consumables ~1_000, tools ~3_000, weapons ~31_000_000 are variant-encoded +/// rather than distinct ids), and the scan is a few thousand map lookups run +/// once by a codegen binary, never at runtime. +const ITEM_ID_PROBE_CEILING: u32 = 40_000; + +/// Every profession the authority knows. Kept adjacent to the enum it mirrors +/// so a new profession fails the catalog test rather than silently vanishing +/// from the builder menu. +pub const ALL_PROFESSIONS: [AuthorityProfessionKind; 7] = [ + AuthorityProfessionKind::Craftsman, + AuthorityProfessionKind::Medic, + AuthorityProfessionKind::Scout, + AuthorityProfessionKind::Marksman, + AuthorityProfessionKind::Brawler, + AuthorityProfessionKind::BioEngineer, + AuthorityProfessionKind::Commando, +]; + +/// One grantable item. +pub struct DebugCatalogItem { + pub id: u32, + pub name: String, +} + +/// One grantable skill box. +pub struct DebugCatalogSkillBox { + pub id: String, + pub title: String, + pub profession: String, + pub tier: String, +} + +/// Every item id the runtime will name, in ascending id order. +pub fn debug_catalog_items() -> Vec { + let mut items = Vec::new(); + for id in 0..=ITEM_ID_PROBE_CEILING { + if let Some(name) = inventory_item_name(id) { + items.push(DebugCatalogItem { + id, + name: name.to_owned(), + }); + } + } + items +} + +/// Every skill box: per profession, novice, each track at tiers i..iv, master. +pub fn debug_catalog_skill_boxes() -> Vec { + const TIERS: [&str; 4] = ["i", "ii", "iii", "iv"]; + let mut boxes = Vec::new(); + for profession in ALL_PROFESSIONS { + let mut ids = vec![format!("{}-novice", profession.id())]; + for track in authority_skill_box_tracks(profession) { + for tier in TIERS { + ids.push(format!("{}-{track}-{tier}", profession.id())); + } + } + ids.push(format!("{}-master", profession.id())); + for id in ids { + let Some(definition) = authority_skill_box_definition(&id) else { + continue; + }; + let tier = id.rsplit('-').next().unwrap_or("").to_owned(); + boxes.push(DebugCatalogSkillBox { + title: definition.title.clone().unwrap_or_else(|| id.clone()), + profession: profession.id().to_owned(), + tier, + id, + }); + } + } + boxes +} diff --git a/crates/successor-sim/src/authority/economy.rs b/crates/successor-sim/src/authority/economy.rs index 8f64f001..4d0c31a4 100644 --- a/crates/successor-sim/src/authority/economy.rs +++ b/crates/successor-sim/src/authority/economy.rs @@ -438,7 +438,13 @@ impl SliceAuthorityState { | QUARRY_CHOPPER_ITEM_ID | STEN_MK2_ITEM_ID | KILN_ENERGY_CELL_ITEM_ID - | LIGHTNING_CARBINE_ITEM_ID => PERSONAL_SHIELD_GENERATOR_STACK_CAP, + | LIGHTNING_CARBINE_ITEM_ID + | BADGE_BOLT_PISTOL_ITEM_ID + | SLAGRAIL_VANGUARD_ITEM_ID + | COILGATE_SCATTER_ITEM_ID + | KILN_LONG_PATTERN_ITEM_ID + | BASTION_LMG_ITEM_ID + | FLARE_NET_LAUNCHER_ITEM_ID => PERSONAL_SHIELD_GENERATOR_STACK_CAP, BODY_ENHANCEMENT_PACK_A_ITEM_ID | SPIRIT_ENHANCEMENT_PACK_A_ITEM_ID => { ENHANCEMENT_PACK_A_STACK_CAP } diff --git a/crates/successor-sim/src/authority/inventory.rs b/crates/successor-sim/src/authority/inventory.rs index 3f2d6687..112cbb67 100644 --- a/crates/successor-sim/src/authority/inventory.rs +++ b/crates/successor-sim/src/authority/inventory.rs @@ -102,6 +102,12 @@ pub(super) const fn weapon_id_for_inventory_item(item_id: u32) -> Option Some(AuthorityWeaponId::WpnSmg), KILN_ENERGY_CELL_ITEM_ID => Some(AuthorityWeaponId::WpnCarbine), LIGHTNING_CARBINE_ITEM_ID => Some(AuthorityWeaponId::LightningCarbine), + BADGE_BOLT_PISTOL_ITEM_ID => Some(AuthorityWeaponId::WpnPistol), + SLAGRAIL_VANGUARD_ITEM_ID => Some(AuthorityWeaponId::WpnAssault), + COILGATE_SCATTER_ITEM_ID => Some(AuthorityWeaponId::WpnShotgun), + KILN_LONG_PATTERN_ITEM_ID => Some(AuthorityWeaponId::WpnSniper), + BASTION_LMG_ITEM_ID => Some(AuthorityWeaponId::WpnHeavy), + FLARE_NET_LAUNCHER_ITEM_ID => Some(AuthorityWeaponId::WpnLauncher), _ => None, } } @@ -117,16 +123,16 @@ pub(super) const fn canonical_inventory_item_for_weapon_id( AuthorityWeaponId::ScraplineMachete => Some(SCRAPLINE_MACHETE_ITEM_ID), AuthorityWeaponId::FieldSaber => Some(FIELD_SABER_ITEM_ID), AuthorityWeaponId::QuarryChopper => Some(QUARRY_CHOPPER_ITEM_ID), - AuthorityWeaponId::LightningCarbine => Some(LIGHTNING_CARBINE_ITEM_ID), - AuthorityWeaponId::Unarmed => None, - AuthorityWeaponId::WpnPistol - | AuthorityWeaponId::WpnAssault - | AuthorityWeaponId::WpnShotgun - | AuthorityWeaponId::WpnSniper - | AuthorityWeaponId::WpnHeavy - | AuthorityWeaponId::WpnLauncher => None, + AuthorityWeaponId::WpnPistol => Some(BADGE_BOLT_PISTOL_ITEM_ID), AuthorityWeaponId::WpnSmg => Some(STEN_MK2_ITEM_ID), AuthorityWeaponId::WpnCarbine => Some(KILN_ENERGY_CELL_ITEM_ID), + AuthorityWeaponId::LightningCarbine => Some(LIGHTNING_CARBINE_ITEM_ID), + AuthorityWeaponId::WpnAssault => Some(SLAGRAIL_VANGUARD_ITEM_ID), + AuthorityWeaponId::WpnShotgun => Some(COILGATE_SCATTER_ITEM_ID), + AuthorityWeaponId::WpnSniper => Some(KILN_LONG_PATTERN_ITEM_ID), + AuthorityWeaponId::WpnHeavy => Some(BASTION_LMG_ITEM_ID), + AuthorityWeaponId::WpnLauncher => Some(FLARE_NET_LAUNCHER_ITEM_ID), + AuthorityWeaponId::Unarmed => None, } } @@ -385,7 +391,7 @@ pub(super) fn is_creator_clothing_item_id(item_id: u32) -> bool { creator_clothing_item_key(item_id).is_some() } -pub(super) fn inventory_item_name(item_id: u32) -> Option<&'static str> { +pub(crate) fn inventory_item_name(item_id: u32) -> Option<&'static str> { if is_creator_clothing_item_id(item_id) { return creator_clothing_item_key(item_id); } @@ -440,6 +446,12 @@ pub(super) fn inventory_item_name(item_id: u32) -> Option<&'static str> { STEN_MK2_ITEM_ID => Some("STEN Mk II"), KILN_ENERGY_CELL_ITEM_ID => Some("Kiln Energy Cell Carbine"), LIGHTNING_CARBINE_ITEM_ID => Some("Lightning Carbine"), + BADGE_BOLT_PISTOL_ITEM_ID => Some("Badge Bolt Pistol"), + SLAGRAIL_VANGUARD_ITEM_ID => Some("Slagrail Vanguard"), + COILGATE_SCATTER_ITEM_ID => Some("Coilgate Scatter"), + KILN_LONG_PATTERN_ITEM_ID => Some("Kiln Long Pattern"), + BASTION_LMG_ITEM_ID => Some("Bastion LMG"), + FLARE_NET_LAUNCHER_ITEM_ID => Some("Flare Net Launcher"), COMBAT_HELM_ITEM_ID => Some("Combat Helm"), CREDIT_CHIP_ITEM_ID => Some("Credit Chip"), // Bio-Engineer seeds (6_0xx), tools + reagents (6_2xx) — bioengineer-design.md §0.5. @@ -618,28 +630,62 @@ mod catalog_tests { } #[test] - fn approved_ranged_items_are_the_only_standard_rows_in_3110_band() { - assert_eq!( - weapon_id_for_inventory_item(STEN_MK2_ITEM_ID), - Some(AuthorityWeaponId::WpnSmg) - ); - assert_eq!( - weapon_id_for_inventory_item(KILN_ENERGY_CELL_ITEM_ID), - Some(AuthorityWeaponId::WpnCarbine) - ); - assert_eq!(inventory_item_name(STEN_MK2_ITEM_ID), Some("STEN Mk II")); - assert_eq!( - inventory_item_name(KILN_ENERGY_CELL_ITEM_ID), - Some("Kiln Energy Cell Carbine") - ); - assert_eq!( - canonical_inventory_item_for_weapon_id(AuthorityWeaponId::WpnSmg), - Some(STEN_MK2_ITEM_ID) - ); - assert_eq!( - canonical_inventory_item_for_weapon_id(AuthorityWeaponId::WpnCarbine), - Some(KILN_ENERGY_CELL_ITEM_ID) - ); + fn concrete_ranged_items_have_canonical_inventory_rows() { + let concrete_items = [ + (STEN_MK2_ITEM_ID, AuthorityWeaponId::WpnSmg, "STEN Mk II"), + ( + KILN_ENERGY_CELL_ITEM_ID, + AuthorityWeaponId::WpnCarbine, + "Kiln Energy Cell Carbine", + ), + ( + LIGHTNING_CARBINE_ITEM_ID, + AuthorityWeaponId::LightningCarbine, + "Lightning Carbine", + ), + ( + BADGE_BOLT_PISTOL_ITEM_ID, + AuthorityWeaponId::WpnPistol, + "Badge Bolt Pistol", + ), + ( + SLAGRAIL_VANGUARD_ITEM_ID, + AuthorityWeaponId::WpnAssault, + "Slagrail Vanguard", + ), + ( + COILGATE_SCATTER_ITEM_ID, + AuthorityWeaponId::WpnShotgun, + "Coilgate Scatter", + ), + ( + KILN_LONG_PATTERN_ITEM_ID, + AuthorityWeaponId::WpnSniper, + "Kiln Long Pattern", + ), + ( + BASTION_LMG_ITEM_ID, + AuthorityWeaponId::WpnHeavy, + "Bastion LMG", + ), + ( + FLARE_NET_LAUNCHER_ITEM_ID, + AuthorityWeaponId::WpnLauncher, + "Flare Net Launcher", + ), + ]; + for (item_id, weapon_id, name) in concrete_items { + assert_eq!(weapon_id_for_inventory_item(item_id), Some(weapon_id)); + assert_eq!(inventory_item_name(item_id), Some(name)); + assert_eq!( + canonical_inventory_item_for_weapon_id(weapon_id), + Some(item_id) + ); + assert_eq!( + SliceAuthorityState::inventory_stack_cap_for_item(item_id, 99), + PERSONAL_SHIELD_GENERATOR_STACK_CAP + ); + } let retired_item_ids = [3_110, 3_113, 3_114, 3_115, 3_116, 3_117]; let loot_variant = encode_loot_variant(LootTier::Marked, 0); @@ -657,19 +703,5 @@ mod catalog_tests { "retired item {item_id} must not have a loot path" ); } - - for weapon_id in [ - AuthorityWeaponId::WpnPistol, - AuthorityWeaponId::WpnAssault, - AuthorityWeaponId::WpnShotgun, - AuthorityWeaponId::WpnSniper, - AuthorityWeaponId::WpnHeavy, - AuthorityWeaponId::WpnLauncher, - ] { - assert!( - canonical_inventory_item_for_weapon_id(weapon_id).is_none(), - "abstract weapon {weapon_id:?} must not migrate to an inventory row" - ); - } } } diff --git a/crates/successor-sim/src/authority/movement.rs b/crates/successor-sim/src/authority/movement.rs index c760ad3b..e7ce3bf7 100644 --- a/crates/successor-sim/src/authority/movement.rs +++ b/crates/successor-sim/src/authority/movement.rs @@ -25,6 +25,18 @@ impl SliceAuthorityState { current: AuthorityPosition, requested: AuthorityPosition, area: &AreaAuthorityState, + ) -> AuthorityPosition { + let mut blockers = self.circle_blockers_for_area(area_id); + self.push_blocked_cell_circle_blockers(area_id, &mut blockers); + self.clamped_unblocked_player_position_with_blockers(current, requested, area, &blockers) + } + + fn clamped_unblocked_player_position_with_blockers( + &self, + current: AuthorityPosition, + requested: AuthorityPosition, + area: &AreaAuthorityState, + blockers: &[CircleAabb], ) -> AuthorityPosition { let target = requested.clamp_to_area(area); if target == current { @@ -34,8 +46,6 @@ impl SliceAuthorityState { .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32; let delta_y = (i64::from(target.y) - i64::from(current.y)) .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32; - let mut blockers = self.circle_blockers_for_area(area_id); - self.push_blocked_cell_circle_blockers(area_id, &mut blockers); let current_center = ground_center_from_anchor(current); let resolved_center = resolve_circle_move_milli( CirclePoint { @@ -45,7 +55,7 @@ impl SliceAuthorityState { delta_x, delta_y, CIRCLE_COLLISION_RADIUS_MILLI, - &blockers, + blockers, ); anchor_from_ground_center(AuthorityPosition { x: resolved_center.x, @@ -59,15 +69,40 @@ impl SliceAuthorityState { .runtime .durable .actors - .keys() - .cloned() + .iter() + .filter_map(|(actor_id, actor)| actor.move_intent.map(|_| actor_id.clone())) .collect::>(); + let mut blockers_by_area = BTreeMap::new(); + for actor_id in &actor_ids { + let Some(area_id) = self + .runtime + .durable + .actors + .get(actor_id) + .map(|actor| actor.area_id.clone()) + else { + continue; + }; + blockers_by_area.entry(area_id.clone()).or_insert_with(|| { + let mut blockers = self.circle_blockers_for_area(&area_id); + self.push_blocked_cell_circle_blockers(&area_id, &mut blockers); + blockers + }); + } for actor_id in actor_ids { - self.tick_player_move_intent(&actor_id); + let blockers = self + .runtime + .durable + .actors + .get(&actor_id) + .and_then(|actor| blockers_by_area.get(&actor.area_id)) + .map(Vec::as_slice) + .unwrap_or(&[]); + self.tick_player_move_intent(&actor_id, blockers); } } - fn tick_player_move_intent(&mut self, actor_id: &str) { + fn tick_player_move_intent(&mut self, actor_id: &str, blockers: &[CircleAabb]) { let Some(intent) = self .runtime .durable @@ -123,6 +158,10 @@ impl SliceAuthorityState { && is_player_like_role(&actor_snapshot.role) && !actor_snapshot.sprint_recovery_locked && sprint_available_milli >= sprint_cost_milli; + let sprint_exhausted = intent.sprint + && is_player_like_role(&actor_snapshot.role) + && !actor_snapshot.sprint_recovery_locked + && sprint_available_milli < sprint_cost_milli; let movement_multiplier_milli = if sprinting { scaled_milli( movement_speed_multiplier_milli_for_actor(&actor_snapshot), @@ -143,11 +182,11 @@ impl SliceAuthorityState { actor_snapshot .position .offset(intent.dx, intent.dy, distance_milli); - let target_position = self.clamped_unblocked_player_position( - &actor_snapshot.area_id, + let target_position = self.clamped_unblocked_player_position_with_blockers( actor_snapshot.position, requested_position, area, + blockers, ); let moved_milli = position_distance_milli(actor_snapshot.position, target_position); let target = target_position.cell(); @@ -172,6 +211,13 @@ impl SliceAuthorityState { moved_milli, ); actor.last_moved_tick = Some(self.runtime.durable.tick); + if sprint_exhausted { + actor.vitals.action = 0; + actor.sprint_action_drain_milli = 0; + actor.sprint_recovery_locked = true; + actor.sprint_recovery_regen_carry = 0; + actor.passive_regen_milli.action = 0; + } if sprinting { let sprint_action_cost = actor_sprint_action_cost_milli( actor, @@ -975,8 +1021,8 @@ fn fallback_micro_reversal_allowed( #[cfg(test)] mod tests { - use super::swept_circle::CIRCLE_TRACE_SKIN_MILLI; use super::*; + use successor_movement::CIRCLE_TRACE_SKIN_MILLI; use successor_net::{ClientCommand, ClientCommandEnvelope, PlayerId, SessionId}; fn door_collision_snapshot() -> crate::SliceSnapshot { diff --git a/crates/successor-sim/src/authority/progression.rs b/crates/successor-sim/src/authority/progression.rs index f42f1764..d99589e1 100644 --- a/crates/successor-sim/src/authority/progression.rs +++ b/crates/successor-sim/src/authority/progression.rs @@ -215,7 +215,7 @@ pub(super) fn profession_stat_curve(track_bonus: i32, base: i32, per_step: i32) } #[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct AuthoritySkillBoxDefinition { +pub(crate) struct AuthoritySkillBoxDefinition { pub(super) id: String, pub(super) profession: AuthorityProfessionKind, pub(super) title: Option, @@ -1117,7 +1117,7 @@ pub(super) enum AuthorityTitleError { Unavailable, } -pub(super) fn authority_skill_box_definition( +pub(crate) fn authority_skill_box_definition( skill_box_id: &str, ) -> Option { let id = normalize_skill_box_id(skill_box_id); @@ -1216,7 +1216,9 @@ fn skill_box_profession_suffix(id: &str) -> Option<(AuthorityProfessionKind, &st None } -fn authority_skill_box_tracks(profession: AuthorityProfessionKind) -> &'static [&'static str] { +pub(crate) fn authority_skill_box_tracks( + profession: AuthorityProfessionKind, +) -> &'static [&'static str] { match profession { AuthorityProfessionKind::Marksman => &["rifle", "pistol", "tactics", "fieldcraft"], AuthorityProfessionKind::Scout => { diff --git a/crates/successor-sim/src/authority/swept_circle.rs b/crates/successor-sim/src/authority/swept_circle.rs index a83e0ae9..d5ee67d9 100644 --- a/crates/successor-sim/src/authority/swept_circle.rs +++ b/crates/successor-sim/src/authority/swept_circle.rs @@ -1,888 +1,4 @@ -//! Integer swept circle-vs-AABB resolver shared with the client-side predictor. -//! -//! The TypeScript mirror lives in `client/src/slice-core/movementSystem.ts` -//! (`resolveCircleMove`). Keep the constants and scenario table in these two -//! files aligned: player center is swept in milli-cells, radius is 300 milli, -//! skin is 2 milli, and at most three slide iterations are performed. - -use std::cmp::Ordering; - -pub(super) const CIRCLE_COLLISION_RADIUS_MILLI: i32 = 300; -pub(super) const CIRCLE_TRACE_SKIN_MILLI: i32 = 2; -const CIRCLE_DEPENETRATION_EPSILON_MILLI: i32 = 1; -const MAX_INITIAL_DEPENETRATION_ITERATIONS: usize = 4; -const MAX_TRACE_SLIDE_ITERATIONS: usize = 3; -const TOI_SCALE: i64 = 1_000_000; -const NORMAL_SCALE_MILLI: i32 = 1_000; - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub(super) struct CirclePoint { - pub(super) x: i32, - pub(super) y: i32, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct CircleAabb { - pub(super) left: i32, - pub(super) top: i32, - pub(super) right: i32, - pub(super) bottom: i32, -} - -impl CircleAabb { - pub(super) const fn new(left: i32, top: i32, right: i32, bottom: i32) -> Self { - Self { - left, - top, - right, - bottom, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct RationalToi { - num: i64, - den: i64, -} - -impl RationalToi { - const ONE: Self = Self { num: 1, den: 1 }; - - fn new(num: i64, den: i64) -> Self { - debug_assert!(den > 0); - Self { num, den } - } - - fn cmp(self, other: Self) -> Ordering { - (i128::from(self.num) * i128::from(other.den)) - .cmp(&(i128::from(other.num) * i128::from(self.den))) - } - - fn ge(self, other: Self) -> bool { - self.cmp(other) != Ordering::Less - } - - fn scaled_floor(self) -> i64 { - if self.num <= 0 { - return 0; - } - if self.ge(Self::ONE) { - return TOI_SCALE; - } - ((i128::from(self.num) * i128::from(TOI_SCALE)) / i128::from(self.den)) - .clamp(0, i128::from(TOI_SCALE)) as i64 - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct CircleTraceHit { - t_scaled: i64, - normal_x_milli: i32, - normal_y_milli: i32, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct CircleOverlapPush { - depth: i32, - push_x: i32, - push_y: i32, -} - -/// Resolve a center sweep against stable-order AABB faces plus rounded corner caps. -/// -/// TOI is represented as rational/fixed-point integer values: face hits use -/// exact rational slab times, corner hits solve the circle quadratic with -/// integer square roots, and the selected impact is projected onto a 1e6 -/// fixed-point scale for the skin/slide step. This keeps comparisons -/// deterministic without floats while staying below the 2-milli parity -/// tolerance used by the client/Rust shared scenario table. -pub(super) fn resolve_circle_move_milli( - origin: CirclePoint, - delta_x: i32, - delta_y: i32, - radius_milli: i32, - blockers: &[CircleAabb], -) -> CirclePoint { - let mut position = origin; - let radius_milli = radius_milli.max(0); - if blockers.is_empty() { - position.x = position.x.saturating_add(delta_x); - position.y = position.y.saturating_add(delta_y); - return position; - } - - depenetrate_initial_circle_position(&mut position, radius_milli, blockers); - - let mut remaining_x = i64::from(delta_x); - let mut remaining_y = i64::from(delta_y); - for _ in 0..MAX_TRACE_SLIDE_ITERATIONS { - let distance = integer_sqrt_ceil( - remaining_x - .saturating_mul(remaining_x) - .saturating_add(remaining_y.saturating_mul(remaining_y)) as u64, - ) as i64; - if distance <= 0 { - break; - } - let Some(hit) = trace_circle_against_blockers( - position, - remaining_x, - remaining_y, - radius_milli, - blockers, - ) else { - position.x = saturating_i64_to_i32(i64::from(position.x).saturating_add(remaining_x)); - position.y = saturating_i64_to_i32(i64::from(position.y).saturating_add(remaining_y)); - break; - }; - - let t_scaled = hit.t_scaled; - let skin_scaled = div_ceil_i64(i64::from(CIRCLE_TRACE_SKIN_MILLI) * TOI_SCALE, distance); - let travel_scaled = t_scaled.saturating_sub(skin_scaled).clamp(0, TOI_SCALE); - let travel_x = scale_i64(remaining_x, travel_scaled, TOI_SCALE); - let travel_y = scale_i64(remaining_y, travel_scaled, TOI_SCALE); - position.x = saturating_i64_to_i32(i64::from(position.x).saturating_add(travel_x)); - position.y = saturating_i64_to_i32(i64::from(position.y).saturating_add(travel_y)); - - let leftover_scale = TOI_SCALE.saturating_sub(travel_scaled).clamp(0, TOI_SCALE); - remaining_x = scale_i64(remaining_x, leftover_scale, TOI_SCALE); - remaining_y = scale_i64(remaining_y, leftover_scale, TOI_SCALE); - let normal_x = i64::from(hit.normal_x_milli); - let normal_y = i64::from(hit.normal_y_milli); - let normal_len_sq = normal_x - .saturating_mul(normal_x) - .saturating_add(normal_y.saturating_mul(normal_y)) - .max(1); - let normal_dot = remaining_x - .saturating_mul(normal_x) - .saturating_add(remaining_y.saturating_mul(normal_y)); - if normal_dot < 0 { - let remove_x = div_round_i128( - i128::from(normal_x) * i128::from(normal_dot), - i128::from(normal_len_sq), - ); - let remove_y = div_round_i128( - i128::from(normal_y) * i128::from(normal_dot), - i128::from(normal_len_sq), - ); - remaining_x = saturating_i128_to_i64(i128::from(remaining_x).saturating_sub(remove_x)); - remaining_y = saturating_i128_to_i64(i128::from(remaining_y).saturating_sub(remove_y)); - } - } - - position -} - -pub(super) fn circle_intersects_aabb( - center: CirclePoint, - radius_milli: i32, - blocker: CircleAabb, -) -> bool { - circle_aabb_overlap_push(center, radius_milli.max(0), blocker).is_some() -} - -fn depenetrate_initial_circle_position( - position: &mut CirclePoint, - radius_milli: i32, - blockers: &[CircleAabb], -) { - for _ in 0..MAX_INITIAL_DEPENETRATION_ITERATIONS { - let mut best: Option = None; - for blocker in blockers { - let Some(push) = circle_aabb_overlap_push(*position, radius_milli, *blocker) else { - continue; - }; - if best.is_none_or(|candidate| push.depth > candidate.depth) { - best = Some(push); - } - } - let Some(push) = best else { break }; - if push.depth <= CIRCLE_DEPENETRATION_EPSILON_MILLI { - break; - } - position.x = position.x.saturating_add(push.push_x); - position.y = position.y.saturating_add(push.push_y); - } -} - -fn trace_circle_against_blockers( - position: CirclePoint, - dx: i64, - dy: i64, - radius_milli: i32, - blockers: &[CircleAabb], -) -> Option { - let mut best: Option = None; - for blocker in blockers { - let Some(hit) = - sweep_segment_against_rounded_aabb(position, dx, dy, radius_milli, *blocker) - else { - continue; - }; - if best.is_none_or(|candidate| hit.t_scaled < candidate.t_scaled) { - best = Some(hit); - } - } - best -} - -fn sweep_segment_against_rounded_aabb( - position: CirclePoint, - dx: i64, - dy: i64, - radius_milli: i32, - blocker: CircleAabb, -) -> Option { - if let Some(hit) = overlapping_circle_trace_hit(position, dx, dy, radius_milli, blocker) { - return Some(hit); - } - - let mut best: Option = None; - trace_vertical_face( - &mut best, - position, - dx, - dy, - i64::from(blocker.left).saturating_sub(i64::from(radius_milli)), - blocker.top, - blocker.bottom, - -NORMAL_SCALE_MILLI, - ); - trace_vertical_face( - &mut best, - position, - dx, - dy, - i64::from(blocker.right).saturating_add(i64::from(radius_milli)), - blocker.top, - blocker.bottom, - NORMAL_SCALE_MILLI, - ); - trace_horizontal_face( - &mut best, - position, - dx, - dy, - i64::from(blocker.top).saturating_sub(i64::from(radius_milli)), - blocker.left, - blocker.right, - -NORMAL_SCALE_MILLI, - ); - trace_horizontal_face( - &mut best, - position, - dx, - dy, - i64::from(blocker.bottom).saturating_add(i64::from(radius_milli)), - blocker.left, - blocker.right, - NORMAL_SCALE_MILLI, - ); - - trace_corner( - &mut best, - position, - dx, - dy, - radius_milli, - blocker.left, - blocker.top, - ); - trace_corner( - &mut best, - position, - dx, - dy, - radius_milli, - blocker.right, - blocker.top, - ); - trace_corner( - &mut best, - position, - dx, - dy, - radius_milli, - blocker.left, - blocker.bottom, - ); - trace_corner( - &mut best, - position, - dx, - dy, - radius_milli, - blocker.right, - blocker.bottom, - ); - best -} - -fn overlapping_circle_trace_hit( - position: CirclePoint, - dx: i64, - dy: i64, - radius_milli: i32, - blocker: CircleAabb, -) -> Option { - let push = circle_aabb_overlap_push(position, radius_milli, blocker)?; - let (normal_x_milli, normal_y_milli) = - normal_milli_from_vector(i128::from(push.push_x), i128::from(push.push_y)); - let normal_dot = dx - .saturating_mul(i64::from(normal_x_milli)) - .saturating_add(dy.saturating_mul(i64::from(normal_y_milli))); - if normal_dot >= 0 { - return None; - } - Some(CircleTraceHit { - t_scaled: 0, - normal_x_milli, - normal_y_milli, - }) -} - -fn trace_vertical_face( - best: &mut Option, - position: CirclePoint, - dx: i64, - dy: i64, - plane_x: i64, - span_top: i32, - span_bottom: i32, - normal_x_milli: i32, -) { - let den = if normal_x_milli < 0 { dx } else { -dx }; - if den <= 0 { - return; - } - let x = i64::from(position.x); - let t_num = if normal_x_milli < 0 { - plane_x.saturating_sub(x) - } else { - x.saturating_sub(plane_x) - }; - if t_num < 0 || t_num > den { - return; - } - let contact_y_num = - i128::from(position.y) * i128::from(den) + i128::from(dy) * i128::from(t_num); - let top = i128::from(span_top) * i128::from(den); - let bottom = i128::from(span_bottom) * i128::from(den); - if contact_y_num < top || contact_y_num > bottom { - return; - } - keep_earliest_hit( - best, - CircleTraceHit { - t_scaled: RationalToi::new(t_num, den).scaled_floor(), - normal_x_milli, - normal_y_milli: 0, - }, - ); -} - -fn trace_horizontal_face( - best: &mut Option, - position: CirclePoint, - dx: i64, - dy: i64, - plane_y: i64, - span_left: i32, - span_right: i32, - normal_y_milli: i32, -) { - let den = if normal_y_milli < 0 { dy } else { -dy }; - if den <= 0 { - return; - } - let y = i64::from(position.y); - let t_num = if normal_y_milli < 0 { - plane_y.saturating_sub(y) - } else { - y.saturating_sub(plane_y) - }; - if t_num < 0 || t_num > den { - return; - } - let contact_x_num = - i128::from(position.x) * i128::from(den) + i128::from(dx) * i128::from(t_num); - let left = i128::from(span_left) * i128::from(den); - let right = i128::from(span_right) * i128::from(den); - if contact_x_num < left || contact_x_num > right { - return; - } - keep_earliest_hit( - best, - CircleTraceHit { - t_scaled: RationalToi::new(t_num, den).scaled_floor(), - normal_x_milli: 0, - normal_y_milli, - }, - ); -} - -fn trace_corner( - best: &mut Option, - position: CirclePoint, - dx: i64, - dy: i64, - radius_milli: i32, - corner_x: i32, - corner_y: i32, -) { - let ox = i64::from(position.x).saturating_sub(i64::from(corner_x)); - let oy = i64::from(position.y).saturating_sub(i64::from(corner_y)); - let a = i128::from(dx) - .saturating_mul(i128::from(dx)) - .saturating_add(i128::from(dy).saturating_mul(i128::from(dy))); - if a <= 0 { - return; - } - let b = 2_i128.saturating_mul( - i128::from(ox) - .saturating_mul(i128::from(dx)) - .saturating_add(i128::from(oy).saturating_mul(i128::from(dy))), - ); - let radius = i128::from(radius_milli.max(0)); - let c = i128::from(ox) - .saturating_mul(i128::from(ox)) - .saturating_add(i128::from(oy).saturating_mul(i128::from(oy))) - .saturating_sub(radius.saturating_mul(radius)); - if c <= 0 { - return; - } - let discriminant = b - .saturating_mul(b) - .saturating_sub(4_i128.saturating_mul(a).saturating_mul(c)); - if discriminant < 0 { - return; - } - let sqrt_discriminant = integer_sqrt_ceil_u128(discriminant as u128) as i128; - let numerator = b.saturating_neg().saturating_sub(sqrt_discriminant); - let denominator = 2_i128.saturating_mul(a); - if numerator < 0 || numerator > denominator { - return; - } - let t_scaled = ((numerator.saturating_mul(i128::from(TOI_SCALE))) / denominator) - .clamp(0, i128::from(TOI_SCALE)) as i64; - let offset_x_scaled = i128::from(ox) - .saturating_mul(i128::from(TOI_SCALE)) - .saturating_add(i128::from(dx).saturating_mul(i128::from(t_scaled))); - let offset_y_scaled = i128::from(oy) - .saturating_mul(i128::from(TOI_SCALE)) - .saturating_add(i128::from(dy).saturating_mul(i128::from(t_scaled))); - let (normal_x_milli, normal_y_milli) = - normal_milli_from_vector(offset_x_scaled, offset_y_scaled); - let normal_dot = i128::from(dx) - .saturating_mul(i128::from(normal_x_milli)) - .saturating_add(i128::from(dy).saturating_mul(i128::from(normal_y_milli))); - if normal_dot >= 0 { - return; - } - keep_earliest_hit( - best, - CircleTraceHit { - t_scaled, - normal_x_milli, - normal_y_milli, - }, - ); -} - -fn keep_earliest_hit(best: &mut Option, hit: CircleTraceHit) { - if !(0..=TOI_SCALE).contains(&hit.t_scaled) { - return; - } - if best.is_none_or(|candidate| hit.t_scaled < candidate.t_scaled) { - *best = Some(hit); - } -} - -fn circle_aabb_overlap_push( - center: CirclePoint, - radius_milli: i32, - blocker: CircleAabb, -) -> Option { - let x = i64::from(center.x); - let y = i64::from(center.y); - let left = i64::from(blocker.left); - let right = i64::from(blocker.right); - let top = i64::from(blocker.top); - let bottom = i64::from(blocker.bottom); - let radius = i64::from(radius_milli.max(0)); - - if x >= left && x <= right && y >= top && y <= bottom { - let left_distance = (x - left).max(0); - let right_distance = (right - x).max(0); - let top_distance = (y - top).max(0); - let bottom_distance = (bottom - y).max(0); - let mut face_distance = left_distance; - let mut normal_x = -1; - let mut normal_y = 0; - if right_distance < face_distance { - face_distance = right_distance; - normal_x = 1; - normal_y = 0; - } - if top_distance < face_distance { - face_distance = top_distance; - normal_x = 0; - normal_y = -1; - } - if bottom_distance < face_distance { - face_distance = bottom_distance; - normal_x = 0; - normal_y = 1; - } - let depth = radius.saturating_add(face_distance); - let push = depth.saturating_add(i64::from(CIRCLE_DEPENETRATION_EPSILON_MILLI)); - return Some(CircleOverlapPush { - depth: saturating_i64_to_i32(depth), - push_x: saturating_i64_to_i32(i64::from(normal_x).saturating_mul(push)), - push_y: saturating_i64_to_i32(i64::from(normal_y).saturating_mul(push)), - }); - } - - let closest_x = x.clamp(left, right); - let closest_y = y.clamp(top, bottom); - let offset_x = x.saturating_sub(closest_x); - let offset_y = y.saturating_sub(closest_y); - let dist_sq = offset_x - .saturating_mul(offset_x) - .saturating_add(offset_y.saturating_mul(offset_y)); - let min_clearance = radius.saturating_sub(i64::from(CIRCLE_DEPENETRATION_EPSILON_MILLI)); - if dist_sq >= min_clearance.saturating_mul(min_clearance) { - return None; - } - if dist_sq <= 0 { - return Some(CircleOverlapPush { - depth: radius_milli, - push_x: radius_milli.saturating_add(CIRCLE_DEPENETRATION_EPSILON_MILLI), - push_y: 0, - }); - } - let distance = integer_sqrt_floor(dist_sq as u64).max(1) as i64; - let depth = radius.saturating_sub(distance).max(0); - let push_distance = depth.saturating_add(i64::from(CIRCLE_DEPENETRATION_EPSILON_MILLI)); - let mut push_x = scale_i64(offset_x, push_distance, distance); - let mut push_y = scale_i64(offset_y, push_distance, distance); - if push_x == 0 && offset_x != 0 { - push_x = offset_x.signum(); - } - if push_y == 0 && offset_y != 0 { - push_y = offset_y.signum(); - } - Some(CircleOverlapPush { - depth: saturating_i64_to_i32(depth), - push_x: saturating_i64_to_i32(push_x), - push_y: saturating_i64_to_i32(push_y), - }) -} - -fn scale_i64(value: i64, scale: i64, denom: i64) -> i64 { - if denom <= 0 || value == 0 || scale == 0 { - return 0; - } - let numerator = i128::from(value) * i128::from(scale); - let denom = i128::from(denom); - if numerator >= 0 { - ((numerator + denom / 2) / denom).clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64 - } else { - ((numerator - denom / 2) / denom).clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64 - } -} - -fn div_round_i128(value: i128, denom: i128) -> i128 { - if denom <= 0 || value == 0 { - return 0; - } - if value >= 0 { - (value + denom / 2) / denom - } else { - (value - denom / 2) / denom - } -} - -fn normal_milli_from_vector(x: i128, y: i128) -> (i32, i32) { - let dist_sq = x.saturating_mul(x).saturating_add(y.saturating_mul(y)); - if dist_sq <= 0 { - return (NORMAL_SCALE_MILLI, 0); - } - let distance = integer_sqrt_floor_u128(dist_sq as u128).max(1) as i128; - let normal_x = div_round_i128(x.saturating_mul(i128::from(NORMAL_SCALE_MILLI)), distance).clamp( - i128::from(-NORMAL_SCALE_MILLI), - i128::from(NORMAL_SCALE_MILLI), - ) as i32; - let normal_y = div_round_i128(y.saturating_mul(i128::from(NORMAL_SCALE_MILLI)), distance).clamp( - i128::from(-NORMAL_SCALE_MILLI), - i128::from(NORMAL_SCALE_MILLI), - ) as i32; - if normal_x == 0 && normal_y == 0 { - (NORMAL_SCALE_MILLI, 0) - } else { - (normal_x, normal_y) - } -} - -fn div_ceil_i64(value: i64, denom: i64) -> i64 { - if denom <= 0 { - return 0; - } - if value <= 0 { - return value / denom; - } - (value + denom - 1) / denom -} - -fn integer_sqrt_floor(value: u64) -> u64 { - if value <= 1 { - return value; - } - let mut low = 1_u64; - let mut high = value.min(u64::from(u32::MAX) * u64::from(u32::MAX)); - while low <= high { - let mid = low + (high - low) / 2; - let sq = u128::from(mid) * u128::from(mid); - match sq.cmp(&u128::from(value)) { - Ordering::Equal => return mid, - Ordering::Less => low = mid.saturating_add(1), - Ordering::Greater => high = mid.saturating_sub(1), - } - } - high -} - -fn integer_sqrt_ceil(value: u64) -> u64 { - let floor = integer_sqrt_floor(value); - if floor.saturating_mul(floor) == value { - floor - } else { - floor.saturating_add(1) - } -} - -fn integer_sqrt_floor_u128(value: u128) -> u128 { - if value <= 1 { - return value; - } - let mut low = 1_u128; - let mut high = value; - while low <= high { - let mid = low + (high - low) / 2; - let sq = mid.saturating_mul(mid); - match sq.cmp(&value) { - Ordering::Equal => return mid, - Ordering::Less => low = mid.saturating_add(1), - Ordering::Greater => high = mid.saturating_sub(1), - } - } - high -} - -fn integer_sqrt_ceil_u128(value: u128) -> u128 { - let floor = integer_sqrt_floor_u128(value); - if floor.saturating_mul(floor) == value { - floor - } else { - floor.saturating_add(1) - } -} - -fn saturating_i128_to_i64(value: i128) -> i64 { - value.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64 -} - -fn saturating_i64_to_i32(value: i64) -> i32 { - value.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32 -} - -#[cfg(test)] -mod tests { - use super::*; - - const WALL: CircleAabb = CircleAabb::new(5_000, 0, 5_295, 10_000); - const DOOR_LEFT_JAMB: CircleAabb = CircleAabb::new(1_000, 5_000, 2_442, 5_295); - const DOOR_RIGHT_JAMB: CircleAabb = CircleAabb::new(3_558, 5_000, 5_000, 5_295); - const HOUSE_LEFT_LOWER_SILL: CircleAabb = CircleAabb::new(502_658, 511_895, 502_921, 511_921); - const HOUSE_RIGHT_LOWER_SILL: CircleAabb = CircleAabb::new(504_026, 511_895, 504_158, 511_921); - - fn resolve( - origin: CirclePoint, - delta_x: i32, - delta_y: i32, - blockers: &[CircleAabb], - ) -> CirclePoint { - resolve_circle_move_milli( - origin, - delta_x, - delta_y, - CIRCLE_COLLISION_RADIUS_MILLI, - blockers, - ) - } - - #[test] - fn sprint_length_swept_move_stops_radius_plus_skin_outside_thin_band() { - let result = resolve(CirclePoint { x: 2_000, y: 5_000 }, 10_000, 0, &[WALL]); - assert_eq!( - result.x, - WALL.left - CIRCLE_COLLISION_RADIUS_MILLI - CIRCLE_TRACE_SKIN_MILLI - ); - assert_eq!(result.y, 5_000); - assert!(!circle_intersects_aabb( - result, - CIRCLE_COLLISION_RADIUS_MILLI, - WALL - )); - } - - #[test] - fn diagonal_slide_preserves_tangential_travel() { - let result = resolve(CirclePoint { x: 4_600, y: 2_000 }, 1_000, 1_000, &[WALL]); - assert!( - (WALL.left - CIRCLE_COLLISION_RADIUS_MILLI - CIRCLE_TRACE_SKIN_MILLI - ..=WALL.left - CIRCLE_COLLISION_RADIUS_MILLI) - .contains(&result.x) - ); - assert!((2_998..=3_002).contains(&result.y), "slide y={}", result.y); - assert!(!circle_intersects_aabb( - result, - CIRCLE_COLLISION_RADIUS_MILLI, - WALL - )); - } - - #[test] - fn origin_inside_recovers_outward_without_crossing_far_face() { - let result = resolve(CirclePoint { x: 5_150, y: 6_000 }, 0, 0, &[WALL]); - assert_eq!( - result.x, - WALL.right + CIRCLE_COLLISION_RADIUS_MILLI + CIRCLE_DEPENETRATION_EPSILON_MILLI - ); - assert_eq!(result.y, 6_000); - assert!(result.x > WALL.right); - assert!(!circle_intersects_aabb( - result, - CIRCLE_COLLISION_RADIUS_MILLI, - WALL - )); - } - - #[test] - fn closed_door_blocker_stops_gap_and_open_door_passes() { - let closed = [CircleAabb::new(2_442, 5_000, 3_558, 5_295)]; - let stopped = resolve(CirclePoint { x: 3_000, y: 5_900 }, 0, -1_700, &closed); - assert!(stopped.y >= 5_295 + CIRCLE_COLLISION_RADIUS_MILLI); - assert!(!circle_intersects_aabb( - stopped, - CIRCLE_COLLISION_RADIUS_MILLI, - closed[0] - )); - - let open = resolve(CirclePoint { x: 3_000, y: 5_900 }, 0, -1_700, &[]); - assert_eq!(open, CirclePoint { x: 3_000, y: 4_200 }); - } - - #[test] - fn rounded_corner_trace_stops_on_arc_instead_of_square_corner_face() { - let box_blocker = CircleAabb::new(5_000, 5_000, 6_000, 6_000); - let result = resolve( - CirclePoint { x: 4_000, y: 4_000 }, - 2_000, - 2_000, - &[box_blocker], - ); - - assert!( - (4_784..=4_788).contains(&result.x) && (4_784..=4_788).contains(&result.y), - "corner arc stop should be near the true circle-vs-corner TOI, got {:?}", - result - ); - assert!(!circle_intersects_aabb( - result, - CIRCLE_COLLISION_RADIUS_MILLI, - box_blocker - )); - } - - #[test] - fn doorway_sill_brush_keeps_northward_progress() { - let blockers = [HOUSE_LEFT_LOWER_SILL, HOUSE_RIGHT_LOWER_SILL]; - let origin = CirclePoint { - x: 503_150, - y: 512_350, - }; - let result = resolve(origin, 0, -3_000, &blockers); - - assert!( - result.y < 511_000, - "rounded corner response should glide through a door-sill brush instead of stopping at the square inflated bottom face: {:?}", - result - ); - assert!( - result.x > origin.x, - "left sill brush should push toward the open doorway, got {:?}", - result - ); - for blocker in blockers { - assert!(!circle_intersects_aabb( - result, - CIRCLE_COLLISION_RADIUS_MILLI, - blocker - )); - } - } - - #[allow(clippy::type_complexity)] - #[test] - fn shared_client_parity_scenario_table() { - let cases: &[(&str, CirclePoint, (i32, i32), &[CircleAabb], CirclePoint)] = &[ - ( - "head-on stop", - CirclePoint { x: 2_000, y: 5_000 }, - (10_000, 0), - &[WALL], - CirclePoint { x: 4_698, y: 5_000 }, - ), - ( - "diagonal slide", - CirclePoint { x: 4_600, y: 2_000 }, - (1_000, 1_000), - &[WALL], - CirclePoint { x: 4_699, y: 3_000 }, - ), - ( - "corner", - CirclePoint { x: 4_000, y: 4_000 }, - (2_000, 2_000), - &[CircleAabb::new(5_000, 5_000, 6_000, 6_000)], - CirclePoint { x: 4_786, y: 4_786 }, - ), - ( - "door-gap pass", - CirclePoint { x: 3_000, y: 5_900 }, - (0, -1_700), - &[DOOR_LEFT_JAMB, DOOR_RIGHT_JAMB], - CirclePoint { x: 3_000, y: 4_200 }, - ), - ( - "origin-inside recovery", - CirclePoint { x: 5_150, y: 6_000 }, - (0, 0), - &[WALL], - CirclePoint { x: 5_596, y: 6_000 }, - ), - ]; - - for (label, origin, (dx, dy), blockers, expected) in cases { - let actual = resolve(*origin, *dx, *dy, blockers); - assert!( - actual.x.abs_diff(expected.x) <= 2 && actual.y.abs_diff(expected.y) <= 2, - "{label}: expected {:?}, got {:?}", - expected, - actual - ); - } - } -} +pub(super) use successor_movement::{ + circle_intersects_aabb, resolve_circle_move_milli, CircleAabb, CirclePoint, + CIRCLE_COLLISION_RADIUS_MILLI, +}; diff --git a/crates/successor-sim/src/authority/tests/core_and_combat.rs b/crates/successor-sim/src/authority/tests/core_and_combat.rs index 1d8d456e..68e0929c 100644 --- a/crates/successor-sim/src/authority/tests/core_and_combat.rs +++ b/crates/successor-sim/src/authority/tests/core_and_combat.rs @@ -1494,6 +1494,82 @@ fn authority_plasma_sword_debug_give_equips_as_vibrosword_and_encodes_item_id() ); } +#[test] +fn authority_concrete_ranged_inventory_items_equip_with_certification_and_encode_identity() { + let (config, mut state) = roll_combat_test_state(); + let player_id = config.player_actor_id.clone(); + clear_test_professions(&mut state, &player_id); + p12_grant_boxes( + &mut state, + &player_id, + &[ + "marksman-pistol-i", + "marksman-rifle-ii", + "marksman-rifle-iii", + "commando-heavy-weapons-ii", + "commando-heavy-weapons-iv", + "commando-demolitions-ii", + ], + ); + + for (command_id, weapon_id, item_id) in [ + ( + 1, + AuthorityWeaponId::WpnPistol, + BADGE_BOLT_PISTOL_ITEM_ID, + ), + ( + 2, + AuthorityWeaponId::WpnAssault, + SLAGRAIL_VANGUARD_ITEM_ID, + ), + ( + 3, + AuthorityWeaponId::WpnShotgun, + COILGATE_SCATTER_ITEM_ID, + ), + ( + 4, + AuthorityWeaponId::WpnSniper, + KILN_LONG_PATTERN_ITEM_ID, + ), + (5, AuthorityWeaponId::WpnHeavy, BASTION_LMG_ITEM_ID), + ( + 6, + AuthorityWeaponId::WpnLauncher, + FLARE_NET_LAUNCHER_ITEM_ID, + ), + ] { + let variant_id = 90_000 + command_id as u32; + state + .apply_debug_give_item(&config, item_id, variant_id, 1, false) + .expect("concrete weapon added to inventory"); + let frame = state.apply_envelope( + &config, + command( + command_id, + ClientCommand::SetEquippedWeapon { + weapon_id: Some(weapon_id), + weapon_item_id: Some(item_id), + weapon_variant_id: Some(variant_id), + }, + ), + ); + assert_eq!( + frame.status, + AuthorityCommandStatus::Accepted, + "{weapon_id:?} rejected: {:?}", + frame.reason_code + ); + + let snapshot = state.actor_snapshot(&player_id).expect("player snapshot"); + let weapon = snapshot.weapon.expect("equipped concrete weapon snapshot"); + assert_eq!(weapon.weapon_id, authority_weapon_id_label(weapon_id)); + assert_eq!(weapon.weapon_item_id, item_id); + assert_eq!(weapon.weapon_variant_id, variant_id); + } +} + #[test] fn authority_combat_helm_debug_give_creates_real_inventory_row() { let (config, mut state) = roll_combat_test_state(); @@ -1694,6 +1770,29 @@ fn authority_debug_grant_skill_boxes_adds_brawler_ranged_block_boxes() { assert_eq!(player.professions.brawler_ranged_block_chance_milli(), 950); } +#[test] +fn authority_debug_give_credits_adjusts_and_saturates_wallet() { + let (config, mut state) = roll_combat_test_state(); + let initial_credits = state.actors.get("player").unwrap().professions.credits; + + let grant = state.apply_envelope( + &config, + command(1, ClientCommand::DebugGiveCredits { amount: 250 }), + ); + assert_eq!(grant.status, AuthorityCommandStatus::Accepted); + assert_eq!( + state.actors.get("player").unwrap().professions.credits, + initial_credits + 250 + ); + + let drain = state.apply_envelope( + &config, + command(2, ClientCommand::DebugGiveCredits { amount: i64::MIN }), + ); + assert_eq!(drain.status, AuthorityCommandStatus::Accepted); + assert_eq!(state.actors.get("player").unwrap().professions.credits, 0); +} + #[test] fn authority_roll_vibrosword_basic_attack_uses_melee_profile_without_per_hit_xp() { let (_config, mut state) = roll_combat_test_state(); diff --git a/crates/successor-sim/src/authority/tests/movement_and_world.rs b/crates/successor-sim/src/authority/tests/movement_and_world.rs index 616da3bc..d0aa9c66 100644 --- a/crates/successor-sim/src/authority/tests/movement_and_world.rs +++ b/crates/successor-sim/src/authority/tests/movement_and_world.rs @@ -435,6 +435,38 @@ fn authority_sprint_uses_fractional_action_cost_for_smooth_moves() { ); } +#[test] +fn authority_sprint_locks_recovery_when_fractional_remainder_exhausts_action() { + let config = SliceAuthorityConfig::default(); + let snapshot = crate::authority_test_slice(); + let mut state = SliceAuthorityState::from_snapshot(&snapshot).unwrap(); + { + let actor = state.actors.get_mut(&config.player_actor_id).unwrap(); + actor.vitals.action = 1; + actor.sprint_action_drain_milli = 800; + } + + let output = state.apply_envelope( + &config, + command( + 1, + ClientCommand::Move { + dx: 1, + dy: 0, + duration_ticks: 1, + facing: None, + sprint: true, + }, + ), + ); + + assert_eq!(output.status, AuthorityCommandStatus::Accepted); + let actor = state.actors.get(&config.player_actor_id).unwrap(); + assert_eq!(actor.vitals.action, 0); + assert_eq!(actor.sprint_action_drain_milli, 0); + assert!(actor.sprint_recovery_locked); +} + #[test] fn authority_sprint_spends_action_for_faster_move() { let config = SliceAuthorityConfig::default(); diff --git a/crates/successor-sim/src/authority_bridge.rs b/crates/successor-sim/src/authority_bridge.rs index bd99a40e..72d5d326 100644 --- a/crates/successor-sim/src/authority_bridge.rs +++ b/crates/successor-sim/src/authority_bridge.rs @@ -1772,6 +1772,10 @@ pub enum AuthorityBridgeCommandInput { #[serde(rename = "DebugGrantSkillBoxes")] command: AuthorityBridgeDebugGrantSkillBoxesInput, }, + DebugGiveCredits { + #[serde(rename = "DebugGiveCredits")] + command: AuthorityBridgeDebugGiveCreditsInput, + }, EnterTransition { #[serde(rename = "EnterTransition")] command: AuthorityBridgeTransitionInput, @@ -2252,6 +2256,9 @@ impl AuthorityBridgeCommandInput { Self::DebugGrantSkillBoxes { command } => Ok(ClientCommand::DebugGrantSkillBoxes { skill_box_ids: command.skill_box_ids, }), + Self::DebugGiveCredits { command } => Ok(ClientCommand::DebugGiveCredits { + amount: command.amount, + }), Self::EnterTransition { command } => Ok(ClientCommand::EnterTransition { transition_id: command.transition_id, }), @@ -2919,6 +2926,11 @@ pub struct AuthorityBridgeDebugGrantSkillBoxesInput { pub skill_box_ids: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct AuthorityBridgeDebugGiveCreditsInput { + pub amount: i64, +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub struct AuthorityBridgeTransitionInput { pub transition_id: String, diff --git a/crates/successor-sim/src/bin/emit_debug_catalog.rs b/crates/successor-sim/src/bin/emit_debug_catalog.rs new file mode 100644 index 00000000..75014b8a --- /dev/null +++ b/crates/successor-sim/src/bin/emit_debug_catalog.rs @@ -0,0 +1,160 @@ +//! Emit the debug character-builder catalog. +//! +//! The native client embeds the output with `include_str!`, so the builder menu +//! lists exactly what the authority can actually grant. Regenerate with: +//! +//! cargo run -p successor-sim --bin emit_debug_catalog -- \ +//! tools/codegen/generated/debug-catalog.generated.json +//! +//! `--check` exits non-zero when the committed file has drifted, which is what +//! the hygiene gate runs. + +use std::{env, fs, path::Path}; + +use successor_sim::debug_catalog::{debug_catalog_items, debug_catalog_skill_boxes}; + +const SCHEMA: &str = "successor.debug-catalog.v1"; +const REGEN: &str = "cargo run -p successor-sim --bin emit_debug_catalog -- tools/codegen/generated/debug-catalog.generated.json"; + +/// Curated bundles, so a tester can outfit a character in one tick instead of +/// hunting ids. Every id is asserted present in the emitted item list, so a +/// renamed or removed item breaks generation rather than shipping a dead pack. +const PACKS: &[(&str, &str, &[u32])] = &[ + // Everything the survey/extraction loop is gated on. Without the multitool + // and a category tool the authority answers `target_unavailable` and the + // pane looks broken, which is the single most common tester dead end. + ( + "survey_kit", + "Survey Kit", + &[3001, 3006, 3008, 3009, 3010, 3011, 3007], + ), + ( + "extractor_kit", + "Extractor Kit", + &[3012, 3013, 3014, 3201, 3004], + ), + ( + "medic_kit", + "Medic Kit", + &[1001, 1002, 1003, 1007, 1008, 1009], + ), + ( + "weapons_ranged", + "Ranged Weapons", + &[3111, 3112, 3121, 3122, 3123, 3124, 3125, 3126, 3127], + ), + ( + "weapons_melee", + "Melee Weapons", + &[3103, 3104, 3105, 3106, 3107], + ), + ( + "resource_sampler", + "Resource Sampler", + &[2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010], + ), + ( + "creature_harvest", + "Creature Harvest", + &[2101, 2102, 2103, 2104], + ), +]; + +fn escape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for ch in text.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + c => out.push(c), + } + } + out +} + +fn render() -> Result { + let items = debug_catalog_items(); + let skill_boxes = debug_catalog_skill_boxes(); + let known: std::collections::BTreeSet = items.iter().map(|item| item.id).collect(); + + let mut out = String::new(); + out.push_str("{\n"); + out.push_str(&format!(" \"schema\": \"{SCHEMA}\",\n")); + out.push_str(&format!(" \"regenerationCommand\": \"{REGEN}\",\n")); + + out.push_str(" \"items\": [\n"); + for (index, item) in items.iter().enumerate() { + let comma = if index + 1 == items.len() { "" } else { "," }; + out.push_str(&format!( + " {{ \"id\": {}, \"name\": \"{}\" }}{comma}\n", + item.id, + escape(&item.name) + )); + } + out.push_str(" ],\n"); + + out.push_str(" \"skillBoxes\": [\n"); + for (index, entry) in skill_boxes.iter().enumerate() { + let comma = if index + 1 == skill_boxes.len() { + "" + } else { + "," + }; + out.push_str(&format!( + " {{ \"id\": \"{}\", \"title\": \"{}\", \"profession\": \"{}\", \"tier\": \"{}\" }}{comma}\n", + escape(&entry.id), + escape(&entry.title), + escape(&entry.profession), + escape(&entry.tier) + )); + } + out.push_str(" ],\n"); + + out.push_str(" \"packs\": [\n"); + for (index, (id, label, contents)) in PACKS.iter().enumerate() { + for item in *contents { + if !known.contains(item) { + return Err(format!("pack {id} references unknown item id {item}")); + } + } + let comma = if index + 1 == PACKS.len() { "" } else { "," }; + let ids = contents + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(", "); + out.push_str(&format!( + " {{ \"id\": \"{id}\", \"label\": \"{label}\", \"items\": [{ids}] }}{comma}\n" + )); + } + out.push_str(" ]\n}\n"); + Ok(out) +} + +fn main() -> Result<(), Box> { + let args: Vec = env::args().skip(1).collect(); + let check = args.iter().any(|arg| arg == "--check"); + let path = args + .iter() + .find(|arg| !arg.starts_with("--")) + .cloned() + .unwrap_or_else(|| "tools/codegen/generated/debug-catalog.generated.json".to_owned()); + let rendered = render()?; + let path = Path::new(&path); + if check { + let current = fs::read_to_string(path).unwrap_or_default(); + if current != rendered { + eprintln!("debug catalog is stale; run:\n {REGEN}"); + std::process::exit(1); + } + println!("debug catalog: PASS (matches the runtime)"); + return Ok(()); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, &rendered)?; + println!("wrote {}", path.display()); + Ok(()) +} diff --git a/crates/successor-sim/src/command_manifest.rs b/crates/successor-sim/src/command_manifest.rs index 5c5d4f03..b87055fa 100644 --- a/crates/successor-sim/src/command_manifest.rs +++ b/crates/successor-sim/src/command_manifest.rs @@ -1160,6 +1160,16 @@ command_specs! { args: &[repeated_id_arg("skill_box_ids", true, "skill_box_id")], reason_codes: REASONS_DEBUG, }, + rust DebugGiveCredits { + verb: "debug-give-credits", + aliases: EMPTY, + doc: "Debug-only wallet adjustment gated by GAME_DEBUG_AUTHORITY_COMMANDS. Negative amounts drain and clamp at zero.", + budget_class: "authority-debug", + debug_gated: true, + durable_intent: None, + args: &[int_arg("amount", true)], + reason_codes: REASONS_DEBUG, + }, rust EnterTransition { verb: "enter-transition", aliases: &["transition"], @@ -2246,6 +2256,7 @@ pub fn client_command_kind_for_manifest(command: &ClientCommand) -> &'static str ClientCommand::SetEquippedClothing { .. } => "SetEquippedClothing", ClientCommand::DebugGiveItem { .. } => "DebugGiveItem", ClientCommand::DebugGrantSkillBoxes { .. } => "DebugGrantSkillBoxes", + ClientCommand::DebugGiveCredits { .. } => "DebugGiveCredits", ClientCommand::EnterTransition { .. } => "EnterTransition", ClientCommand::UseConsumable { .. } => "UseConsumable", ClientCommand::RefillAmmo { .. } => "RefillAmmo", @@ -2350,6 +2361,9 @@ pub fn client_command_kind_for_manifest(command: &ClientCommand) -> &'static str ClientCommand::GuildRescindWar { .. } => "GuildRescindWar", ClientCommand::GuildDisband { .. } => "GuildDisband", ClientCommand::HarvestCrop { .. } => "HarvestCrop", + ClientCommand::PurchaseTravelTicket { .. } => "PurchaseTravelTicket", + ClientCommand::UseTravelTicket { .. } => "UseTravelTicket", + ClientCommand::ToggleDoor { .. } => "ToggleDoor", } } @@ -2390,9 +2404,9 @@ mod tests { fn command_manifest_golden_shape_covers_sp0_keystones() { let manifest = command_manifest(); assert_eq!(manifest.schema, COMMAND_MANIFEST_SCHEMA); - assert_eq!(manifest.command_count, 117); - assert_eq!(manifest.rust_command_count, 114); - assert_eq!(manifest.debug_gated_count, 2); + assert_eq!(manifest.command_count, 118); + assert_eq!(manifest.rust_command_count, 115); + assert_eq!(manifest.debug_gated_count, 3); let cancel = command("CancelAbilityQueue"); assert_eq!(cancel.verb, "cancel-queue"); @@ -2430,7 +2444,7 @@ mod tests { } assert_eq!( reflected.len(), - 114, + 115, "sample_rust_client_commands must cover every stable ClientCommand variant" ); let manifest_kinds = COMMANDS @@ -2587,6 +2601,7 @@ mod tests { ClientCommand::DebugGrantSkillBoxes { skill_box_ids: vec!["marksman_novice".to_owned()], }, + ClientCommand::DebugGiveCredits { amount: 25_000 }, ClientCommand::EnterTransition { transition_id: "door-a".to_owned(), }, diff --git a/crates/successor-sim/src/lib.rs b/crates/successor-sim/src/lib.rs index 234b4c32..2f494bc8 100644 --- a/crates/successor-sim/src/lib.rs +++ b/crates/successor-sim/src/lib.rs @@ -28,6 +28,7 @@ use successor_inventory::{ use successor_net::{ClientCommand, ClientCommandEnvelope, PlayerId, SessionId}; use thiserror::Error; +pub use authority::debug_catalog; pub use authority::{ AbilityQueueEntryClass, AbilityQueueLifecycle, AuthorityAbilityQueueEntrySnapshot, AuthorityAbilityQueueEventSnapshot, AuthorityAbilityQueueSnapshot, diff --git a/desktop/scripts/verify-key-ownership.mjs b/desktop/scripts/verify-key-ownership.mjs index 224cd869..2fd6ca8e 100644 --- a/desktop/scripts/verify-key-ownership.mjs +++ b/desktop/scripts/verify-key-ownership.mjs @@ -10,9 +10,13 @@ import { fileURLToPath } from "node:url"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const desktopRoot = path.resolve(scriptDir, ".."); +const packageRoot = path.join(desktopRoot, "release", `successor-${process.platform}-${process.arch}`); +const defaultAppBinary = process.platform === "darwin" + ? path.join(packageRoot, "Successor.app", "Contents", "MacOS", "Electron") + : path.join(packageRoot, "successor"); const appBinary = process.env.SUCCESSOR_DESKTOP_BINARY ? path.resolve(process.env.SUCCESSOR_DESKTOP_BINARY) - : path.join(desktopRoot, "release", `successor-${process.platform}-${process.arch}`, "successor"); + : defaultAppBinary; main().catch((error) => { console.error(error); @@ -273,6 +277,7 @@ function desktopVerificationEnvironment({ configRoot, cacheRoot, runtimeLogPath, ...env, XDG_CONFIG_HOME: configRoot, XDG_CACHE_HOME: cacheRoot, + SUCCESSOR_DESKTOP_STATE_DIR: path.join(configRoot, "successor", "game-state"), SUCCESSOR_DESKTOP_MODE: "offline", SUCCESSOR_DESKTOP_ENABLE_DEVTOOLS: "0", SUCCESSOR_DESKTOP_RUNTIME_LOG: runtimeLogPath, @@ -410,32 +415,35 @@ async function runtimeViewportProof(page) { } function runtimeViewportFillsDisplay(proof) { - const display = proof?.windowState?.displayBounds; + const content = proof?.windowState?.contentBounds; const viewport = proof?.viewport; const stage = proof?.stage; - if (!display || !viewport || !stage) return false; + if (!content || !viewport || !stage) return false; return ( - viewport.innerWidth >= display.width - 2 - && viewport.innerHeight >= display.height - 2 - && viewport.clientWidth >= display.width - 2 - && viewport.clientHeight >= display.height - 2 + viewport.innerWidth >= content.width - 2 + && viewport.innerHeight >= content.height - 2 + && viewport.clientWidth >= content.width - 2 + && viewport.clientHeight >= content.height - 2 && stage.x <= 1 && stage.y <= 1 - && stage.width >= display.width - 2 - && stage.height >= display.height - 2 + && stage.width >= content.width - 2 + && stage.height >= content.height - 2 ); } function contentFillsDisplay(state) { const contentBounds = state?.contentBounds; const displayBounds = state?.displayBounds; + const targetBounds = process.platform === "darwin" + ? state?.workArea ?? displayBounds + : displayBounds; return Boolean( contentBounds - && displayBounds - && contentBounds.x <= displayBounds.x - && contentBounds.y <= displayBounds.y - && contentBounds.x + contentBounds.width >= displayBounds.x + displayBounds.width - && contentBounds.y + contentBounds.height >= displayBounds.y + displayBounds.height + && targetBounds + && contentBounds.x <= targetBounds.x + && contentBounds.y <= targetBounds.y + && contentBounds.x + contentBounds.width >= targetBounds.x + targetBounds.width + && contentBounds.y + contentBounds.height >= targetBounds.y + targetBounds.height ); } diff --git a/desktop/src/main.mjs b/desktop/src/main.mjs index 865a385b..53ac6570 100644 --- a/desktop/src/main.mjs +++ b/desktop/src/main.mjs @@ -297,7 +297,7 @@ function registerAppProtocol(sessionProtocol, clientDist, sharedPublicRoots, she } const resolved = resolveAppFile(clientDist, url.pathname); - if (!resolved) return new Response("Forbidden path.", { status: 403 }); + if (!resolved) return new Response("Successor app asset not found.", { status: 404 }); return net.fetch(pathToFileURL(resolved).toString()); }); @@ -348,6 +348,7 @@ function resolveAppFile(clientDist, requestPath) { if (resolved.status) return null; if (fileExists(resolved.path)) return resolved.path; + if (path.extname(resolved.path)) return null; const indexPath = path.join(path.resolve(clientDist), "index.html"); return fileExists(indexPath) ? indexPath : null; } @@ -709,7 +710,10 @@ async function openRuntimeWindow() { } function desktopGameStateDir() { - return path.join(app.getPath("appData"), "successor", "game-state"); + const override = successorDesktopEnv("STATE_DIR"); + return override + ? path.resolve(override) + : path.join(app.getPath("appData"), "successor", "game-state"); } /** diff --git a/desktop/src/server-runtime.mjs b/desktop/src/server-runtime.mjs index d70c6131..80b651f6 100644 --- a/desktop/src/server-runtime.mjs +++ b/desktop/src/server-runtime.mjs @@ -24,7 +24,7 @@ const ownerRefPattern = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u; const stateLockHandshakeSchema = "successor.state-lock.v1"; const stateLockHandshakeTimeoutMs = 5_000; const stateLockReleaseTimeoutMs = 2_000; -const flockBin = "/usr/bin/flock"; +const flockBins = ["/usr/bin/flock", "/opt/homebrew/bin/flock", "/usr/local/bin/flock"]; const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const stateLockSupervisorPath = path.join(moduleDir, "state-lock-supervisor.mjs"); @@ -188,15 +188,17 @@ export async function acquireDesktopStateLock({ const resolvedStateDir = path.resolve(stateDir ?? path.dirname(lockPath)); const resolvedLockPath = path.resolve(lockPath ?? path.join(resolvedStateDir, ".desktop-state.lock")); fs.mkdirSync(resolvedStateDir, { recursive: true }); - assertFile(flockBin, "desktop state locking requires /usr/bin/flock"); + const flockBin = [serverEnv.SUCCESSOR_FLOCK_BIN, ...flockBins] + .find((candidate) => candidate && fs.existsSync(candidate)); + if (!flockBin) throw new Error(`desktop state locking requires one of ${flockBins.join(", ")}`); assertFile(stateLockSupervisorPath, "desktop state-lock supervisor is missing"); if (serverEntry) assertFile(serverEntry, "server dist entry missing; run pnpm --dir server build"); if (serverCwd) assertDirectory(serverCwd, "server working directory missing"); const args = [ - "--exclusive", - "--nonblock", - "--no-fork", + "-x", + "-n", + ...(process.platform === "linux" ? ["--no-fork"] : []), resolvedLockPath, process.execPath, stateLockSupervisorPath, @@ -225,6 +227,10 @@ export async function acquireDesktopStateLock({ const cause = error instanceof Error ? `: ${error.message}` : ""; throw new Error(`Successor desktop game state is already in use or its lock could not be acquired at ${resolvedLockPath}${cause}`); } + // GNU flock can exec the supervisor in-place; BSD flock retains a parent + // process. Preserve the proven supervisor PID so graceful signals reach the + // process that owns server shutdown on both implementations. + child.successorSupervisorPid = handshake.pid; const lease = { child, @@ -696,10 +702,13 @@ export async function stopDesktopServerSupervisor(child, { killWaitMs = stopKillWaitMs, } = {}) { const pid = child?.pid; + const supervisorPid = Number.isInteger(child?.successorSupervisorPid) + ? child.successorSupervisorPid + : pid; if (!pid) return { stopped: false, reason: "missing_pid" }; - log("game-server-stop-start", { pid, port, signal: "SIGTERM" }); - signalProcess(pid, "SIGTERM", log); + log("game-server-stop-start", { pid, supervisorPid, port, signal: "SIGTERM" }); + signalProcess(supervisorPid, "SIGTERM", log); const exitedWithinGrace = await waitForChildExit(child, graceMs); let escalated = false; diff --git a/desktop/src/state-lock-supervisor.mjs b/desktop/src/state-lock-supervisor.mjs index 2e25c2bb..69bb8a32 100644 --- a/desktop/src/state-lock-supervisor.mjs +++ b/desktop/src/state-lock-supervisor.mjs @@ -90,14 +90,15 @@ function stopForLostParent() { function findInheritedLockFd(filePath) { const target = fs.statSync(filePath); - for (const name of fs.readdirSync("/proc/self/fd")) { + const fdDirectory = fs.existsSync("/proc/self/fd") ? "/proc/self/fd" : "/dev/fd"; + for (const name of fs.readdirSync(fdDirectory)) { const fd = Number(name); if (!Number.isInteger(fd) || fd <= 3) continue; try { const candidate = fs.fstatSync(fd); if (candidate.dev === target.dev && candidate.ino === target.ino) return fd; } catch { - // Descriptors can disappear while /proc is enumerated. + // Descriptors can disappear while the directory is enumerated. } } return null; diff --git a/docs/ASSET_PIPELINE.md b/docs/ASSET_PIPELINE.md index 514ad1ce..ac1e684d 100644 --- a/docs/ASSET_PIPELINE.md +++ b/docs/ASSET_PIPELINE.md @@ -21,7 +21,7 @@ Do not call a file integrated because it sits under `public/`. | Kind | Source | Runtime | | --- | --- | --- | -| Humanoids, rigged equipment, animations, weapons | Bunker `~/dev/games/pawn-forge/pawnforgev2/` | `client-3d/public/assets/pawn-pack/` | +| Humanoids, rigged equipment, animations, weapons | neighboring `pawn-forge/pawnforgev2/` source checkout | `client-3d/public/assets/pawn-pack/` | | Creatures | Blender/build recipes in the active media/source workspace | `client-3d/public/assets/creatures/` | | Items, crops, food, tools, medical and gene-lab models | deterministic builders or `.blend` sources | `client-3d/public/assets/items/` | | Curated world props | deterministic builders or source models | `client-3d/public/assets/world-items/` | @@ -102,8 +102,9 @@ Collision ships as a mesh-derived sidecar (`_collision.json`) referenced from the model manifest as a structural-only proxy. Both Dustgate facility sidecars are emitted directly at schema `successor.structure-collision.v3` by their deterministic builders: the cloning facility -(`tools/successor/assets/build_cloning_facility.py`) carries nine stable -structural proxy boxes plus the `closed_door_panel` door blocker, and the +(`tools/successor/assets/cloning-facility-opus5/src/build_clone_suite.py`) +carries nine stable structural proxy boxes plus the `closed_door_panel` door +blocker, and the commerce facility (`tools/successor/assets/build_commerce_facility.py`) carries its wall boxes, 15 named furniture blockers, and a door blocker. Those sidecar boxes are the collision authority; detail meshes never are. diff --git a/docs/ASSET_PROVENANCE_POLICY.md b/docs/ASSET_PROVENANCE_POLICY.md index efa1b703..378aaa61 100644 --- a/docs/ASSET_PROVENANCE_POLICY.md +++ b/docs/ASSET_PROVENANCE_POLICY.md @@ -29,7 +29,7 @@ The audit scope includes: - `client-3d/public/assets/` - `client/public/successor-audio/` - `client/public/successor-slice/` -- Bunker `~/dev/games/pawn-forge/pawnforgev2/` for source recipes +- neighboring `pawn-forge/pawnforgev2/` checkout for source recipes Purchased trial packs may stay as a cataloged selection library when their license permits it. Promotion into the world requires a stable Successor id, diff --git a/docs/CANONICAL_CONTEXT.md b/docs/CANONICAL_CONTEXT.md index baccd9f1..44e3f4cc 100644 --- a/docs/CANONICAL_CONTEXT.md +++ b/docs/CANONICAL_CONTEXT.md @@ -1,6 +1,6 @@ # Successor Canonical Context -Status: current supported architecture as of 2026-07-28. +Status: current supported architecture as of 2026-07-31. This is the repository's source of truth for product scope and ownership. Code and tests define exact behavior; when they change this contract, update this @@ -21,10 +21,50 @@ focused design detail and cannot introduce another active runtime path. | Gameplay authority | `crates/successor-sim/` | Deterministic world simulation and gameplay mutations | | Shared Rust contracts | `crates/successor-{core,inventory,net,wasm}/` | Types, inventory primitives, wire commands, and platform bindings | | Public deployment | `ops/deploy/` | Immutable client/site publication, AWS infrastructure, and single-writer server operation | +| Rust web beta | `client-rust/` | no_std Rust engine plus one authority-driven connected runtime on desktop GL and WebGL2; the WebGL2 build is an opt-in beta at `/beta/`; native remains development-only | + +There are two stable player-facing clients. `client/` is a shared package, not +a third visual client. The Rust WebGL2 client is a separately versioned, +opt-in beta surface; it does not replace the stable graphical or terminal +clients. + +`client-rust/` runs the same streamed-state projection and command path as the +stable clients. Its public WebGL2 artifact is selected only by +`/beta/release.json`; `/play/` continues to select `/client/release.json`. +Both surfaces use the same accounts, character roster, world, one-use +capabilities, server protocol identity, single authority, and durable state. +The native Rust build remains development-only and stays out of the download +ledger. + +The native client's desktop platform has one developer-only agent-control +surface. Explicit opt-in starts a loopback-only text protocol; the companion +`successor-control` CLI can override keyboard, pointer, text, and scroll input, +request completed-frame screenshots, and drive or inspect a live connected +client. The same platform boundary records effective input to the current-only +`successor.input.v1` frame command format and replays it deterministically. +This tooling is disabled by default, is absent from the web backend, and +submits gameplay through the ordinary client/server command path; it is not a +second gameplay authority or a public control endpoint. + +The browser backend consumes a strict launch context from the exact storefront +parent, stable-id assets, and the same connected scene as native. Public builds +exclude URL/global launch capabilities and bind the storefront, game, chat, +client-release, and server-release identities at build time. The runtime may +rebuild renderer resources after WebGL context loss while retaining the +session and last valid authority projection; it must not replay launch +capabilities or synthesize gameplay state. The deterministic +half-float-disabled mode is verification-only. + +The native renderer also has one developer graphics-mastering layer over the +existing immediate-mode UI. Backquote toggles it; its validated Low, Medium, +and High presets live at `client-rust/assets/render/settings.json`. Native +development may tune and atomically save sun, shadows, ambient/AO/emissive, +bloom, FXAA, exposure, color grade, and palette quantization. Web builds apply +the checked-in settings read-only. Missing or invalid settings fall back to +compiled defaults. This is presentation tooling only: it creates no gameplay +authority, public surface, or alternate renderer. + -There are two supported player-facing clients. `client/` is a shared package, -not a third visual client. Both clients submit the same server commands and -render the same authoritative state. ## Public alpha topology @@ -33,21 +73,28 @@ The supported public alpha is live: ```text www.successorgame.com -> S3/CloudFront site shell - -> immutable browser-client release on CloudFront + -> stable `/play/` pointer and opt-in `/beta/` pointer + -> separate immutable browser-client releases on CloudFront + -> content-addressed `objects/` payloads for streamed Rust-beta assets + (immutable `SPAK1` packs plus standalone wardrobe/creature files; boot + closure + spawn-region packs staged first, region packs and wardrobe + streamed on demand through the async asset channel) -> same-origin account/control routes - -> one-use game and chat tickets + -> release-bound one-use game and chat tickets -> world.successorgame.com ALB -> one private EC2 host -> one digest-pinned TypeScript/Rust authority container -> one encrypted persistent state domain ``` -The marketing site and browser-client pointer are separately promotable. A site -release does not deploy gameplay, and a game release does not imply a site or -native-download promotion. The public EC2 authority remains single-writer and -has no public SSH ingress. SSM is the operator path. S3/CloudFront owns the -site, browser client, and native archives; the ALB owns public game/chat -ingress. +The site, stable browser pointer, and Rust beta pointer are independently +promotable. A beta promotion never mutates `/client/release.json`; disabling or +rolling back beta changes only `/beta/release.json` or removes the exact beta +release from the server allowlist. Stable and beta releases must name the same +server protocol identity. The public EC2 authority remains single-writer and +has no public remote-shell ingress. The provider session service is the +operator path. S3/CloudFront owns the site, browser clients, and native +archives; the ALB owns public game/chat ingress. `CURRENT_DEPLOYMENT.md` owns the exact current site release, client manifest, source commit, server image digest, state generation, and download manifest. @@ -128,9 +175,10 @@ forces a `world-transition` checkpoint before the server acknowledges it, then refreshes the character-store projection. The current file format is `successor.character-store.v2`. Every record carries -its owner, complete appearance (including an explicit nullable face), worn -projection, position/vitals, profession/profile fields, bounded record kinds, -world-entry marker, and timestamps. The standalone account database head is +its owner, complete appearance (explicit `male`/`female` body route and explicit +nullable face), worn projection, position/vitals, profession/profile fields, +bounded record kinds, world-entry marker, and timestamps. The standalone +account database head is `alpha-control-bug-reports-v2`; startup has one explicit, checksum-pinned upgrade from `alpha-control-current-v1` that preserves accounts and adds the player-report ledger. Every browser or device launch carries immutable @@ -138,6 +186,13 @@ provenance. Older character formats, unknown account migration heads, claim-code ownership repair, and synthesized ticket identity are not supported inputs. +Character creation requires one of the two canonical PawnForge humanoid bodies. +The character store persists that choice, and the server projects it +deterministically as `adventurer-premium-male` or +`adventurer-premium-female`; clients do not infer body type from names, roles, +hair, or clothing. Both bodies use the same 50-joint/47-clip runtime contract +and accept the fixed starter outfit without a separate gameplay identity. + The bridge keeps the wire request names `exportState` and `importState`, but the payload is the current-only `authority.checkpoint.v1` schema at version `1`. It stores durable progress and the ids of open authored doors. It does not own @@ -183,7 +238,7 @@ Dapplepod. The fixture identity string remains `planetfall-v5-seed-424242-size-1024-rogues-18-desert-critters-48-verdance-critters-24-areas-open-desert-overworld-verdance-forest-overworld`. -The compiled slice contains 139 stable logical props/anchors, including the humanoid +The compiled slice contains 141 stable logical props/anchors, including the humanoid camps' solid props, 19 take-only footlockers, sparse 15-prop Dustgate occupation, and the interactive factory `dustgate-occupation-workbench` at cell (494, 508). Dustgate hammer was removed instead of increasing asset caps. Bank and clone anchors @@ -193,9 +248,9 @@ are `dustgate-bank-terminal`, `dustgate-cloning-facility`, `dustgate-pa-terminal`. The identity string is unchanged. The checked-in slice hash is -`69a19db8289b0d4711ccca5d4febef39b8dcd2ef662f9f70539935e49af8680e` and the +`bd489338c0d65535f4fc1d8cfe7f0dcb3f532ced7f658c756c39913ffea00c02` and the map-bundle hash is -`01df5d1d178a8199b5bbd62f7e2107f017f5ae2ba1ca45081bb0ecdbb8f65795`. The clone +`df23df6a59f555040f607b7ac5218d0472e6921df2132b2ed2828f2daedf9ef5`. The clone facility sits at cell (513, 499), size 10 by 8, yaw 0, on the north edge of the Dustgate plaza with its entrance facing south into the plaza. Its authored floor is 0.02 m; the runtime floor after the fixture's uniform fit is @@ -439,9 +494,8 @@ emitted `index.html`, keeps Three.js on the eager vendor path while the emitted index has no static `feature-*` JS/CSS, and loads features dynamically. The public browser release, authority image, native manifest, and site release -are recorded in `CURRENT_DEPLOYMENT.md`. The primary development branch is -`main` and may be ahead of the production game source; integrated does not mean -promoted. +are recorded in `CURRENT_DEPLOYMENT.md`. The current development source may be +ahead of the production game source; integrated does not mean promoted. Use these maturity labels when discussing content: @@ -513,6 +567,6 @@ fixture for end-to-end proof. Add a new product surface only through an explicit decision recorded here. `docs/future/` contains retained design briefs, not current contracts or -implementation claims. Revalidate a brief against `main` before using it, then -move any implemented contract into the canonical docs or a focused current -specification in the same change. +implementation claims. Revalidate a brief against the current source tree +before using it, then move any implemented contract into the canonical docs or +a focused current specification in the same change. diff --git a/docs/CHARACTER_SYSTEM.md b/docs/CHARACTER_SYSTEM.md index 134bdd51..12532487 100644 --- a/docs/CHARACTER_SYSTEM.md +++ b/docs/CHARACTER_SYSTEM.md @@ -1,7 +1,7 @@ # Successor Character System -Status: current `main` character contract. The public alpha may trail this -source; exact production identity lives in `CURRENT_DEPLOYMENT.md`. +Status: current development-source character contract. The public alpha may +trail this source; exact production identity lives in `CURRENT_DEPLOYMENT.md`. ## Ownership diff --git a/docs/COMPRESS_HOSTING_FOUNDATION.md b/docs/COMPRESS_HOSTING_FOUNDATION.md index adc0b545..d953f3d3 100644 --- a/docs/COMPRESS_HOSTING_FOUNDATION.md +++ b/docs/COMPRESS_HOSTING_FOUNDATION.md @@ -35,15 +35,15 @@ ComPress user ## Repository Management -ComPress is not vendored or forked into Successor. The canonical ComPress -checkout owns `plugins/successor`, its account models, and ticket routes. -Successor consumes that capability only through the versioned HTTP and Redis -ticket boundary; game authority and game persistence stay in this repository. +ComPress is not vendored or forked into Successor. Its owning repository +controls `plugins/successor`, account models, and ticket routes. Successor +consumes that capability only through the versioned HTTP and Redis ticket +boundary; game authority and game persistence stay in this repository. Cross-repository work uses separate commits in each owning repository. The -bridge verifier targets the canonical ComPress checkout by default and accepts -`COMPRESS_MAIN_ROOT` when CI or another workstation checks it out elsewhere. -Temporary feature worktrees are never an integration source of truth. +bridge verifier accepts `COMPRESS_MAIN_ROOT` as the explicit ComPress checkout +location. Bind cross-repository proof to exact revisions; do not infer authority +from checkout names, branch names, or worktree layout. ## Ownership Map diff --git a/docs/CURRENT_DEPLOYMENT.md b/docs/CURRENT_DEPLOYMENT.md index eb83057e..62f3cebf 100644 --- a/docs/CURRENT_DEPLOYMENT.md +++ b/docs/CURRENT_DEPLOYMENT.md @@ -1,8 +1,12 @@ # Successor Current Deployment -Status: re-observed and fully exercised on 2026-07-30 UTC after the authority -hotfix, browser-client promotion, native-package publication, and site -promotion. +Status: site and Rust beta promoted on 2026-08-08 UTC from +`dev/rust-client` source `2d9f1e14467efcf526712ee54aa4840911d433aa`. +Stable browser client remains source +`bd1396cdc9c1249605888db2bb465d17d6cdd39b`; the authority remains source +`4b659e80f9ea2f652606167782694c4ef26aaa1f` with image content identical from +`62202406`. The authority image, stable pointer, native download ledger, and +durable state were unchanged. This file owns volatile production identity. Product and authority contracts live in `CANONICAL_CONTEXT.md`, implementation inventory lives in @@ -13,31 +17,272 @@ operator procedures live in `OPERATIONS.md`. | Surface | Public address | Current identity | | --- | --- | --- | -| Site, account, and browser launch | `https://www.successorgame.com/` | `site-0acf4e2e-20260730` | -| Browser client pointer | `https://www.successorgame.com/client/release.json` | `successor-alpha@cdab7dccacc1d75c` | -| Game and chat authority | `https://world.successorgame.com/` and `wss://world.successorgame.com` | image digest `e46164824608…` | -| Native download ledger | `https://www.successorgame.com/downloads/manifest.json` | release `successor-alpha@cdab7dccacc1d75c`, version `0.0.4`, four builds | -| Public source | `https://github.com/LycaonLLC/successor` | site release commit `0acf4e2e449ca830192a487e6daa7e06711abb45` | - -The site, browser client, and native archives are versioned S3 objects behind -CloudFront. One digest-pinned authority container runs on private EC2 behind -the public ALB. The host has no public SSH ingress. Operators reach it through -SSM from Bunker; Bunker is a build, test, and operations host, not the public -game host. +| Site, account, stable launch, and beta launch | `https://www.successorgame.com/` | `site-2d9f1e1-20260808`, manifest `49e925113742ae98f61c9658ef10080b819e36dd407e61fc813ce30611f55092` | +| Stable browser pointer | `https://www.successorgame.com/client/release.json` | `successor-alpha@bd1396cdc9c12496` (unchanged) | +| Beta browser pointer | `https://www.successorgame.com/beta/release.json` | `successor-rust-beta@2d9f1e14467efcf`, manifest `7ba73519c97334a04f212d39ec46b20e46a1427c6861019fa2886f4459d01ecd` | +| Game and chat authority | `https://world.successorgame.com/` and `wss://world.successorgame.com` | image digest `sha256:47abcfe6c86b090553093dc3375d164571f719d0952c8e0cb871182d8394ecb5` (tags `rel-62202406`, `rel-0efb2347`, `rel-e1e0e44c`) | +| Native download ledger | `https://www.successorgame.com/downloads/manifest.json` | unchanged: release `successor-alpha@cdab7dccacc1d75c`, version `0.0.4`, four builds | +| Public source | `https://github.com/LycaonLLC/successor` | stable `bd1396cdc9c1249605888db2bb465d17d6cdd39b`; authority `4b659e80f9ea2f652606167782694c4ef26aaa1f`; site/beta `2d9f1e14467efcf526712ee54aa4840911d433aa` | + +The site and immutable browser assets are in S3 behind CloudFront. One +digest-pinned authority container runs on private EC2 behind the public ALB. +The host has no public remote-shell ingress. Operators use the documented +provider session path; development workstations are not public game hosts. + +The `client-rust/` WebGL2 client is now available only through the opt-in +`/beta/` route. It has not replaced the supported stable browser client and is +not present in the native download ledger. Stable and beta have independent +no-cache pointers and immutable release prefixes; promotion or rollback of one +does not move the other. + +The promoted beta identity is source +`2d9f1e14467efcf526712ee54aa4840911d433aa`, client +`successor-rust-beta@2d9f1e14467efcf`, and immutable publication inventory +SHA-256 `7ba73519c97334a04f212d39ec46b20e46a1427c6861019fa2886f4459d01ecd`. +Previous dry runs and superseded beta candidates are not release identities. + +## 2026-08-08 site and Rust beta promotion — entry overlay fix + +Site and beta-only promotion; authority image, stable pointer, native ledger, +and durable state were untouched. + +- Source `2d9f1e14467efcf526712ee54aa4840911d433aa` + (`fix(site): dismiss beta entry panel after handoff`). The launch lifecycle + sets `data-stage-state="live"`; the site CSS incorrectly hid the entry panel + and exposed frame controls only for the unused state `running`. The CSS and + regression contract now use the actual `live` state. +- Site `site-2d9f1e1-20260808`, immutable manifest + `49e925113742ae98f61c9658ef10080b819e36dd407e61fc813ce30611f55092` + (53 files), promoted to `site/current`. The authenticated S3 pointer names + that exact release and manifest. The documented public `/current.json` route + returned HTTP 403 during verification; `/beta/` and its promoted stylesheet + returned HTTP 200 and contained the corrected live-state selector. +- Client `successor-rust-beta@2d9f1e14467efcf`, immutable publication + inventory SHA-256 + `7ba73519c97334a04f212d39ec46b20e46a1427c6861019fa2886f4459d01ecd` + (6 uploaded, 304 skipped-existing; web-release manifest + `16adc27ce4b70e72f80f56ab3f0ed4e56d7ce1fe6459290d8f478b2422b23b4e`). +- Authority allowlist gained `successor-rust-beta@2d9f1e14467efcf` via the + documented SSM interactive path; `successor.service` restarted with zero + sessions and returned `active`. +- Verification: site suite 179/179, site build and all seven transfer budgets + passed; Rust hygiene and the clean-tree workspace build/test gates passed. + Root CI passed context, command/coverage, verification, deploy, Terraform, + and denylist checks, then stopped at the pre-existing transitive + `@colyseus/core` `nanoid <3.3.17` production-audit finding. Public Chromium + loaded the promoted stylesheet, observed the `live` entry panel as + `display: none`, observed frame controls as `display: flex`, and visually + confirmed no panel over the frame. The beta pointer and immutable client + entry returned HTTP 200; `/healthz` returned 200, `/readyz` reported every + check true, and `/game/status` reported `rustLive: true`. + +## 2026-08-07 Rust beta promotion — lighting settings release + +Beta-only flip; authority image, site, stable pointer, and durable state were +untouched. + +- Source `792dfa58c7064afbca35bdb07842a866c574faaf` ("saving lighting + settings"; removes pawn auto point lights, adds the `auto_point_light_scale` + graphics tuning slider and per-preset values, adds + `client-rust/tools/connect-local.sh`). +- Client `successor-rust-beta@792dfa58c7064afb`; immutable publication + inventory SHA-256 + `d58f158963cf2ce109e889f9b2e000d07ce567df4fb949edb23a3f13212e90a8` + (publish: 6 uploaded, 304 skipped-existing; web-release manifest + `8d638d7097bfbf38566c122246273ab08152007b43c3c083254ed0e1b51cc00a`). +- Authority allowlist gained `successor-rust-beta@792dfa58c7064afb` via the + documented SSM interactive path; `successor.service` restarted with zero + sessions connected and returned `active`. A first publication stamped with + pre-rebase commit `944cf16…` (content-identical tree) was superseded before + pointer promotion; its allowlist id is kept, matching prior superseded-id + practice. +- Verification: `https://www.successorgame.com/beta/release.json` serves the + new pointer; immutable `index.html`, `successor.js`, and `successor.wasm` + (3,445,303 bytes) return HTTP 200 from the new release prefix; the wasm + embeds `successor.render-settings.v1` with `auto_point_light_scale` + (`0.37007576` medium preset); `/healthz` 200, `/readyz` all checks true, + `/game/status` `rustLive: true`. + + +## 2026-08-07 integration promotion — authority, Rust beta, site + +Release evidence document SHA-256 (the recorded maintenance seal identity): +`f9d837dec8faed775e1fc7c9c02a8af4b2489d994b413d2e22738fb70a863b10` +(source and copy: `release-evidence-e1e0e44c.json` on the cockpit at mint +time; bind it into the ops evidence store on the next operator session). + +What shipped, all from `integration/rust-ui-runtime-20260803`: + +- **Authority image** `sha256:47abcfe6c86b090553093dc3375d164571f719d0952c8e0cb871182d8394ecb5` + replaced `b45c8ad37913…` via the documented SSM maintenance deploy + (`GAME_SHARD_MANIFEST_PATH` had to be supplied explicitly — the preflight + default filename does not match the live `state-generation.manifest.json`). + Zero sessions were connected; the durable state tree was untouched and the + durability generation remained + `e8455ec582e9b99ddd5ed27b741a00cfabb1ee4b1f7073d36bb58079473e6609`. + The allowlist gained `successor-rust-beta@e1e0e44caa38b1d1`; prior stable and + beta identities remain accepted. This image carries the co-dev movement + overhaul with per-kind ingress budgets, room-message handling, and the idle + pong flush. +- **Rust web beta** `successor-rust-beta@e1e0e44caa38b1d1`, publication + manifest `551b0fff8ced3c7c39c0b8da91c8b9810d7da5c37b78879992fa074fae9758d8`, + published to the object store (first population: 301 objects + 9 in-dist + duplicates) and pointer-flipped after the authority accepted the new id. + Client fixes in this release: compact combat-event tuple decoding (combat + FX/floating text/SFX were silently dead on the live wire), NPC world-corpse + looting (window opens from the actor snapshot's lootable marking), streamed + inventory 3D preview retry, Tab target cycling, full number-row toolbar key + sampling, and a fresh-install toolbar loadout (ATTACK on 1, AIMED SHOT on 2). +- **Site** `site-62202406-20260807` — ground-up FE rewrite on the game's own + chrome with five keyed themes (DAWN default; SIGNAL/PHOSPHOR/AMBER/OXIDE are + byte-exact hud.rs ports, contract-tested against the client source), minimal + copy, evidence-plaque field record including two native Rust client + captures, and the real TUI frame. Published immutable under + `site/releases/site-62202406-20260807` and promoted to `site/current`. + +Verification basis: static G0 gates (context, private-path — on the cockpit +checkout; the deploy worktree lacks the `hub/main` ref the private-path gate +expects — commands, coverage, denylist, fixture contract, zero-GPU, wardrobe, +deploy contract) all pass; client-rust unit suite 439 at release head; +workspace suites 586 at `5d40e029` with client-rust-only deltas after; +site suite 178 with performance budgets met; amd64 container smoke (read-only +rootfs, tmpfs state) healthy; live Bunker Xvfb runtime proofs (door +open/close, combat FX visible post-fix, NPC loot window, Tab cycling). The +full verification farm is NOT runnable on currently available hosts: every 3D +journey task is `linux-only` (Mac ineligible) and Playwright 1.59 does not +support Chromium on ubuntu26.04-x64 (Bunker ineligible) — the same +host-availability waiver recorded for the 2026-08-06 promotion. Restoring a +supported Linux farm host is the standing gap. + +Runtime-presentation proof (Bunker Xvfb, local authority, same source): +fresh-install toolbar shows ATTACK/AIMED on 1/2; Tab selects the sparring +partner and keys 1/2 return `accepted:ok` receipts; combat FX draw (muzzle +light, outcome burst, floating text); doors open/close; a rights-held NPC +corpse opens the loot window and row TAKE transfers. Known tooling boundary: +`DebugGiveItem` cannot grant wardrobe ids (73xx, 7319, even 9900001) on +current-fixture shards — clothing enters via the creation lifecycle; debug +wardrobe grants only work against pre-catalog-cut states, which blocks +outfit-portrait capture automation on fresh shards. + +Public post-deploy checks: `/healthz` 200, `/game/status` live with +`rustLive: true` on the preserved state; beta pointer serves the exact new +source, client id, and manifest; the immutable entry answers HTTP 200; a +contextless direct open of the release index fail-closes with +`hosted launch is not configured` while booting the wasm and canvas (the +ticketed `/beta/` path is the supported entry). A signed-in public-account +launch has not yet been exercised for this promotion. + +Operational drift found and worked around during this promotion (fix +forward): `~/.config/successor/instance-id.txt` on the provider host names a +stale instance; the AWS CLI 2.31/ubuntu26 build crashes on `ssm send-command` +(boto3 venv used instead); `publish-site.mjs` requires `--output-dir` despite +advertising a default; `promote-client-runtime` inputs are trusted for +`--source-commit` (a wrong tail was initially published and immediately +re-flipped); the release-seal tooling rejects current v2 asset manifests and +the stable manifest's own release-id shape, and the standalone farm scheduler +has no eligible host (above). The site `/current.json` public pointer route +returns 403 at the edge (route never wired); the S3 object exists. + +## 2026-08-06 Rust beta action-key hotfix + +Source `2f7ffd99c360c69cbd9ba2c45e9ab54c4ef6aeca` replaced the Rust beta +only. Symbolizing the production WebAssembly stack located the interaction-key +trap in `audio::triggers::play_ui`: it called `std::time::Instant::now`, which +panics on the shipped `wasm32-unknown-unknown` target before an F, R, or Space +action can be dispatched. UI cue throttling now uses +`successor_platform::now_ms` and saturating integer elapsed-time arithmetic. + +The corrected beta is +`successor-rust-beta@2f7ffd99c360c69c`, with immutable publication inventory +SHA-256 `26dda4a36284690a35596d292e5f52c92779251db3163c06c2df8efb5ad78ac4`. +The client-rust verify, allocation, runtime, render, terrain, and `no_std` +gates passed; the steady-state frame allocation result remained zero. + +The authority received only the additional beta client identity in its release +allowlists and restarted on the unchanged image digest +`b45c8ad37913e577a82f2a28eea76a15a108da22abfe6c4cd460bb78cafe2721`. +No schema, fixture, state, stable pointer, site release, or native download +changed. After restart, public health and readiness returned HTTP 200 and the +durability generation remained +`e8455ec582e9b99ddd5ed27b741a00cfabb1ee4b1f7073d36bb58079473e6609`. + +The no-cache beta pointer returned the exact corrected source, client identity, +server protocol, and inventory above. The immutable entry, JavaScript, and +WebAssembly objects returned HTTP 200. A fresh public beta launch entered the +world, opened the cloning terminal with F, approached the Dustgate Cloning +Facility door, and opened it with F. The render loop remained live with no +page, console, or recorded render error. + +## 2026-08-06 production promotion + +The exact release source is +`bd1396cdc9c1249605888db2bb465d17d6cdd39b`. The promoted identities are: + +- stable client `successor-alpha@bd1396cdc9c12496`, immutable inventory + SHA-256 `6091484c96fa83d22811cd7ce64b457214b4333fbb73912d2b57f2905b246f12`; +- Rust WebGL2 beta `successor-rust-beta@bd1396cdc9c12496`, immutable + publication inventory SHA-256 + `203befa84a9957649aeddac270e0cb30ffe0060c75e7acade05fdcb154b8a792`; +- site `site-bd1396c-20260806`, manifest SHA-256 + `fe37674da4bfc0acda9cb7d37788d5d81cb5a78d5f9f6ef0b84eb00166ee40b1`; +- authority image + `595529182031.dkr.ecr.us-east-1.amazonaws.com/successor-staging-1/server@sha256:b45c8ad37913e577a82f2a28eea76a15a108da22abfe6c4cd460bb78cafe2721`. + +The server protocol identity remains +`planetfall-v5-seed-424242-size-1024-rogues-18-desert-critters-48-verdance-critters-24-areas-open-desert-overworld-verdance-forest-overworld`. +The live fixture SHA-256 is +`c21a81d9e511fa35d059a51eeb5ffc9f60d12b6d6cc63161930befb733bb5e2d`; +the map-bundle SHA-256 is +`260687cb95cee5f783e134e3a401f7265d05f0403307fcd6b4283cec2f281cb2`. + +Before replacement, the stopped single writer was backed up to immutable, +versioned object +`s3://successor-backups-5a537a77/state/successor-before-release-20260806T005412Z-immutable.tar.gz`, +version `jcnGL5_GznYlIgBhNF2YEXr00BdwGs2C`. The archive is 1,224,034 bytes with +SHA-256 `39b40ea32b54b671000706ef6dd9c1a9b76055f6606ef1c8b6c4b2ad7496737d`. +The bucket was discovered without the documented versioning protection; +versioning was enabled before this immutable copy was created. + +The new fixture did not match the prior pre-alpha checkpoint. Although an +immutable backup had been verified and the writer was stopped, the character +roster, checkpoint, journal, durability manifest, and craft-roll-key binding +were reset without the explicit reset authorization required by repository +policy. Account and control-plane state remained intact. The pre-reset domain +remains recoverable from the versioned backup above, but restoring it requires +either the matching older authority or an explicit conversion to the new +fixture. The replacement durability generation is +`e8455ec582e9b99ddd5ed27b741a00cfabb1ee4b1f7073d36bb58079473e6609`. + +Release proof passed `pnpm run ci`, the client-rust verify, allocation, +runtime, render, terrain, and `no_std` gates, the desktop smoke +`desktop-smoke-20260806T012013Z`, and an amd64 container smoke. The full native +Linux/x64 verification farm was explicitly waived for this promotion because +no matching host was available; the native download ledger was not changed. +The release evidence SHA-256 is +`b80a0324b6bbad81850fab04d4ec39da9c927bef2476c489c14313fa21d0b296`. + +After promotion, public `/healthz` and `/readyz` returned HTTP 200 with every +readiness check true. `/game/status` reported the exact fixture, map bundle, +release source, and durability generation above. A fresh public account +created a nondefault Scout, rendered the character doll, entered the stable +3D world with chat connected, and moved from `E 7 · N 9` to `E 7 · N 13`. +The same account launched the promoted Rust beta, completed its streaming and +scene-building phases, rendered a 1440 by 1000 world canvas, and accepted +movement input. ## Site The authenticated S3 pointer contains: -- release: `site-0acf4e2e-20260730` -- source commit: `0acf4e2e449ca830192a487e6daa7e06711abb45` +- release: `site-254cc62-20260802` +- source commit: `254cc626622b1f0badf1cc612a5fa59c505a7487` - manifest SHA-256: - `ef6c2f6e5f00cf872e7838d9b6b9d1f3eb39fb5a7774641dead53659705ef9d9` -- release prefix: `site/releases/site-0acf4e2e-20260730` -- inventory: 48 files, 35,577,370 bytes + `5b67d31f55acea8830a00e228e7e34f1dd055fb34a4dc2a05a5fb0f94abd23ec` +- release prefix: `site/releases/site-254cc62-20260802` +- inventory: 49 files -The site suite passed 173/173 tests on Bunker, then its TypeScript/Vite build -and all seven transfer-budget checks passed. The publisher excluded the +The site suite passed 174/174 tests, followed by its TypeScript/Vite build +and all seven transfer-budget checks. The publisher excluded the independently managed `downloads/manifest.json` as required. After promotion, an isolated headless Chrome session loaded the public @@ -75,14 +320,139 @@ The earlier launch-identity regression is repaired. Tickets, server protocol identity, client identity, and shard identity now agree, and character selection/creation hands directly into the 3D client. +### Rust WebGL2 beta + +The independent beta pointer is: + +- source commit: `67fb09e96a898f37259c47077776c7b04f2909fb` +- client release: `successor-rust-beta@67fb09e96a898f37` +- manifest SHA-256: + `9ee75835de342bfc624d4dce9a4eb81d212a90f7d78077033d42936cc01770f1` +- release-builder manifest SHA-256: + `df9bc617cb3e20c973b1a22c31a6adc8a1ee3f3758bb2668968f810de6b32b6b` +- immutable entry: + `https://d2kf3ri6r74a0m.cloudfront.net/releases/9ee75835de342bfc624d4dce9a4eb81d212a90f7d78077033d42936cc01770f1/index.html` + +The first-load HTTP 525 failure was an expired launch ticket: the site minted +the ticket before constructing the iframe, while the Rust client downloaded +its complete initial asset closure before matchmaking. The client now +preloads first and announces readiness afterward; the authenticated site +mints and delivers the launch context only in response to that exact-origin, +exact-window readiness message. Duplicate readiness messages reuse the +in-memory envelope rather than minting another ticket. + +The mandatory Rust client gates passed with zero steady-state frame +allocations. The release adds blended gait transitions and enables the Web +Audio bootstrap in the public artifact. The authority allowlist was extended +with the exact beta client identity, then `successor.service` was restarted; +public readiness again reported every check true. + +The public pointer returned the exact promoted source, client, manifest, and +server protocol identities. An authenticated beta journey minted a ticket +with HTTP 200, completed matchmaking with HTTP 204 then HTTP 200, rendered the +world at 1440 by 900, resized to 1100 by 700, and moved the selected actor. +Accepted commands and receipts both advanced by 12. Forced WebGL2 context loss +and restoration emitted both expected events and left the context live. A +page reload minted fresh tickets and completed a second 200/204/200 entry. +The redacted proof record has SHA-256 +`eada44e716f928d7c427fca5254fad4021eaf657583960c5cccccd8bd646dbbb`. + +The current release repairs the WebGL matrix-array bridge: skinned draws now +upload all `count * 16` floats instead of only the first joint matrix. The +authority accepted the exact release after its allowlist update and clean +restart. A public authenticated Chromium journey loaded the promoted immutable +entry with no failed requests or render error, visibly rendered the selected +player pawn and its equipment in the live world, and visibly moved that pawn +under keyboard input. The redacted proof record has SHA-256 +`5b5cf77c2b5b58702031ab8d50361d51b5263b45c74a79e1781d568071dc780f`. + +The 2026-08-04 beta release lowers the enterable-building shell cutoff from +two thirds to 40 percent of fitted model height and replaces instant authored +roof/wall hiding with the renderer's smoothstep-driven screen-space dither. +The mandatory standalone-client gates passed, including zero standard and +connected steady-state frame allocations. The exact release was added to the +authority allowlist through the provider operator route, `successor.service` +was restarted, and public readiness returned every check true. + +An authenticated 1440 by 900 Chromium journey selected `Beta-Rook`, loaded the +promoted immutable entry, completed the authority connection, moved through +the Dustgate cloning-facility doorway, and visibly rendered the lower interior +shell around the player. The completed frame reported no render error; the +authority accepted movement commands, then returned to zero sessions after +the page closed. The inspected frame SHA-256 is +`d8fa7873805c8aece4459458c58ae3ac466a65b8f9ef45923ae591330bef4bde`. + +The stable pointer remained `successor-alpha@cdab7dccacc1d75c`. Its repeated +public `/play/` journey still reached HTTP 204 then failed HTTP 400 +`{"error":"invalid matchmake body"}`. The beta exit control also disappeared +without removing its iframe in this headless journey. Audio bootstrap presence +and automated coverage were verified, but the live `AudioContext` state was +not directly observable from the cross-origin headless frame. These are +observed residual defects, not successful checks. + +The 2026-08-04 head-relative-cutoff release replaces the fitted-model-height +percentage with the normalized adult pawn head height plus 0.2 m of clearance. +The cutoff follows the player's terrain elevation and applies to both authored +selective shells and legacy whole-prop cutaways. The exact release was added to +`SUCCESSOR_ALPHA_CLIENT_RELEASE_ALLOWLIST` through the provider operator route, +`successor.service` was restarted, and public readiness returned every check +true. + +An authenticated public `/beta/` journey selected `Beta-Rook`; ticket minting +returned HTTP 200 with the exact client and server identities, the promoted +immutable client entered the live authority, and the completed 1440 by 900 +frame visibly showed the active lower interior shell around the player. The +inspected frame SHA-256 is +`6000395e6942a1101c8b99830e125cc782818a9ee79731710f1cf9f92041ac37`. +The stable pointer remained `successor-alpha@cdab7dccacc1d75c`. + +The corrected 2026-08-04 beta release selects every authored enterable shell +side (`roof`, front, right, back, and left) and uploads the point-light +`u_screenSize` uniform as the declared `vec2`, eliminating the observed +`glUniform4f` `INVALID_OPERATION`. The exact client identity was added to +`SUCCESSOR_ALPHA_CLIENT_RELEASE_ALLOWLIST` through the provider operator route, +`successor.service` was restarted, and public readiness returned every check +true. + +An authenticated public `/beta/` journey selected `Beta-Rook`; ticket minting +returned HTTP 200 with the exact corrected client and server identities. The +promoted immutable client rendered the live world and the cloning facility's +complete cutaway at 1440 by 900. Captured console and page errors contained no +`INVALID_OPERATION` or `glUniform4f`. The inspected frame SHA-256 is +`6a0395cbc7f5ba6dc39334e488bcaddc11afeb9c51a5e5e26b85e776e50c83dd`. +The stable pointer remained `successor-alpha@cdab7dccacc1d75c`. + +The 2026-08-04 modular-building release adds literal authored ceiling-name +selection to the Rust cutaway classifier and includes the integrated +framebuffer-anchored UI and ticketed-launch corrections. The standalone Rust +client verification, zero-allocation, runtime, render, terrain, and no_std +gates passed. The release build initially exposed two source-tree integration +defects: the model corpus included four preserved Blender authoring files, and +the public shim retained the new creator-mode query read. The release source +now excludes only those authoring files from runtime-model parsing and replaces +creator mode with `false` in production artifacts; the final immutable build +passed both release checks. + +The exact client identity was appended to the authority allowlist through AWS +Systems Manager and `successor.service` was restarted. Public readiness +returned every check true. An authenticated 1440 by 900 `/beta/` journey +selected `Beta-Rook`, loaded the exact immutable entry, visibly rendered the +live world, and submitted movement. Accepted commands advanced by 11 while +rejected commands remained at 823; the session returned to zero after the +browser closed. The inspected post-movement frame SHA-256 is +`1375aab88eb4ba3570c6d086679b7a372192e75234bc41e2b43e156f3fa26efb`. +The existing beta exit-control defect remains: clicking Exit hid the control +but left the iframe connected, so the proof closed the browser tab to retire +the session. The stable pointer was not moved. + ## Authority and durable state The running authority is: - runtime source commit: - `81dd217365b5b18ea62e467e50e063724937a0dd` + `b99dfb6b5f5ee64425f9d2f792d6170ae4c0e48b` - immutable image: - `595529182031.dkr.ecr.us-east-1.amazonaws.com/successor-staging-1/server@sha256:e461648246084787e2985413b8ef6005e829d10873baa0a09fcb35a6a369166d` + `595529182031.dkr.ecr.us-east-1.amazonaws.com/successor-staging-1/server@sha256:40530c1343122b9f67cbe168ff19c00bb02b3115a36368417c4f8b85191e39f7` - state-generation release: `b9262b21a1c8f51d146a9006188d62794456f3fb` - state-generation id: @@ -99,21 +469,20 @@ The runtime source and the state-generation release intentionally differ. This deployment repaired the container and persistence behavior without resetting or restamping the live state domain. -Immediately before and after the controlled restart, the restored Rust state -hash was identical: - -```text -f52af9c24ca696a304f4f1f9a98d91d9baf7ed08931be071c75f516ccde2899b -``` - -The final backup is: +Immediately before and after the controlled replacement, the restored Rust +state hash was identical: ```text -s3://successor-backups-5a537a77/state/successor-20260730T031511Z.tar.gz +c485c797c0bdb80bd78f22185f37a1aa54306bf343702f6cf0de86265cac45ad ``` -Its SHA-256 is -`75fb0ae57e4d2f8ae50393ee5b93817ec8190b6701d3035e7b3a31da15f41a3`. +The pre-deployment immutable backup is +`s3://successor-backups-5a537a77/state/successor-20260802T214820Z.tar.gz`, +825,552 bytes, SHA-256 +`fd48d72c407dbf00ef5ac681d19edaad46dbdd75e85062353fd3e726135b291a`. +The rollback image remains the prior digest +`sha256:e461648246084787e2985413b8ef6005e829d10873baa0a09fcb35a6a369166d`; +the stable client pointer was not moved. The live state then advanced normally as the browser and native proof characters entered the world. The mutable `persistence.stateHash` is therefore an observation, not a deployment identity. diff --git a/docs/CURRENT_PROJECT_STATE.md b/docs/CURRENT_PROJECT_STATE.md index 4f92dc1b..dcfa5bf0 100644 --- a/docs/CURRENT_PROJECT_STATE.md +++ b/docs/CURRENT_PROJECT_STATE.md @@ -1,7 +1,13 @@ # Successor Current Project State -Status: current implementation inventory after the 2026-07-29 public-alpha -release. Exact public hashes and pointers live in `CURRENT_DEPLOYMENT.md`. +Status: current implementation inventory as of 2026-08-04, after the +2026-07-29 public-alpha release. Exact public hashes and pointers live in +`CURRENT_DEPLOYMENT.md`. + +Work in progress on `integration/rust-ui-runtime-20260803` is not described +here as shipped. That branch's state, its open defects, and the reasoning +behind them live in `HANDOFF_SUCCESSORGAMEWORKAUG4.md`. Nothing from it is +merged to `main` or deployed. ## What is real now @@ -15,8 +21,8 @@ The current release includes: - full-viewport browser play with no page content below the game; - a parent-owned exit/full-view control and ordinary visible cursor; - automatic keyboard focus, Enter-to-chat, and clean character switching; -- exact creator appearance through roster, Rust checkpoint, relog, and world - rendering; +- exact creator body, face, hair, and color appearance through roster, Rust + checkpoint, relog, and world rendering; - local, zone, and global chat in the graphical HUD and terminal client; - in-place world travel with inventory, equipment, skills, professions, vitals, credits, and appearance continuity; @@ -35,14 +41,9 @@ characters. ## Supported repository shape -There is one canonical checkout: - -```text -~/dev/games/successor -``` - -It is on `main`, which is the only local long-lived branch and the only -registered Successor worktree. The supported components are: +The supported repository shape is defined by project-relative paths and does +not depend on a checkout location, branch name, workstation, or worktree +layout. The supported components are: | Path | Role | | --- | --- | @@ -51,16 +52,100 @@ registered Successor worktree. The supported components are: | `client/` | Shared networking, commands, chat, state projection, audio rules, and headless automation | | `server/` | Account boundary, tickets, rooms, chat routing, persistence projection, and Rust bridge | | `crates/successor-sim/` | Deterministic gameplay authority | -| `crates/successor-{core,inventory,net,wasm}/` | Shared Rust contracts and primitives | +| `crates/successor-{core,inventory,movement,net,wasm}/` | Shared Rust contracts, collision kinematics, and primitives | | `desktop/` | Electron packaging and isolated local-authority lifecycle | | `site/` | Marketing, account, launch, legal, roadmap, and download presentation | | `ops/deploy/` | AWS infrastructure and immutable release/operator scripts | +| `client-rust/` | Rust native/WebGL2 client workspace; source now includes the independently published `/beta/` WebGL2 route and release tooling, while native remains development-only | There is no supported 2D game client. `client/` has one headless entry point and contains no visual runtime; graphical presentation belongs to `client-3d/`. The checked-in slice and map bundle are renderer-neutral authority inputs, not an old 2D game. +The standalone Rust client now runs the same authority-driven game runtime on +native GL and WebGL2. Both targets consume the checked-in open-desert slice, +props mapping, PawnForge bodies and equipment, streamed actors, structures, +doors, extractors, camps, corpses, farms, clock, weather, receipts, compact +acks, and combat events. The client submits typed `successor-net` commands. +Continuous input uses wall-time cadence, bounded stop retries, an independent +movement lane, and fixed-step prediction/replay against the same +`successor-movement` swept-circle solver used by Rust authority. Applied +movement command identity drives reconciliation; actor interpolation adapts to +observed packet cadence. These remain projections of server and Rust-sim +authority rather than a client-side gameplay fallback. + +The connected presentation uses a locked north-up orthographic camera, live +terrain and props, complete pawn/equipment routing, environment grading, +weather, bounded effects, immediate-mode HUD/windows, dock and toolbar, +datapad, macros, options, and redacted bug reporting. Native connected audio +uses the platform mixer. WebGL2 consumes the same `ConnectedScene`, requires a +strict one-use launch envelope, fetches stable-id assets fail-closed, unlocks +audio only after a gesture, renders correctly at resized viewports and through +the forced RGBA8 fallback, and rebuilds GPU resources after WebGL context loss +without resetting the live network session or authority projection. + +The renderer loads the versioned +`client-rust/assets/render/settings.json` Low/Medium/High presets and exposes a +Backquote graphics-mastering overlay through the existing immediate-mode UI. +Lighting, shadows, AO/emissive response, bloom, FXAA, exposure, color grade, +and palette quantization apply live. Native save uses atomic replacement; +reload validates completely; web applies the checked-in document read-only. +The current visual proof inspected all three pages, changed lighting and color, +observed distinct pixels, saved/reloaded the override, and restored the exact +checked-in defaults. + +### World-light source status + +The Rust client source models two world-light inputs: explicit +`KHR_lights_punctual` point lights authored in GLB documents, and conservative +point lights inferred from real emissive geometry. Inference requires effective +non-black emissive data and a strict marker token (`glow`, `lampglow`, `sconce`, +`pendant`, `chandelier`, `beacon`, `bulb`, `flame`, `ember`, `campfire`, or +`hearth`); generic `light` names are insufficient. Shared-index emitters are +clustered within 0.25 m, large emitters are partitioned into deterministic 2 m +cells, tiny flux is suppressed, and each model is capped at 64 retained lights. +Explicit lights are authoritative. Static placement is node-global × +anchor-local; animated placement uses the skeleton bone global × local. + +Local exact-source verification is complete; this change has not been published +or promoted. `model-check` scanned 2,577 models and 19,138 primitives and +reported 631 emissive candidates, 557 retained/inferred lights, 74 low-flux +suppressions, zero light errors, and zero over-budget models. Byte-identical +fixture assets showed localized warm `BK_lampglow` spill at the modular starter +home, cyan/amber `CF_Glow` at the clone terminal, and separated facility pools +in native GL and WebGL2 HDR/RGBA8. The renderer selects at most 256 point lights +per camera and 32 per transparent draw; light entities follow the owning props +loader, streamed-model pool slot, actor/equipment presentation, or rigid weapon +attachment. Generated point lights do not cast shadows, so conservative +range/flux bounds intentionally limit wall bleed. + +Native developer builds retain the explicit loopback-only `successor-control` +path. Nine connected journey groups and nine deterministic input replays cover +the live command/window families; focused failure journeys cover launch, +release/source/schema, packet, receipt, reconnect, chat-loss, required-asset, +command-rejection, and authority-shutdown behavior. Chromium proof covers live +entry, commands, selection/combat rejection, permanent windows, zoom, desktop +and resized layouts, gesture-gated audio, forced half-float fallback, and +context loss/recovery. A matched legacy `client-3d` frame confirms the same +north-up fixture composition and authority state. + +The source now promotes WebGL2 as an opt-in beta without replacing the stable +clients. The site has a real `/beta/` route and an independent +`/beta/release.json` pointer; ticket minting and redemption bind stable and beta +release ids end to end. Public Rust artifacts exclude URL launch capabilities, +carry exact storefront/game/chat/client/server identities, and emit a +deterministic hashed inventory. The native Rust build remains unshipped and the +download ledger is unchanged. + +The stripped native and WebAssembly artifacts remain subject to the 6 MiB and +4 MiB absolute caps. Standard runtime and connected frame loops must retain +zero steady-state allocations, and runtime, terrain, render, and RSS gates +remain below their absolute budgets. This source status does not claim that a +beta pointer, server allowlist, site release, or public player journey has been +promoted; those volatile identities belong only in `CURRENT_DEPLOYMENT.md`. + + Source assets and generated runtime assets have separate homes. PawnForge source work remains outside this repository; promoted GLBs, face atlases, audio, and the generated world bundle are checked against manifests and @@ -118,9 +203,9 @@ terminals in both areas. Runtime characters cannot claim an authored actor id. The fixture and map bundle are byte-stable generated source: - slice SHA-256: - `69a19db8289b0d4711ccca5d4febef39b8dcd2ef662f9f70539935e49af8680e` + `c21a81d9e511fa35d059a51eeb5ffc9f60d12b6d6cc63161930befb733bb5e2d` - map-bundle SHA-256: - `01df5d1d178a8199b5bbd62f7e2107f017f5ae2ba1ca45081bb0ecdbb8f65795` + `260687cb95cee5f783e134e3a401f7265d05f0403307fcd6b4283cec2f281cb2` The authority and clients have working foundations for roster/appearance, inventory/equipment, professions, combat and life state, surveying and @@ -132,8 +217,9 @@ or presentation is finished. ## Character and durability contract The file-backed `successor.character-store.v2` record owns roster/profile -metadata, complete appearance, bounded character records, and the one-way -first-entry marker. Rust owns durable gameplay state. The checkpoint contains +metadata, complete appearance including a canonical `male`/`female` body route, +bounded character records, and the one-way first-entry marker. Rust owns +durable gameplay state. The checkpoint contains inventory, equipment, position, vitals, profession and skill state, credits, recipes, and other actor progress. @@ -210,7 +296,11 @@ picker or require a second click. Opening `/play/` directly still presents the picker as a recovery path. This source behavior is not public until a new site release is promoted. -The face compositor uses a runtime-cut skinned overlay from the PawnForge body +The creator exposes the two canonical PawnForge humanoid bodies. The persisted +choice deterministically selects `adventurer-premium-male` or +`adventurer-premium-female` in the authority, browser client, and native client; +both share the same 50-joint/47-clip runtime contract and fixed starter outfit. +The face compositor uses a runtime-cut skinned overlay from the selected body mesh. It shares the body skeleton and renders exact stored styles/colors in world pawns, roster dolls, paper dolls, and portraits. @@ -219,23 +309,29 @@ movement, chat, combat narration, vitals, radar, inventory/crafting language, and automated journeys. The website preview uses a real 80x24 TUI fixture. There is no public TUI archive yet. -## Repository recovery and retained plans +The Rust pregame entry, roster, creation, and loading surfaces are implemented, +but native account entry is not. In a `dev-tools` build, `--demo pregame` +drives those screens with a simulated roster. The production-capability native +binary still requires `--launch-context`; it has no callsign/password session +client and does not fetch `/alpha-api/characters` or mint its own play ticket. +The ticketed connected runtime itself is live-proven. Continuation details and +the exact account boundary are in +`HANDOFF_NATIVE_FIRST_ENTRY_20260807.md`. -The 2026-07-28 cleanup reduced 210 registered worktrees to the one canonical -checkout. Every old ref, worktree tip, dirty patch, non-reproducible payload, -and the corrupt-checkout raw tree was preserved before removal at: -```text -~/dev/releases/successor-preconsolidation-20260728T1811-MDT -``` +## Repository recovery and retained plans -The verified `successor-all-refs.bundle` and `SHA256SUMS` are the recovery -boundary. Do not recreate the full worktree farm; extract one named item into a -temporary worktree and reevaluate it against `main`. +The 2026-07-28 repository cleanup preserved old refs, dirty patches, +non-reproducible payloads, and a corrupt-checkout raw tree in an external +pre-consolidation archive. The verified `successor-all-refs.bundle` and +`SHA256SUMS` identify that recovery boundary. Its storage location is +environment-specific and must be supplied explicitly when recovery is needed. +Do not recreate the former worktree farm; extract one named item into an +isolated checkout and reevaluate it against the current source tree. Eleven useful Creative Wave briefs remain under `docs/future/` with explicit non-current status. They were not deleted. The unfinished property/farming -iteration remains excluded from `main` as archive commit +iteration remains excluded from the current source as archive commit `fa7a9977200f1e668e26a04a08a24e4e654eca94` and `property-farm-wip.patch` in the recovery archive. It contains partial land-claim, parcel, starter-seed, server projection, and farm-HUD work; it is @@ -247,17 +343,20 @@ The next work should be chosen from these actual gaps: 1. Split `server/src/game/shard.ts` behind stable, tested domain boundaries. This is maintainability work, not a rewrite of gameplay authority. -2. Build new graphical/TUI packages from an accepted client release and prove - hosted device authorization before repopulating the download ledger. -3. Run an independent isolated restore rehearsal of the latest complete +2. Wire the existing Rust pregame surfaces to real native callsign login, + roster, creation, and play-ticket acquisition without creating a second game + runtime or credential path. +3. Build new graphical/TUI packages from accepted client releases and prove + hosted authorization before changing the download ledger. +4. Run an independent isolated restore rehearsal of the latest complete pre-reset archive. -4. Repair the public `/current.json` CloudFront route, which currently returns +5. Repair the public `/current.json` CloudFront route, which currently returns 403 while the authenticated S3 pointer remains correct. -5. Deliberately rework the archived property/farming changes or leave them +6. Deliberately rework the archived property/farming changes or leave them archived; do not merge the old patch as if it were finished. -6. Continue real game content, balance, onboarding, performance budgets, and +7. Continue real game content, balance, onboarding, performance budgets, and focused visual gates. The systems breadth is ahead of the playable content. -`main` may contain documentation or site changes newer than the deployed +The development source may contain documentation or site changes newer than the deployed authority. Built, published, promoted, and player-verified states must always be reported separately. diff --git a/docs/HANDOFF_NATIVE_FIRST_ENTRY_20260807.md b/docs/HANDOFF_NATIVE_FIRST_ENTRY_20260807.md new file mode 100644 index 00000000..a301fdf5 --- /dev/null +++ b/docs/HANDOFF_NATIVE_FIRST_ENTRY_20260807.md @@ -0,0 +1,351 @@ +# Native Rust first-entry handoff — 2026-08-07 + +Status: continuation handoff for `integration/rust-ui-runtime-20260803`. +This document is scoped to native callsign login, roster, character creation, +and ticketed world entry. Architecture, current implementation, deployment, +and proof commands remain owned by `CANONICAL_CONTEXT.md`, +`CURRENT_PROJECT_STATE.md`, `CURRENT_DEPLOYMENT.md`, and `VERIFICATION.md`. + +## Resume point + +- Branch: `integration/rust-ui-runtime-20260803` +- Runtime baseline before this handoff-only change: + `d4bf5882` (`4b659e80` is the last runtime source change; `baa6bc4b` and + `d4bf5882` are deployment records). +- The branch is not merged to `main`. +- Do not restart from `dev/rust-client`, `hosted-direct-launch`, or an archived + ref. Their useful work is already reachable from this branch. +- Stable browser remains `successor-alpha@bd1396cdc9c12496`. +- Opt-in Rust WebGL2 beta is + `successor-rust-beta@4b659e80f9ea2f65`, manifest + `f83a1a1054d112cd0be0e5285ccf70253768c1db8e6f4e4c86679fe3c8ef0490`. +- Production authority image is + `sha256:47abcfe6c86b090553093dc3375d164571f719d0952c8e0cb871182d8394ecb5`. + +Read, in order: + +1. `docs/CANONICAL_CONTEXT.md` +2. `docs/CURRENT_PROJECT_STATE.md` +3. `docs/CURRENT_DEPLOYMENT.md` +4. `docs/VERIFICATION.md` +5. this document + +## User-visible target + +Running the shipped native binary with no development flags must open the real +Successor entry flow: + +1. callsign + masked password; +2. authenticated character roster; +3. existing-character selection or the current two-stage creator; +4. one-use play-ticket minting; +5. `LaunchEnvelope` validation and entry into the existing connected runtime. + +Acceptance is end-to-end behavior, not a new demo mode: + +- `make -C client-rust native`, then + `(cd client-rust && ./out/bin/successor)`, opens the account screen without + `--launch-context`. +- Invalid credentials return to the entry screen with bounded status copy. +- A valid account loads only its authoritative character rows. +- Selecting a character mints fresh game/chat tickets and reaches the hosted + world through `connected::run_launch`. +- Creation posts the exact current character contract, refreshes the roster, + selects the returned stable id, then can enter the world. +- Passwords are masked, never persisted, never logged, never included in bug + reports or control snapshots, and cleared after the login attempt completes. +- Raw endpoint/player identity remains available only in a `dev-tools` build + with `--dev-identity`. It is not an authentication fallback. +- The stable browser client and current `/beta/` web launch remain unchanged. + +Account registration is not part of this continuation. New accounts can still +be created on the same-origin website; the native target is login, roster, +creation, and play. + +## What already exists + +### Pregame presentation is not lost + +Commit `4ddb4d89d3f9eb180a600a32da40ea71da05ecf1` added the Successor-owned +pregame port and is an ancestor of this branch. The retained implementation is: + +- `client-rust/source/app/src/screens.rs` + - `EntryScreen`: fixed 330x200 entry composition and connecting modal; + - `CharacterScreen`: roster, profile, identity, status, and stable-id + selection; + - `LoadingScreen`: transition presentation; + - `ScreenAction`: host-facing intents. +- `client-rust/source/app/src/hosted_creator.rs` + - bounded roster projection; + - canonical name/profession/body mapping; + - default current appearance payload; + - pending-create/result/roster fence. +- `client-rust/source/app/assets/ui/` + - `PT_Sans-Web-Bold.ttf`, license, icon atlas, icon metadata; + - nine generated UI textures under `generated/`. + +The UI is exercised by `--demo pregame` in a `dev-tools` build. That host is +explicitly presentation-only: it simulates connect, roster, create, and loading +transitions. + +### Ticketed connected runtime already works + +- `client-rust/source/app/src/net/session.rs` strictly parses + `successor.launch-context.v1`, rejects expired/invalid envelopes, and consumes + game/chat tickets once. +- `client-rust/source/app/src/main.rs::connected::run_launch` already passes the + returned client release to game and chat authentication. +- Commit `4de6eca385ce87ddd7d83591415dcd98d4d2eb77` fixed the game matchmake body to + the strict hosted shape `{gameTicket, release}`. +- The native websocket/TLS runtime has already reached + `wss://world.successorgame.com` on port 443. The missing boundary is account + acquisition, not world transport. + +### Existing HTTP and asset infrastructure + +- `client-rust/source/platform/src/native/http.rs` already implements native + TLS GET and JSON POST for assets/matchmake. +- It currently returns only a successful response body. It does not expose + response status/headers, accept arbitrary request headers, retain cookies, + decode every HTTP body framing mode, or set I/O timeouts. It is insufficient + for account sessions as-is. +- `client-rust/tools/boot-closure.mjs` derives the runtime asset closure from + canonical manifests. +- `client-rust/tools/web-release.mjs` materializes a self-contained immutable + Rust runtime with its manifest, packs, and standalone streamed assets. + +## Exhaustive search result + +The missing account wiring was checked across all local heads, remote refs, and +archive refs in the Successor repository: + +- No Rust callsign/password frontend exists in another Successor ref. +- `hosted-direct-launch` does not contain a hidden native account client. +- Password text under `client-rust/source/app/src` appears only in the bug-report + hygiene warning; there is no dormant login transport to merge. +- The current `EntryScreen` fields are deliberately development `endpoint` and + `player` fields even though their geometry came from the login capture. +- Ordinary native launch in `main.rs` still exits with + `ordinary launch requires --launch-context `. + +The external `~/dev/swgrewrite` checkout at +`720252fd0104cfd0ef7ec819c18c3ee9357d3cd8` is an oracle/capture harness, not a +second Rust game client. Its relevant records are: + +- `oracle/manifests/original-client-login-create-world.scenario.json` +- `oracle/manifests/original-client-login-existing-world.scenario.json` +- `oracle/manifests/original-client-ui-matrix.json` +- `oracle/manifests/oracle-baseline.json` + +Those raw references are intentionally not copied into this repository or its +handoff bundle. `docs/adr/ADR-0001-source-isolation-and-spec-handoff.md` requires +external references to remain outside product source. The implementation and +Successor-owned generated UI assets above are the build inputs. + +## Exact hosted account contract + +The browser implementation is the executable reference: + +- request client: `site/src/api/client.ts` +- types: `site/src/api/types.ts` +- server routes and schemas: `server/src/alpha/http.ts` +- full local account-to-world recipe: + `tools/verification/standalone/live-runtime.mjs` + +Use same-origin production base `https://www.successorgame.com/alpha-api`. +Cookie mutations require JSON plus the origin/session/CSRF boundary used by the +browser: + +```text +Origin: https://www.successorgame.com +Sec-Fetch-Site: same-origin +Content-Type: application/json +Cookie: __Host-successor_session= +X-CSRF-Token: +``` + +Never print either opaque value. + +### Login and roster + +1. `GET /alpha-api/csrf` + - response: `{csrfToken, authenticated}`; + - captures the `__Host-successor_session` pre-auth cookie. +2. `POST /alpha-api/login` + - body: `{callsign, password}`; + - sends the pre-auth cookie and CSRF token; + - captures the rotated authenticated session cookie; + - 401 `invalid_credentials` is the expected bounded rejection. +3. `GET /alpha-api/characters` + - sends the authenticated cookie; + - response is `{characters: Character[]}` from `site/src/api/types.ts`. + +Project rows to `RosterEntry` by stable `id`; never derive identity from the +display name. `CreatorCharacter::roster_entry` already owns the safe body and +profession projection pattern. + +### Character creation + +`POST /alpha-api/characters` is strict: + +```json +{ + "name": "Mara Voss", + "initialProfessionId": "scout", + "appearance": { + "body": "female", + "skinTone": "#c78f62", + "hair": "hair_mop", + "hairMat": "hair_raven", + "face": null + } +} +``` + +`HostedCreatorFlow::begin_create` already validates the visible name, maps the +screen vocation to the canonical profession id, and creates this current +default appearance. Reuse that logic, but do not POST its outer hosted +`successor.creator.create.v1` message envelope: the HTTP route accepts only the +nested strict character object. + +Current authority limits are five slots and 16 characters in a name. +`CharacterScreen::ROSTER_CAP` is eight and its raw text fields are wider because +they reproduce the presentation. The native host must use server limits and the +existing canonical-name validator rather than treating screen capacity as +authority. + +Lineage, build, and tutorial controls are presentation state today. Do not add +new server fields or a second character schema while wiring this flow. + +### Ticket and world entry + +`POST /alpha-api/play-ticket` with authenticated cookie + CSRF: + +```json +{ + "characterId": "", + "clientReleaseId": "" +} +``` + +The response contains `gameTicket`, `chatTicket`, `characterId`, `expiresAt`, +`endpoints`, and `release`. The server response omits `schema`; the browser adds +`"schema":"successor.launch-context.v1"` before handing it to the client. +Native must do the same, then call `LaunchEnvelope::from_json` and +`connected::run_launch`. + +Do not omit `clientReleaseId`: production would otherwise mint the server's +default stable-browser identity for a different client. The currently +allowlisted Rust identity is `successor-rust-beta@4b659e80f9ea2f65`, which is +valid only for the content-identical `4b659e80` runtime used for the current +smoke. Any account-client code change requires a newly built/published release +identity and an explicit authority allowlist update before live testing. Never +hardcode an old release id into a changed binary. + +## Recommended implementation cut + +1. Replace the production meaning of `EntryScreen`'s endpoint/player fields with + callsign/password and replace its dev-specific `Connect(JoinOptions)` intent + with an account-login intent. Raw dev joins already bypass this screen. +2. Mask password rendering without copying the password into status or debug + strings. Clear the field after the worker returns. +3. Generalize native HTTP into one bounded request/response primitive that + exposes method, status, headers, and body. Preserve existing asset and + matchmake callsites. Support the response framing actually exercised by the + local standalone server and production edge; do not add speculative retry + behavior. +4. Put account I/O on one background worker and return typed results over a + channel. Do not block the render/input frame on DNS, TLS, password verify, or + ticket minting. +5. Turn the current `run_pregame` presentation host into a reusable controller + or extract its render/input loop. The production controller owns only: + login pending/error, roster, create pending/error, ticket pending/error, and + the final validated envelope. +6. Feed creation through the existing canonical mapping and pending roster + fence. Refresh `/characters` after a successful create and select the new + server id. +7. On ticket success, leave the pregame window cleanly and enter the existing + `connected::run_launch`; do not fork a second game loop. + +Before changing exported screen actions or fields, use LSP references. Migrate +every caller and test; do not leave a dev alias beside the account contract. + +## Verification required before calling it done + +Focused contracts: + +- HTTP parser covers status, `Set-Cookie`, content length/chunked body framing, + request headers, and bounded error bodies. +- Account result types reject malformed/unbounded server data. +- Password never appears in `Debug`, errors, control JSON, screenshots, or a + persisted settings file. +- Entry, roster, create, cancel/back, retry, and stable-id selection transitions + have behavior tests. +- `LaunchEnvelope` receives the exact play-ticket response plus added schema and + still clears both tickets after consumption. + +Runtime proof: + +1. Start the documented standalone authority with a disposable account. +2. Build the production-capability native binary, not only `successor-dev`. +3. Exercise wrong password, valid login, empty roster, creation, roster refresh, + selection, play-ticket, `game.hello`, and `chat.hello`. +4. Restart with the same account and prove the created character persists. +5. Only after a new release id is published and allowlisted, repeat the signed-in + path against production. The 2026-08-07 Rust beta promotion has not yet had a + signed-in public-account launch. + +Minimum repository gates for this slice: + +```sh +make -C client-rust test-unit +make -C client-rust native +pnpm verify:successor-context +pnpm check:commands +pnpm denylist +``` + +The smoke test is the native binary reaching the real roster and world; a unit +suite alone is not delivery proof. + +## Asset and bundle inventory + +Normal continuation is a clone/fetch of the integration branch followed by +`git lfs pull`. All 2,933 tracked LFS paths are part of the branch contract. +The handoff prepared alongside this document contains: + +- a Git bundle for the integration ref; +- a materialized tracked-source archive (LFS payloads, not pointer text; + ignored `client-rust/target` and other build caches excluded); +- the untracked `.site-captures/` evidence as a separate optional archive; +- SHA-256 checksums and a machine-readable manifest. + +The `.site-captures/` tree is runtime/site evidence, not a build dependency. It +is kept separate so the repository does not gain 106 MB of raw BMP evidence. +The product media selected from it already lives under `site/public/media/` and +is tracked through LFS. + +For a fresh normal checkout: + +```sh +git switch integration/rust-ui-runtime-20260803 +git lfs pull +pnpm install --frozen-lockfile +make -C client-rust native +(cd client-rust && ./out/bin/successor) +``` + +If an asset is missing, fix the LFS checkout or the canonical manifest. Do not +add path fallbacks, placeholder geometry, or a second asset root. + +## Do not mix into this continuation + +- Do not replace or move the stable browser pointer while proving native login. +- Do not reuse a stable-browser release identity for the native client. +- Do not implement credential persistence, password reset, native registration, + or retry policy as incidental scope. +- Do not copy raw oracle material into product source. +- Do not treat `.site-captures/` as runtime content. +- Do not report the signed-in production path as verified until it is exercised. +- The reconnect loop noted in the older August 4 handoff remains separate work; + account entry must not be hidden behind reconnect changes. diff --git a/docs/HANDOFF_SUCCESSORGAMEWORKAUG3.md b/docs/HANDOFF_SUCCESSORGAMEWORKAUG3.md new file mode 100644 index 00000000..eb26d12d --- /dev/null +++ b/docs/HANDOFF_SUCCESSORGAMEWORKAUG3.md @@ -0,0 +1,127 @@ +# Successor game work handoff — 2026-08-03 + +Status: development-source handoff. This note does not override +`CANONICAL_CONTEXT.md`, `CURRENT_PROJECT_STATE.md`, `CURRENT_DEPLOYMENT.md`, or +`VERIFICATION.md`. + +OMP session name: `SUCCESSORGAMEWORKAUG3`. + +## Resume point + +- Development branch: `dev/rust-client` +- Integrated source tip before this documentation-only handoff commit: + `d72692bc8c2a2683ea5321189c31b0f6cb1a12bc` +- Review surface: +- PR base: `main` +- PR state at handoff: open, cleanly mergeable, with all five required GitHub + checks passing +- `main` was not merged or deployed as part of this work + +The integration landmarks immediately below the handoff commit are: + +- `d72692bc` — hydrate tested LFS assets in CI +- `f2f040bd` — stabilize CI integration and record humanoid source provenance +- `2c486e51` — reconcile concurrent `dev/rust-client` changes +- `b1cdd622` — support macOS durability locks and refresh runtime gates +- `c0cdb35a` — integrate the native client, redesigned world, and humanoid assets + +## Integrated work + +The branch combines the previously parallel development lanes: + +- streamed and skinned GLB rendering in the Rust client; +- routed movement, cutaways, doors, interiors, and current-world presentation; +- the 1,024-cell desert/forest fixture, sparse population, safe starting region, + Dustgate facilities, travel, and authority parity; +- modular runtime buildings, collision sidecars, and LFS-backed building assets; +- canonical male and female PawnForge bodies, creator/body persistence, fitted + wardrobe assets, runtime animation, and production packaging; +- portable desktop durability locking and packaged verification on macOS; and +- CI, provenance, LFS hydration, and cross-platform test repairs needed by the + combined branch. + +Read the canonical and current-state documents for exact active contracts. Do +not treat this summary as a new runtime specification. + +## Asset availability + +All Git LFS objects reachable from the branch were published to GitHub: + +- 2,979 objects processed; +- approximately 1.1 GB of LFS content; +- a fresh HTTPS clone of `dev/rust-client` completed `git lfs pull`; and +- the independent checkout returned `Git LFS fsck OK` at `d72692bc`. + +A collaborator with repository access and Git LFS installed can start with: + +```bash +git clone --branch dev/rust-client https://github.com/LycaonLLC/successor.git +cd successor +git lfs pull +pnpm install --frozen-lockfile +``` + +For an existing checkout: + +```bash +git fetch origin +git switch dev/rust-client +git pull --ff-only +git lfs pull +``` + +Do not use ignored lab build directories as source assets. Versioned runtime +assets and source manifests are the repository contract. + +## Verification recorded at the integration tip + +GitHub Actions passed on `d72692bc`: + +- `denylist gate` +- `cargo test` +- `cargo test (wasm)` +- `pnpm test` +- `provenance audit` + +Focused local proof also passed for the promoted male/female boot fit contract, +humanoid source provenance, desktop packaging/runtime, shared-client tests, and +a clean fresh-clone LFS integrity check. + +Before handing off any further source change, follow `VERIFICATION.md` and run: + +```bash +pnpm run ci +pnpm hygiene:rust +``` + +Changes under `client-3d/` or shared `client/src/` also require: + +```bash +pnpm desktop:build +``` + +## Publication state + +Keep these states separate: + +- **Source published:** yes, on `origin/dev/rust-client`. +- **LFS assets published:** yes, and fetched from a fresh clone. +- **Branch CI:** green at the integration tip. +- **Merged to `main`:** no. +- **Promoted to the public game:** no claim. +- **Authenticated public player journey for this branch:** no claim. + +Use `CURRENT_DEPLOYMENT.md` and direct endpoint observation for public-release +identity. A newer development branch does not prove a deployment. + +## Next safe actions + +1. Fetch `dev/rust-client` and run `git lfs pull` before inspecting visuals or + running asset-dependent tests. +2. Recheck the PR head and CI because another developer may have advanced the + branch after this note. +3. Continue work from the branch tip; do not recreate the former worktree farm + or import ignored generated builds. +4. Run focused checks while iterating, then the repository gates above. +5. Treat a merge to `main`, beta/stable pointer promotion, authority restart, + or public deployment as a separate explicitly authorized operation. diff --git a/docs/HANDOFF_SUCCESSORGAMEWORKAUG4.md b/docs/HANDOFF_SUCCESSORGAMEWORKAUG4.md new file mode 100644 index 00000000..4ddb1af9 --- /dev/null +++ b/docs/HANDOFF_SUCCESSORGAMEWORKAUG4.md @@ -0,0 +1,521 @@ +# Successor game work handoff — 2026-08-04 + +Status: development-source handoff. This note does not override +`CANONICAL_CONTEXT.md`, `CURRENT_PROJECT_STATE.md`, `CURRENT_DEPLOYMENT.md`, or +`VERIFICATION.md`. + +This is a historical wave record. For the current native login/roster/creator +continuation, start with `HANDOFF_NATIVE_FIRST_ENTRY_20260807.md`; deployment +claims below were superseded by `CURRENT_DEPLOYMENT.md`. + +## Resume point + +- Working branch: `integration/rust-ui-runtime-20260803` +- Co-developer branch: `dev/rust-client`, fully merged in, nothing outstanding + in either direction at handoff +- Not merged to `main`. Not deployed. No public-journey claim. + +Waves are recorded newest first below. Wave 6 corrects the in-game verification +claimed for Wave 5: read it before trusting any Wave 5 runtime result. + +## Wave 2 — UI parity pass + +Everything below was exercised against a live loopback authority and a +connected `successor-dev` client, not only unit-tested. + +### Three things blocked local work and are now fixed + +- **`scripts/server-local-persistent.mjs` was Linux-only.** It discovered the + listener through `/proc/net/tcp` and `/proc//fd`, which do not exist on + macOS, so it started a healthy server, failed to find it, and SIGTERMed it. + Listener discovery is now platform-split: `/proc` on Linux, `lsof` on macOS, + same socket/process shape and the same ownership assertions on both. +- **The documented control binary could not be controlled.** `make -C + client-rust native` builds without `dev-tools`, so `out/bin/successor` + refused `--control-port` and `--endpoint`. There is now a `make -C client-rust + dev` target producing `out/bin/successor-dev`; the shipped binary stays + capability-free. +- **A durable character could not join from the native client.** The shard + rejects the bare `{playerId, actorId}` dev shape for any id its character + store owns (`colyseusRoom.ts:454`). The client takes `--character-id` and + sends `{characterId}` when given one. + +The local checkpoint also refused to restore, correctly: `a485288b` regenerated +the world slice for the door apertures, so the Aug 3 checkpoint belonged to a +different fixture. Backed up to `server/.local-state-archive-20260804T120839` +and reset. + +### Cursor + +The fixed screen-centre crosshair is gone. The original has no such reticle on +the ground HUD: aim lives on the pointer, and the cursor set switches to +`ui_cursor_attack` over a valid target. + +`engine-render/src/cursor.rs` draws the original's cursor vocabulary — +arrow, select, attack, interact, move, four resize axes, text, busy, blocked — +from the same primitives the window chrome uses, so the pointer is made of the +same material as the frames it sits on. No bitmaps, no atlas cell per +resolution; proprietary legacy cursor art stays host-local. + +The pointer is drawn last, after the composite bands, so no 3D viewer paints +over it. `Graphics::constrainMouseCursor` clips the hardware cursor to the +client rect; GLFW has no portable clip, so the equivalent is to hide the OS +pointer while it is inside the framebuffer and hand it back when it leaves. + +Shape resolution follows the original's rule — the mediator under the pointer +wins, and only when nothing owns the pointer does the world speak: resize edge +or caption first, then panels, then a hostile (attack, but only while something +is wielded), then a selectable actor, then a world verb prop under the pointer. + +### Windows + +**Title-bar dragging silently resized instead of moving.** The 8 px +`UIWidget::RESIZE_MARGIN` was applied to the whole frame rect, and a 19 px +caption minus 8 px of top resize band leaves a 3 px move sliver — so almost +every caption press landed on the top edge. In the original the caption is a +child page that takes the press and moves the frame; only the border resizes +there. `CAPTION_RESIZE_MARGIN` is 3 px inside the caption band. + +Measured over all eight dock windows through the control protocol: move +translates both axes with size preserved, bottom-right and top-left corner +resize both land exactly, close-and-reopen preserves the rect, and all 28 +registered frames survive a client restart with geometry, open state, and lock +state intact. HUD panes stay locked and gameplay-transparent until a right +click unlocks them, then move and resize like any frame. + +`status` now reports `window_frames`, so this is measured rather than eyeballed. + +### HUD panes + +The player status plate reserved 92 px for content that was often 59 px: a tag +band most sessions never carry, and a weapon readout that only exists while +something is wielded. State chips moved onto the name row, and the weapon +readout became its own pane. The plate is now exactly name + RUN + the three +pools. + +Weapon and group are managed panes of the same grammar as the target plate, +each painting nothing when it has no content — an unarmed solo player sees one +tight plate and no empty furniture. `HUD_SURFACE_COUNT` is 9. + +### Inventory + +Equipment slot rails frame the paperdoll — six cells left, five right, filled +slots carrying the item glyph, click to select and click again to unequip. The +column reserves the rail lanes before the doll claims the width; below a +96 px doll the rails drop and the portrait takes the column, which is what the +250x244 resize floor gets. + +Also: category filter, a three-way sort over the filtered rows, an inline split +stepper replacing the hardcoded 50 % split, and an honest held-stack count in +place of a meter that reported UI page slots. + +**One defect the filter work exposed:** the composited 3D item models were +keyed off `held()` while the cards drew from the filtered, sorted, paged order, +so every filter put models under the wrong labels. The preview lanes now +consume the same order the 2D layer drew. + +### Theme, transparency, sound, radar + +- Window surfaces carried hardcoded cyan ink, so a theme change recoloured the + HUD and left every workspace frame cyan. Ink resolves from + `hud::active_palette()`; one theme covers the whole UI. +- `WINDOW OPACITY` and `HUD OPACITY` settings, `0.35..=1.0`, persisted under the + `uiOpacity` section. Fills fade; type, icons, and the bright perimeter do not, + because a translucent glyph over terrain is unreadable. +- Thirteen `UiCue` variants existed and exactly two ever fired. Panel open and + close, deny on every rejection, item transfer, credits, area transition, chat + send and receive, and both toolbar cues are now wired at their event sites, + with a retrigger floor so a bulk transfer does not stack into a click storm. +- Radar: scale mismatch between plotting and clamping caused edge flicker, + `scope_of` did not subtract its own top inset so the scope escaped the pane on + resize, cardinals ignored camera heading, and there was no range control. + Fixed, with a 32/64/96/128/192-cell range ladder. + +### Wave 3 — measured hi-fi audit of all 30 surfaces + +`tools/observe/pane_gallery.py` opens every registered surface alone under two +themes and grades it. The first run said the UI was in worse shape than it +looked, and every number below is from that harness. + +**Panes were near-black slabs.** Every theme's `bg_panel` was around `#070b0d`, +so 80–94% of a window's own ink was near-black and a window read as a hole +punched in the world. The original's pane is a translucent tint under a bright +outline (`ui_options.inc` centre `#003848`). All four palettes were re-derived +from their accent and checked against WCAG AA at small-text size — ink clears +7.2:1, dim ink 4.6:1, accent 4.7:1 — and `every_theme_is_readable_and_tinted` +now fails the build if a future palette regresses either property. Oxide's rust +accent could not clear the bar against any warm pane and was lifted from +`#c44a26` to `#e0673a`. + +**Three widgets no theme could reach.** `ButtonStyle::default`, `text_field`, +`slider`, and `checkbox` were hardcoded slate-and-gold in `engine-render`, +which has no palette. They were the largest frozen surface in the UI, frozen +across chat, options, macros, the bug report, the inventory footer, the +character sheet, and the graphics tuner. The engine widgets now take their +chrome as a parameter and the app passes `hud::button_style()`. + +**The chat console was 0% themed** — a hardcoded near-black slab, the darkest +thing on screen and the only pane that never followed the theme. + +**Empty examine and converse windows were black rectangles.** A live 3D viewer +needs an unlit backdrop, but that was implemented by darkening the whole frame, +so a pane that is mostly viewer went black. `chrome::viewer_seat` now paints +only the viewer cell and the frame stays themed. + +Result across 30 surfaces: 26 are 98–100% themed with zero near-black and +contrast 6.6–10.5. The rest are explained, not outstanding — pool tints and the +composited paperdoll are identity and must not theme, and four HUD panes draw +nothing without a target, a weapon, a group, or a queued ability. + +**Not done:** the functional panes are still thinner than the web client's. +`survey` renders four resource rows where `client-3d/src/ui/windows/defs/ +surveyToolWindow.ts` renders a concentration heatmap and sample results; +`bank`, `craft`, `loot`, and `trade` are similarly behind +`client-3d/src/ui/`. Most of those gaps are UI-only — `WindowModel` already +carries the data — but they are a feature build, not a polish pass. + +## Equipment on both bodies — measured + +- 101 skinned wearables, each with a male and a female GLB. Female armour seats + within 0.36 mm of the male set. +- 17 rigid socket attachments. **Male and female mount positions are identical + to 0.000 mm** — both bodies share one 45-joint skeleton, so every socket + resolves to the same world point. Rigid equipment is equally correct on both. +- Five weapons bury their grip 64.9–66.2 mm in the hand **on both bodies**: + `wpn_smg`, `wpn_carbine`, `wpn_sniper`, `lightning_carbine`, `wpn_launcher`. + The earlier note flagged this for `wpn_carbine_kiln` alone; it is five mounts + sharing one copy-pasted pose. Re-posing affects how each weapon reads in the + hand, so it stays a deliberate art call, not a silent fix. +- `hat_field_cap` is the one genuine per-body rigid defect: 1.25 mm into the + male scalp, 10.7 mm floating off the female head. +- Hair is unchanged and still parked behind head sign-off: 30 male and 26 + female variants fail the scalp gate. + +`content-pipeline/labs/humanoid-runtime-refit/probe_weapons.py` is the new gate +for all of that; it writes `reports/rigid_mount_fit.json` and fails closed. + +## Known open + +- The five buried weapon mounts and the female field cap above. +- Hair refits, still behind head sign-off. +- **Doorways are better, not fixed.** Interior reachability went 7/7/5 percent + to 19/44/32 for starter/court/wing. The remaining seal is most likely the + 300 milli collision radius closing gaps under 0.6 cells against a 0.95 m wall + pitch. Measure with `collision_map.py` before carving further. +- **Floor sinking is not proven fixed.** The `enterableFloorYAt` port was + genuinely missing, but `floorHeightM` is 0.021 m for the cloning facility and + cannot explain waist-deep sinking. Something else is also wrong. +- **Doll render targets alias.** A 384x640 target composited into a ~76 px + column with no mipmaps breaks up at three-quarter angles. +- **No live combat exchange yet.** The weapon wields and the authority accepts + input, but no hostile was found in the ground covered. Spawn one deliberately + rather than hunting. The attack cursor is therefore unit-proven, not + journey-proven. +- Inventory still has no drag-and-drop and no sub-container navigation. + + +## Wave 7 — the native client could not hold an idle session + +An idle native client was disconnected within the Colyseus liveness window, +every time. It only stayed connected while something was actively driving it, +which is why the fault kept looking intermittent: an agent journey sending +control input kept the socket alive as a side effect, and a client left alone +for a few seconds died. + +`native/net.rs` handled a server Ping with a bare `WsEvent::None` and the +comment "tungstenite handles responder automatically". Tungstenite *queues* the +Pong; it only reaches the socket on the next write or flush. Outbound commands +flushed it incidentally, so a moving player survived and a standing one did +not. Colyseus then terminated the connection for missed liveness — an abrupt +reset with no close frame. `ws_poll` now flushes after a Ping, treating a +`WouldBlock` as normal backpressure and any other error as a transport failure. + +Two diagnostics that hid this are also fixed. Both dropped the cause on the +floor and reported only `reconnect 1/5`: + +- a server Close frame's code and reason are now printed (that is how + `1011 durable character entry failed` becomes visible); +- the tungstenite error behind `WsEvent::Error` is now printed. + +With those in place the failure reads directly: + + ws transport error: IO error: Broken pipe (os error 32) + ws transport error: WebSocket protocol error: Connection reset without closing handshake + +Verified: idle client held `game_connection: Connected` with the authority +reporting `sessionCount: 1` across 89 seconds and ~2,250 ticks, having +previously dropped within seconds of going idle. + +### Known open, found here + +`main.rs` prints `reconnect N/5` but never re-dials. The session FSM moves to +`Connecting` and the drain loop breaks, then the next frame polls the same dead +socket and counts another attempt, so five "attempts" are five polls of one +dead socket and the client then fails permanently. It matters less now that the +socket is not dying on its own, but a genuine server-side drop is still fatal +rather than recoverable. + +## Wave 6 — HUD anchors to the real framebuffer; Wave 5 proof corrected + +### The bottom band floated mid-screen on anything but 720p + +A `ConnectedScene` is built before its first framebuffer exists, so +`framebuffer` starts at the `(1280, 720)` placeholder. `load_persisted` ran +before any real frame and matched the saved layout's viewport stamp against +that placeholder: a 720p document looked authoritative, so its rects were +applied verbatim. The first real frame then set the live size but found no +pending HUD slots, so nothing re-anchored. On a 1600x1000 window the chat +console, notification strip, and ground radar all stayed pinned to `y=720`, +floating in the middle of the world. + +The decision cannot be made at restore time. `restore_window_layout` now +applies saved rects unconditionally and the scene records the document's +viewport stamp; `frame` compares that stamp against the real framebuffer and +re-anchors the band when they disagree. This covers first launch and a live +window resize through one path. + +`hud_surfaces_re_anchor_when_the_framebuffer_changes` passed throughout the +regression. It only ever drove `register_hud_surfaces_at` with an explicit +viewport — the one path production never takes. It now asserts the stamp +comparison directly and proves the stranded-mid-screen state before the +reconcile pulls the band back to the edge. + +Measured live through the control server's `window_frames`: + +| framebuffer | max HUD bottom | max HUD right | +|---|---|---| +| 1600x1000 | 992 | 1600 | +| 1728x1052 | 1044 | 1728 | + +The 1728x1052 case is the useful one: macOS clamped a `--size 1920x1200` +request down, and the HUD followed the framebuffer it actually got rather than +the one that was asked for. + +### Correction to the Wave 5 record + +Wave 5's code landed as described above, and the repository gates were green +for it. Its *in-game* verification was not real and should not be relied on: + +- The local authority's Rust bridge child had exited. The Node shard kept + serving a frozen world (tick stuck, `sessionCount: 0`) and closed every join + with `1011 durable character entry failed`, which the client discards without + printing. Restarting the client against that zombie shard cannot succeed, and + it was restarted many times instead of being restarted itself. +- The screenshots filed as proof of the inventory radial, the GR0K builder, and + the brightened buildings are three crops of the same PLANETFALL loading + screen. They show a client that never left `AWAITING WORLD SNAPSHOT`. + +A clean `node scripts/server-local-persistent.mjs` brings the bridge back; the +same client then reaches `app_mode: Connected` and streams the area. Before +trusting an in-game claim, check `/game/status` shows a rising `tick` **and** +`sessionCount: 1`, and that `successor-control status` reports +`game_connection: Connected` — a screenshot alone does not distinguish the +world from the loading screen. + +### Still unverified in-game + +Wave 5's radial menu, character builder, converse bust camera, and item +preview fill light are code-complete and unit-covered, but no longer carry an +in-game proof. They need a pass against a live shard. + +## Wave 5 — GR0K character builder, inventory radial, debug grants, item preview fill light + +### GR0K conversational debug character builder + +Replaces the legacy MMO `CharacterBuilderTerminal` ("blue frog") flow: + +- **Multi-select page navigation**: Root -> Packs / Item Categories / Professions / Credits -> Multi-select leaf list. A tester can tick several items, packs, or skills and commit them in one action via `GRANT SELECTED`. +- **Data-driven from runtime**: `tools/codegen/generated/debug-catalog.generated.json` (schema `successor.debug-catalog.v1`) is generated directly by `crates/successor-sim/src/bin/emit_debug_catalog.rs` probing `inventory_item_name` (173 items), `authority_skill_box_definition` (126 skills across all 7 professions), and curated packs. A `--check` flag is wired into the `run.sh --rust` hygiene gate so catalog drift fails the build. +- **Wire commands**: `ClientCommand::DebugGiveCredits { amount }` (kind 98) added across `successor-net`, `successor-sim`, `authority_bridge`, and server protocol schemas. Negative amounts drain the wallet. Skills commit as ONE `DebugGrantSkillBoxes` call with a list of IDs. +- **Dual debug gating**: `WindowAction::resolve` gates debug commands under `#[cfg(not(feature = "dev-tools"))]` in release builds; the server gates via `GAME_DEBUG_AUTHORITY_COMMANDS=1` on both HTTP and socket paths. Debug grants are exempted from `consumeIngressBudget` when the flag is enabled so multi-item packs commit without `ingress_budget_exhausted` drops. + +### Inventory radial menu & converse bust + +- **Inventory radial menu unblocked**: `connected_scene::handle_pointer` was swallowing right-clicks over interactive windows at `covers()` before the inventory radial branch ran. Inventory card right-clicks now resolve above the window swallow, opening the object radial with `UNEQUIP`, `EXAMINE`, `DISCARD`, `SPLICE`. Relabelled `DROP` to `DISCARD` to match the authority's `DiscardStack` semantics. +- **Converse NPC bust camera**: `connected_scene` camera calculation for the converse window turned the camera to follow pawn yaw (looking at the NPC's back when turned away). Converse viewer camera now dead-ons the NPC's front (`yaw + PI`), looking the speaker in the eye. Trainer detection range widened from 2.5 to 16.0 cells with `in_range` gating preserved for skill purchases. +- **Viewer seat transparency**: `VIEWER_SEAT` alpha lowered from 245 to 28 so 3D composite cells blend with the pane's translucent background instead of punching an opaque black rectangle through see-through windows. +- **Item preview fill light**: Added secondary fill light (`PointLight { color: [0.72, 0.85, 0.96], intensity: 6.0 }`) to `ItemPreviewRenderer` in `item_preview.rs` so unlit sides of 3D item models in inventory wells remain legible. + + +## Wave 4 - functional panes, HUD defaults, chat, radar + +### The thin panes were filled + +`windows/live.rs` was 2063 lines holding 20 surfaces, so no two people could +edit a surface at once. It is now one file per surface under `windows/live/` +sharing `live/shared.rs` (the pane cursor, the prose writer, the retained text +fields). On that footing five surfaces went from a stub line to the content the +web client has always carried: `bank` had rendered only its out-of-range notice +even while in range, `converse` only `NO DIALOGUE TARGET`, `survey` four +resource rows under a 70% empty pane. `craft` and `splice` now run their full +staged flows. + +`trade` mirrors the web `machine.ts` seal rules: any offer change clears both +seals, and the pane withdraws every mutating control the moment your side is +sealed, so a stale frame cannot emit a change the server has already sealed +against. + +### What the parity pass got wrong, and what caught it + +Worth knowing because the same failure will recur. A first pass produced 100 +lines of client-side trade state machine that nothing called, with 17 assertions +proving it - a green suite over dead code. It is deleted; the trade tests now +drive the real pane. Two more classes of defect only a screenshot caught: + +- 16 string literals used `-` and `·`. `hud::Icons` bakes ASCII 32..=126, so + they rendered as `?` in-game while every test passed. + `tools/hygiene/drawable_glyphs.py` is now a hard gate. Player-authored text is + deliberately out of its scope. +- `survey` drew two-line `label_caption` rows into one-line `next()` rows, so + each spawn's class line landed on the next spawn's name. + +### HUD defaults + +- The notification strip no longer opens on first run. It repeated the shard + state and zone name into the bottom-right corner over the world; it stays + registered and reachable. +- The target plate is flush top-right, mirroring the player plate's flush + top-left, wherever the twelve-slot command bar still fits between them. Below + that width it keeps its old lane under the bar rather than overlapping it. The + bar's measured 474 px is never narrowed - that would resize every slot. +- The radar lane is the scope plus the coordinate rail the radar itself draws. + It had been reserving a window caption and footer that a chromeless pane never + paints: 52 px of dead band around the instrument. +- Hotkey glyphs under the command bar lost their dark seats. Twelve chips in a + row read as a second broken bar under the real one. +- Body cap heights are 13/14/11 px against the original's 12/13/10. The original + was measured on a 1024x768 CRT; the 19 px row absorbs the extra pixel without + reflowing anything, and row height is deliberately NOT scaled with it. + +### Window chrome + +Frames carry a stepped corner bracket, traced from a generated reference sheet +into vector draw calls rather than shipped as art. Every corner now has exactly +one mark: bracket top-left and bottom-left, close control top-right, resize grip +bottom-right. The top bracket's ink follows the caption rail - dark caption ink +over the focused accent, edge tone over the unfocused rail - because one fixed +choice vanishes in the other state. The resize grip is three ticks and always +drawn; a grip you can only find by hovering is a grip players do not know exists. + +### Nameplates + +Two divergences from the web renderer, both fixed: + +- The plate was pushed 8 px BELOW its head anchor and top-anchored, where the + web lifts 24 px from a text baseline. It sat on the pawn's head. `y` is now + the baseline, `NAMEPLATE_SCREEN_LIFT_PX` mirrors the web constant. +- Relation ink followed the theme, so a grouped ally was cyan in one palette and + teal in another. + +Relation colour is now owner-ruled (2026-08-04) and this ruling outranks the web +client, which does not match it everywhere yet: + +| Read | Colour | +|---|---| +| NPC, and every corpse | white `#f8f7f1` | +| Passive attackable NPC | yellow `#f1d06b` | +| Passive that has been attacked | red | +| Aggressive attackable NPC | red `#d33b32` | +| Player, neutral | bright blue `#4aa9ff` | +| Player, same guild or faction | purple `#b066ff` | +| Player, PVP open | red | + +`RelationHud` variants were renamed to those reads - `Hostile`, `Attackable`, +`Social`, `Player`, `Allied` - because `Friendly` meaning "neutral player, blue" +is a trap for the next reader. Classification is by actor `role` against the +shared `actorRoleProfiles` table, plus `will_auto_aggro`, `in_combat`, +`pvp_status`, and the viewer's own `player_organization_id`. + +### Chat + +All four tabs were driven against a live authority. Filtering is correct. Three +defects found and fixed: + +- Every line was attributed to `char_0254efc180a54b6a`. The chat route defaulted + `displayName` to the user id before the hub saw it, so the hub's own lookups + never ran. The route now passes an empty fallback and the hub resolves the + name from the live shard, then the character store, then the raw id. + `ChatNameAuthority` is wired from `gameShard.chatDisplayNameForActor`. +- A `chat.error` marked the whole socket `Degraded`. It is a refusal of ONE + request - no guild, rate limited, moderated - and the socket is fine. It now + surfaces as a system line, which is also the only way the player learns the + message they typed went nowhere. +- An empty tab reported `CHAT DEGRADED`. An empty tab is a filter result; it now + names what is empty and reports the socket only when the socket is unwell. + +Global history survived a client restart, so channel persistence is confirmed. + +### Radar + +The scope was a flat disc with a crosshair and three unlabelled rings. It now +draws a terrain preview sampled from `world::terrain::sample_terrain` - the same +function the 3D terrain path uses - on a 32x32 grid cached until the player +crosses a cell, the zoom step changes, or the palette changes. Plus a world-cell +grid that slides as you move, and range rings labelled in metres at every step +of the existing 32/64/96/128/192 ladder. + +### Known open from this wave + +- `pnpm --dir client-3d build` fails on a pre-existing asset budget: + 295,462,367 bytes against a 292,000,000 limit, 504 of 510 files. No + `client-3d` file was touched in this wave. Raising the limit would hide it. +- The Rust client still sends no `displayName` in its chat handshake. The server + resolves it, so this is redundancy rather than a defect. +- Functional panes report their own NEEDS-PROJECTION gaps: survey concentration + interpolation and scan-disc history, datapad terrain bake and weather storms, + macro runtime state, trade item preview. +- The local authority's state-lock supervisor loses a restart race often enough + to notice; clearing `server/.local-state/*.pid` and starting fresh works. + +## Wave 1 — doorways, floors, viewers, character art + +### Character viewers + +Every open 3D viewer renders at once over one shared pawn instead of the +topmost one stealing the surface. Composites are banded by the owning window's +draw rank and flushed into the UI stream between windows, which is the +original's `flushRenderQueue()` then `renderScene()` ordering. + +### Input and interaction + +- A press inside a panel also reached the world. `pointer_captured` only + latches while the windows run, which is after the host has routed the press. + `WindowManager::covers` answers the same hit test at any time. +- A released movement key could leave the actor walking. A stop now + re-announces across the authority's one-second intent expiry. +- Changing clothes threw the arms up. `CarriedMotion` carries animator, gait, + interpolation and predictor across the pawn rebuild. + +### Collision + +A door module ships as one solid wall box with the opening flattened into it. +`transformStructureCollision` now surfaces the door-tagged panels as apertures +and the fixture carves them out, leaving jambs solid. Actors also stood at +terrain height indoors; `PropsLoader::floor_height_at` ports the web client's +`enterableFloorYAt`. + +Two things that looked like bugs and are not: stopping ~0.8 cells from a wall +is correct (the streamed position is an anchor and the collision circle sits at +`ground_center_from_anchor`), and a weapon riding on the back while idle is the +intended stow pose. + +### Character art + +Skull rebuilt as a dome of circular cross-sections on both bodies, patched into +head-weighted vertex positions only, so all 99 wearables stay bound. A full lab +rebuild moved stature 8 mm and tore every wearable off the character; do not +regenerate the bodies to change the head. + +## Next safe actions + +1. Fetch the branch and run `git lfs pull` before inspecting visuals or running + asset-dependent tests. +2. Recheck `dev/rust-client`; the co-developer may have advanced it. +3. Bring up the stack with `node scripts/server-local-persistent.mjs`, then + drive `client-rust/out/bin/successor-dev` from `client-rust/` per + `TOOLS_AVAILABLE.md`. +4. Run `python3 tools/observe/collision_map.py` first when touching collision. +5. Run focused checks while iterating, then the repository gates in + `VERIFICATION.md`. +6. Treat a merge to `main`, pointer promotion, authority restart, or public + deployment as a separate explicitly authorized operation. diff --git a/docs/INVITE_ALPHA_EXECUTION_PLAN.md b/docs/INVITE_ALPHA_EXECUTION_PLAN.md index 18675bca..ec8a75fd 100644 --- a/docs/INVITE_ALPHA_EXECUTION_PLAN.md +++ b/docs/INVITE_ALPHA_EXECUTION_PLAN.md @@ -27,9 +27,9 @@ changes their owned contracts. issuance in ComPress Postgres/Redis. Keep gameplay truth in exactly one Rust `successor-sim` authority child behind the TypeScript transport and lifecycle parent. -- Keep live authority and irreplaceable player state out of the single-node - Bunker K3s cluster. Use K3s for clean-source verifier Jobs, disposable previews, - load generators, and isolated restore rehearsals. +- Keep live authority and irreplaceable player state out of development and + verification infrastructure. Use disposable infrastructure for clean-source + verifier jobs, previews, load generators, and isolated restore rehearsals. - Start a fresh alpha world before the first external invitation. Preserve player state through migrations and tested recovery from that point onward. - Deploy stateful releases sequentially under maintenance. Never run two writers @@ -72,7 +72,7 @@ friend browser ComPress: account/invite/entitlement/ticket truth in existing Postgres/Redis. AWS: gameplay runtime, private shard state, snapshots, logs, metrics, alarms. -Bunker K3s: verifiers, previews, load, and restore rehearsals only. +Disposable infrastructure: verifiers, previews, load, and restore rehearsals only. ``` ## Alpha player contract @@ -100,10 +100,9 @@ after first entry until an authority-aware retirement protocol exists. ### Phase 0 — Preserve and seal -1. Create encrypted off-box backups for Successor Git/worktree and the 98 GiB - shared source-assets library. +1. Create encrypted off-site backups for the repository and source-assets library. 2. Restore-test both into isolated locations. -3. Freeze broad work in the shared dirty worktree and use dedicated worktrees. +3. Freeze broad work in any shared dirty checkout and use isolated checkouts. 4. Reconcile the v8 fixture/map, Synty eviction, authored code, generated files, labs, proof output, and scratch material into deliberate slices. 5. Repair command manifest coverage for `BuildPlace`, `BuildRemove`, and @@ -154,7 +153,7 @@ interruption, and attempted second writer all fail safely. Terraform owns VPC/security groups, ECR, private immutable asset storage/CDN, ALB/ACM, EC2, encrypted EBS, KMS/IAM, snapshot/archive storage, CloudWatch, -alarms, and budgets. No public SSH; use SSM for break-glass access. Only ALB may +alarms, and budgets. No public remote shell; use the provider session path for break-glass access. Only ALB may reach the private listener. Build once and promote the same image digest from synthetic staging to alpha. @@ -164,7 +163,7 @@ settings from tester geography and representative load. Exit: immutable staging deploy, residential TLS/WSS proof, private game port, hard-restart persistence, and a snapshot restored to an isolated playable -shard without SSH or copied secrets. +shard without copied secrets or public remote-shell access. ### Phase 4 — Browser alpha @@ -184,9 +183,9 @@ CDN serves only required payload, and release diagnostics accompany feedback. ### Phase 5 — Release loop -Inner loop: dedicated worktree, package-local proof, `verify:fast` against the -sealed baseline, surface-specific gate, real runtime smoke, and Fable visual -proof for player-visible changes. +Inner loop: isolated checkout, package-local proof, `verify:fast` against the +sealed baseline, surface-specific gate, real runtime smoke, and visual proof +for player-visible changes. Outer loop: clean detached seal, full existing verification farm, immutable server/client artifacts, same digest to staging, protocol/durability/WSS/load/ @@ -250,16 +249,15 @@ forward fix; emergency S1 restore explicitly loses post-cutover progress. - Product: two humans complete the durable core loop and choose to return. - Promotion: explicit human release decision. -## K3s boundary +## Disposable verification infrastructure -Allowed: clean-source verifier Jobs, disposable synthetic shards, compatibility -matrices, load-client fleets, asset/GPU validation, isolated copied-state -restore rehearsals, scheduled validators, and private previews. +Allowed: clean-source verifier jobs, disposable synthetic shards, +compatibility matrices, load-client fleets, asset/GPU validation, isolated +copied-state restore rehearsals, scheduled validators, and private previews. -Before expansion, reduce Bunker root usage from the observed 91%, prove K3s -backup/isolated restore, set resource requests/limits and TTL cleanup, and use -separate namespaces. Never mount alpha player data or make live admission depend -on Bunker. +Set resource requests and limits, enforce TTL cleanup, and isolate workloads. +Never mount alpha player data or make live admission depend on development or +verification infrastructure. Reconsider Kubernetes for live authority only when several independently schedulable shards/services exist and fencing, PVC behavior, drain/shutdown, diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 8fc838e7..06293e51 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -6,8 +6,8 @@ This runbook covers the stateful maintenance path. It does not replace ## Current public boundary -The public alpha is not hosted on Bunker. Bunker is the trusted build and -operator host. AWS owns: +The public alpha runs in AWS. Any trusted workstation with the required +provider tooling and authorized credentials may perform operator actions. AWS owns: - `www.successorgame.com`: S3/CloudFront site, client pointer, and downloads; - `world.successorgame.com`: ALB ingress to the private single-writer EC2 @@ -25,13 +25,13 @@ curl -fsS https://world.successorgame.com/healthz | jq . curl -fsS https://world.successorgame.com/readyz | jq . ``` -The approved AWS environment is -`~/.config/provider-ops/aws.env` on Bunker. Load it only inside the -trusted subprocess that needs it. Never print, copy to another host, commit, or -place its values in session output. A possible credential exposure in a -transcript or session JSON is a reportable concern, not permission to rotate a -key. Do not rotate or revoke provider credentials unless Michael explicitly -authorizes that action. +Provide the approved AWS environment to the trusted subprocess through the +operator's secret-management mechanism. Do not encode a workstation path in +this runbook, print credentials, copy them between hosts, commit them, or place +their values in session output. A possible credential exposure in a transcript +or session JSON is a reportable concern, not permission to rotate a key. Do not +rotate or revoke provider credentials without explicit authorization naming +that credential scope. Hosted release ids are not interchangeable. The immutable browser build and every launch ticket must agree on `SUCCESSOR_CLIENT_RELEASE_ID` and the @@ -49,8 +49,8 @@ reports are identity-bound by the authenticated game room and stored in the is already part of the application-consistent backup archive. Do not copy it out merely to inspect reports. -From an SSM operator session, list the open queue through the read-only command -shipped in the running immutable image: +From an authorized provider operator session, list the open queue through the +read-only command shipped in the running immutable image: ```bash sudo docker exec \ @@ -76,10 +76,49 @@ window ids, renderer health, and a bounded runtime-error tail. Both client and server redact secret-shaped keys and values; passwords, launch tickets, cookies, chat text, and inventory contents are intentionally not collected. +## Rust web beta deployment + +`deploy-rust-beta.mjs` composes the existing build, immutable asset publisher, +authority allowlist, and pointer promotion tools into one fail-closed command. +It requires a clean HEAD that exactly matches the current branch on `origin`, +derives the client release id from that commit, reads the live server protocol +id from `/beta/release.json`, refuses to restart the authority while any +session is connected, and verifies the promoted public pointer and immutable +entry. + +Review the complete plan without changing AWS: + +```bash +pnpm deploy:rust-beta --dry-run +``` + +Apply with a strict assignment-only AWS environment file: + +```bash +pnpm deploy:rust-beta --apply --aws-env \"$SUCCESSOR_ACCESS_DIR/aws.env\" +``` + +Add `--site` when the same commit changes the `/beta/` site shell. That runs +the site tests/build, publishes and promotes an immutable site release, and +verifies the authenticated site pointer: + +```bash +pnpm deploy:rust-beta --apply --site \ + --aws-env \"$SUCCESSOR_ACCESS_DIR/aws.env\" +``` + +The wrapper runs all standalone Rust client gates by default. `--skip-gates` +is only for an exact clean commit whose required gates already passed and were +recorded separately. `--instance-id` overrides the default exact-one-instance +lookup by the `successor-staging-1` Name tag. `--server-release-id` overrides +the live beta pointer only for an intentional protocol selection. Generated +plans, manifests, SSM parameters, and promotion proofs live under ignored +`tmp/rust-beta-deploy-/`. + ## Marketing-site publication -Site publication is independent of gameplay deployment. From the exact tested -site worktree: +Site publication is independent of gameplay deployment. From the repository +root containing the exact tested site build: ```bash node ops/deploy/scripts/publish-site.mjs \ @@ -260,7 +299,7 @@ state payload, and writes a rehearsal record. It never calls `systemctl`, SUCCESSOR_STATE_DIR=/var/lib/successor \ /usr/local/libexec/successor-restore-rehearsal.sh \ /var/backups/successor/successor-YYYYmmddTHHMMSSZ.tar.gz \ - ~/.cache/successor-restore-rehearsals/manual-YYYYmmdd + "${SUCCESSOR_RESTORE_TARGET:?set isolated restore target}" ``` This proves restore extraction only. An isolated authority/client smoke is diff --git a/docs/TOOLS_AVAILABLE.md b/docs/TOOLS_AVAILABLE.md new file mode 100644 index 00000000..65c66459 --- /dev/null +++ b/docs/TOOLS_AVAILABLE.md @@ -0,0 +1,364 @@ +# Tools Available to Agents + +This document describes repository-owned developer tools that agents should use +instead of inventing parallel automation paths. Tooling here does not change +runtime authority or public deployment identity. + +## Native Rust client remote control + +`client-rust/` provides a developer-only remote-control path for driving and +inspecting the native client. Use it for native visual QA, connected-client +journeys, deterministic input capture, and graphics-mastering checks. + +The controlled process is `client-rust/out/bin/successor-dev`. The companion CLI +is `client-rust/out/bin/successor-control`. Both are built by: + +```sh +make -C client-rust dev +``` + +`make -C client-rust native` builds the shipped `out/bin/successor`, which is +deliberately compiled WITHOUT the `dev-tools` capability: it refuses +`--control-port`, `--demo`, and raw `--endpoint` identity with +`developer probes and demo modes require the dev-tools capability`. Drive +`successor-dev` for agent journeys and observation harnesses. + +The control server is disabled by default, native-only, and bound exclusively +to `127.0.0.1`. It is not available in the WebAssembly client and is not a +public or gameplay-authority endpoint. Remote input still reaches gameplay +through the client's ordinary Colyseus command path. + +### Start a controllable client + +Run the client from `client-rust/`: its asset root is `..`, so a different +working directory fails with `required asset missing: render/props-mapping.json`. +Start a windowed demo with an ephemeral control port: + +```sh +cd client-rust +out/bin/successor-dev --demo parity-basic --gl --control-port 0 +``` + +Or connect to a local authority. `client-rust/tools/connect-local.sh` runs the +whole loop: brings the local authority up when it is not answering, creates (or +reuses) a durable roster character, builds `successor-dev` when missing, and +launches with the dev-identity join. `make -C client-rust connect-local` is the +same script; `PLAYER=` picks the character and `ARGS` passes extra client +flags. Client flags go after `--`: + +```sh +client-rust/tools/connect-local.sh --name local-dev -- --control-port 47778 +``` + +By hand, a durable roster character must join by `--character-id`; the shard +rejects the bare `{playerId, actorId}` dev shape for any id its character store +already owns: + +```sh +cd client-rust +out/bin/successor-dev \ + --dev-identity \ + --endpoint ws://127.0.0.1:28093 \ + --player-id --actor-id \ + --character-id \ + --control-port 47778 +``` + +The process prints the actual listener after binding: + +```text +successor_control_server=127.0.0.1: +``` + +Prefer `--control-port 0` when several clients or agents may run concurrently; +read the printed port and pass it to the CLI. `--control` uses the default +port. The equivalent environment switches are `SUCCESSOR_CONTROL=1` and +`SUCCESSOR_CONTROL_PORT=N`. + +Use the agent harness's supervised-process facility for a client that must stay +running while later commands inspect or control it. Do not launch an unmanaged +background process. + +### Send commands + +The CLI accepts one command in argv: + +```sh +client-rust/out/bin/successor-control --port 47778 status +client-rust/out/bin/successor-control --port 47778 key tap backquote +client-rust/out/bin/successor-control --port 47778 screenshot /tmp/successor-view.bmp +``` + +For a journey, pipe commands or pass `--file PATH`. One CLI invocation keeps a +single TCP connection for the whole stream: + +```sh +printf '%s\n' \ + 'key down w' \ + 'wait 750' \ + 'key up w' \ + 'screenshot /tmp/successor-agent-view.bmp' \ + | client-rust/out/bin/successor-control --port 47778 +``` + +Blank lines and lines beginning with `#` are ignored in command files and +piped input. `wait ` is implemented by the CLI and delays the +next command; it is not sent to the client. + +Supported server commands are: + +```text +key +mouse move +mouse +text +scroll +screenshot +record start +record stop +ui window +ui theme +ui opacity <0.35..1.0> +status +quit +``` + +`key` accepts every code the client binds, not just movement: + +```text +w a s d r f i c o v x n p k b m g +0..9 (also digit0..digit9) +up down left right space enter escape backspace shift tab semicolon backquote +``` + +`p k b m g` reach the datapad, skills, action browser, macros, and association +windows; `i c o` reach inventory, character, and options. + +The `ui` family exists because most surfaces have no hotkey at all — `survey`, +`bank`, `loot`, `trade`, `craft`, `converse` and the rest open from a terminal, +a target, or an item, and a review that cannot reach them is not a review. The +intents are applied through the same entry points the player's own action uses, +so a captured pane is the pane the player would see. Surface ids come from +`status`. + +`status` reports a `window_frames` array beside the `windows` id list. Each +entry is `{id, rect:[x,y,w,h], open, iconified, interactive}` for every +registered workspace frame, HUD panes included. Assert move, resize, and layout +persistence against those numbers instead of reading pixels — the frame rects +are the same values the manager persists. + +Requests are UTF-8 lines. Responses are one JSON object per line. The CLI exits +nonzero if a command returns `"ok":false`, so do not discard its exit status. +While a control connection is active, or a remote key or mouse button remains +held, remote state replaces local GLFW input. Always release held keys and +buttons in the same script unless the test intentionally inspects held state. + +### Screenshots are completion boundaries + +`screenshot ` reads the completed rendered frame before buffer swap. +Its successful JSON response is emitted only after the BMP has been written. +Wait for that response before reading or inspecting the file. A successful TCP +response proves file creation, not visual correctness; inspect the pixels for +visual claims. + +Use protocol screenshots from the control CLI for remote journeys. The +client's startup `--screenshot` option is useful for bounded demo captures but +does not replace a live control journey when input or connected behavior is +under test. + +### Record and replay input + +Start recording either when launching the client: + +```sh +client-rust/out/bin/successor \ + --demo parity-basic --gl --control-port 0 \ + --record-input /tmp/successor-journey.input +``` + +or through the control protocol: + +```text +record start /tmp/successor-journey.input +record stop +``` + +The current-only format begins with `successor.input.v1` and stores +frame-indexed key, pointer, text, and scroll commands. Replay it with the same +scene or connected-client setup: + +```sh +client-rust/out/bin/successor \ + --demo parity-basic --gl --control-port 0 \ + --replay-input /tmp/successor-journey.input +``` + +Replay owns input for the run. Malformed or out-of-order recordings fail +closed. During replay, live input mutation is rejected; `status`, `screenshot`, +and `quit` remain available. `--record-input` and `--replay-input` cannot be +combined. + +### Required proof + +Follow `docs/VERIFICATION.md` for the authoritative gate. For native +agent-control changes, proof must include a real loopback journey that: + +1. launches a windowed demo or connected client with control enabled; +2. sends multiple input commands through `successor-control`; +3. requests and visually inspects a protocol screenshot; +4. saves a `successor.input.v1` recording; +5. relaunches with `--replay-input`; and +6. proves the replayed UI or actor result in a second screenshot. + +A listener, JSON acknowledgement, or generated screenshot file alone is not +visual or gameplay proof. For connected movement, confirm the authority-streamed +result rather than treating local presentation as authority. Graphics-mastering +changes must also complete the overlay and pixel-inspection procedure in +`docs/VERIFICATION.md`. + +The implementation and lower-level protocol notes live in +`client-rust/README.md` and +`client-rust/source/platform/src/native/control.rs`. If this guide disagrees +with the current implementation, update the guide and verification contract in +the same change rather than adding a second control path. + +## Rust beta deployment wrapper + +Use `pnpm deploy:rust-beta --dry-run` to build and inspect a complete beta +deployment plan. Use `--apply --aws-env ` to publish, allowlist, restart, +promote, and verify it; add `--site` when the commit also changes the `/beta/` +site shell. The wrapper composes the existing release scripts and stores its +ignored evidence under `tmp/`; it is not another publication format or +authority path. Exact options and operational guarantees are documented in +`docs/OPERATIONS.md`. + +## Observation harnesses + +Single-point probing is the slowest and least reliable way to understand a +visual or spatial defect. These two answer the whole question at once, and both +prefer measurement over a screenshot someone has to squint at. + +### Character surfaces — `tools/observe/pawn_observatory.py` + +The character is drawn by four independent paths: the world view and the +inventory, character and examine viewers. A body change can land correctly in +one and wrongly in another, and one front-on shot hides it. This drives a live +client through `successor-control` and collects the same subject from every +surface at several angles, then lays them out side by side. + +```sh +python3 tools/observe/pawn_observatory.py --port 47779 --out /tmp/obs +``` + +Nothing in it hardcodes a screen position. The subject is found by motion: +disturb only it, diff the frames, and the pixels that changed are the subject. +That survives window moves, resolution changes, and UI edits. Toggling a window +finds the panel, spinning inside it finds the doll, and idle animation finds the +world pawn — walking does not, because the camera follows and every pixel +changes. + +### Collision — `tools/observe/collision_map.py` + +Walking into a wall yields one number and no shape. This rasterises the +authority's own rule — anchor to ground-centre offset, 300 milli radius, +`collisionBounds` always solid, the door blocker only while shut — then floods +from outside and reports how much of each building interior a player can +actually reach, with the door open and shut. + +```sh +python3 tools/observe/collision_map.py # every building +python3 tools/observe/collision_map.py --prop dustgate-home-starter +``` + +The flood fill is the point, not the picture: it answers "can the player get +in" without walking anywhere. Run it before and after any collision change. A +working door shows a large jump between shut and open; a sealed one does not +move at all. + +### Every UI surface — `tools/observe/pane_gallery.py` + +A UI regression hides in the one pane nobody opened. There are 30 registered +surfaces and most open from a terminal, a target, or an item, so clicking +through them is not a review anyone repeats. This drives a live client, opens +each surface alone, crops the capture to the frame's own rect, and grades what +it captured. + +```sh +python3 tools/observe/pane_gallery.py --port 47778 --out /tmp/gallery +python3 tools/observe/pane_gallery.py --port 47778 --only survey,bank --themes 0,2 +``` + +Three numbers per pane, and each one caught a real defect the first time it +ran: + +- **themed** — the same pane captured under two themes, compared pixel by + pixel. Ink that does not move between an aqua theme and an amber one is a + hardcoded literal, and the report prints the exact colours. This is how the + chat console was found to be 0% themed and `ButtonStyle::default` was found + to be frozen across six surfaces. +- **black** — the share of the pane's own ink that is near-black. This is how + every window was found to be painting a near-black slab instead of the + original's translucent tint. +- **contrast** — WCAG ratio between the pane's 99th-percentile and median + luminance, over the pane's own ink only. + +That last qualifier matters: the pane is captured twice, once open and once +closed, and only the pixels that differ are graded. Without it a pane with +nothing to say grades the terrain showing through it and reports a contrast of +1.0. Panes drawing under 2% of their rect are reported as empty instead. + +Composited 3D content — the paperdoll in the inventory and character sheets — +never themes and should not. It shows up as frozen ink inside the viewer cell; +that is the model, not a literal. + +### Equipment fit — the two humanoid gates + +Skinned wearables and rigid socket attachments fail in different ways, so they +have separate gates. Both run on macOS through Blender's Python. + +```sh +blender --background --factory-startup \ + --python content-pipeline/labs/humanoid-runtime-refit/verify_wearable_fit.py +blender --background --factory-startup \ + --python content-pipeline/labs/humanoid-runtime-refit/probe_weapons.py +``` + +`verify_wearable_fit.py` covers the 101 skinned wearables on both bodies and +writes `reports/wearable_fit.json`. `probe_weapons.py` covers what it cannot +see: weapons, hats, and anything else welded to a bone rather than skinned. It +resolves each socket the way `pawn/catalog.rs` does, measures hand or scalp +penetration on both bodies, and compares the two resolved mount positions. It +writes `reports/rigid_mount_fit.json`. + +The cross-body delta is the cheap signal: male and female share one 45-joint +skeleton, so a socket that resolves more than a millimetre apart is a rig +defect. A grip buried past 30 mm is out the far side of the hand. + +### Drawable glyphs — `tools/hygiene/drawable_glyphs.py` + +A hard gate inside `tools/hygiene/run.sh`, runnable alone: + +```sh +python3 tools/hygiene/drawable_glyphs.py +``` + +`hud::Icons` bakes ASCII 32..=126. A literal outside that range fails nowhere: +the built-in 5x7 path skips the glyph and advances the cursor, the rasterized +path substitutes the font's .notdef box, and every unit test still passes. One +round of that shipped 16 wrong separators across four surfaces, all reading as +`?` in-game. The gate lists each offending literal and the characters at fault. + +It scans only literals that can reach the renderer: assertion messages, logs, +and `#[cfg(test)]` modules are excluded, as is the renderer's own font table. +Player-authored text — chat, names, macro bodies — is deliberately out of scope; +it arrives at runtime, must render as best it can, and never passes through a +source literal. The separator this codebase draws is ` - `. + +### Debug views in the connected client + +- **Shift+C** — collision overlay: blocked cells, prop bounds, door blockers + with live open/closed state, and the player capsule. +- **Shift+V** — free the camera. Arrows orbit, shift+arrow dollies. The shipped + camera is locked north-up at a fixed pitch, which cannot show which way a + door slides or how far it travels. It opens on the shipped view, so nothing + changes until it is moved. diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 972d8934..3ad3b410 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -1,13 +1,12 @@ # Successor Verification -Status: current verification contract and latest public proof as of -2026-07-29. +Status: current verification contract and latest local source proof as of +2026-08-02; latest public proof remains 2026-07-29. -Run commands from the canonical Bunker checkout, -`~/dev/games/successor`, unless a section says otherwise. A passing result -belongs to the exact source tree that produced it. Historical screenshots, -migration reports, another worktree, or a public release cannot prove current -`main`. +Run commands from the repository root in the checkout being verified. A passing +result belongs to the exact source revision and working tree that produced it. +Historical screenshots, migration reports, another checkout, or a public +release cannot prove the current development source. Architecture and scope live in `CANONICAL_CONTEXT.md`. Current implementation inventory lives in `CURRENT_PROJECT_STATE.md`. Volatile public identities and @@ -16,17 +15,15 @@ release debt live in `CURRENT_DEPLOYMENT.md`. ## Install and source identity ```bash -cd ~/dev/games/successor git status --short -git branch --show-current -git worktree list --porcelain +git rev-parse HEAD pnpm install --frozen-lockfile pnpm verify:successor-context ``` -Normal development expects a clean `main` checkout and one registered -Successor worktree. Temporary task worktrees are acceptable while active, but -must be merged or archived and removed before handoff. +Development may use any branch or checkout layout. Bind proof to the exact +revision and working tree, preserve unrelated changes, and remove temporary +isolation created for the task before handoff. ## Required source gates @@ -73,6 +70,65 @@ gates before handoff. | Desktop supervisor | `pnpm --dir desktop check && pnpm --dir desktop test && pnpm --dir desktop verify:key-ownership` | | Marketing and launch site | `pnpm site:test && pnpm site:build` | | Release tooling | `pnpm deploy:contract && pnpm --dir desktop release:manifest` | +| Standalone Rust client | `make -C client-rust verify && make -C client-rust check-allocs && make -C client-rust runtime-check && make -C client-rust render-check && make -C client-rust terrain-check && make -C client-rust nostd`; connected changes also run `make -C client-rust connected-check-allocs CONNECTED_ENDPOINT=... CONNECTED_PLAYER=... CONNECTED_ACTOR=...` against a disposable authority | + +`client-rust/` is outside both root workspaces. Its own gates are mandatory: +`verify` covers tests, corpus audit, and stripped native/wasm size budgets; +`check-allocs` requires zero steady-state allocations in the standard scene; +`connected-check-allocs` applies the same invariant after deferred connected +presentation initialization and requires at least 240 stable actor-count +frames; `runtime-check` checks frame time and RSS; `render-check` checks native +material-parity GPU p99; `terrain-check` checks deterministic desert and forest +ROI probes plus terrain GPU p99; and `nostd` builds both engine crates for +`thumbv7em-none-eabihf`. Browser renderer changes additionally require the +corresponding WebGL2 probe, connected authority commands and receipts, a resize +round trip, deterministic half-float-disabled fallback, gesture-gated audio, +and rendered context-loss recovery without replaying the launch capability. + +For a Rust web beta release, build the immutable artifact from the exact source +and server protocol identities: + +```bash +make -C client-rust web-release \ + SOURCE_COMMIT=<40-char-source-commit> \ + CLIENT_RELEASE_ID=successor-rust-beta@ \ + SERVER_RELEASE_ID= +``` + +Inspect `client-rust/out/web-release/release-manifest.json`, then dry-run +`publish-client-assets.mjs` against that directory and +`promote-client-runtime.mjs` with +`--destination site/current/beta/release.json --channel beta +--server-release-id `. The beta pointer and stable +`site/current/client/release.json` pointer are independent. + +Browser beta proof must use `/beta/` and cover exact-parent READY/launch, +release-bound game and chat redemption, movement and receipts, resize, +gesture-gated audio, RGBA8 fallback, WebGL context loss/recovery, clean exit, +fresh-ticket re-entry, and character switching. Inspect URLs, storage, DOM, +console output, and proof artifacts for capability leakage. Repeat a stable +`/play/` journey after beta proof. + +Native agent-control changes additionally require a real loopback journey: +launch a windowed demo or connected client with `--control-port N`, pipe +multiple input commands through `out/bin/successor-control`, request and +inspect a protocol screenshot, save `successor.input.v1`, relaunch with +`--replay-input`, and prove the replayed UI or actor result in a second +screenshot. A TCP acknowledgement alone is not visual or gameplay proof. + +Graphics-tuning changes additionally require: load the checked-in +`assets/render/settings.json`, open the Backquote overlay through +`successor-control`, inspect all three control pages, change both a lighting or +post value and a palette/color value, capture the visible output difference, +save and reload the document, then restore the intended checked-in preset. +Verify a second launch consumes the saved/default document. Palette and grade +proof must inspect pixels; JSON values or uniform logs alone are insufficient. + +`client-rust/budgets.json` is authoritative. Its fidelity-first caps are 6 MiB +stripped native, 4 MiB stripped WebAssembly, 8.33 ms runtime/terrain p99, +16.67 ms generic render p99, zero steady-state frame allocations, and 512 MiB +peak RSS. Relative headroom is `max(512 KiB, 25%)` for size, 100% for +performance, and 5% for RSS; absolute caps still apply. Changes under `client-3d/` or shared `client/src/` must also rebuild the packaged desktop: @@ -99,9 +155,9 @@ git diff --exit-code -- \ The current checked-in identities are: - slice SHA-256: - `69a19db8289b0d4711ccca5d4febef39b8dcd2ef662f9f70539935e49af8680e` + `c21a81d9e511fa35d059a51eeb5ffc9f60d12b6d6cc63161930befb733bb5e2d` - map-bundle SHA-256: - `01df5d1d178a8199b5bbd62f7e2107f017f5ae2ba1ca45081bb0ecdbb8f65795` + `260687cb95cee5f783e134e3a401f7265d05f0403307fcd6b4283cec2f281cb2` Update the canonical and state docs in the same change when either identity changes. @@ -211,7 +267,7 @@ pnpm verify:full --dry-run --pretty pnpm verify:full ``` -The full run executes fresh against the canonical source hash. Generated +The full run executes fresh against the identified source revision. Generated ledgers, screenshots, and build output are evidence artifacts, not committed source. @@ -222,6 +278,7 @@ reset, key rotation, or pointer promotion: ```bash curl -fsS https://www.successorgame.com/client/release.json | jq . +curl -fsS https://www.successorgame.com/beta/release.json | jq . curl -fsS https://www.successorgame.com/downloads/manifest.json | jq . curl -fsS https://world.successorgame.com/healthz | jq . curl -fsS https://world.successorgame.com/readyz | jq . @@ -261,11 +318,10 @@ The sealed amd64 image is: 595529182031.dkr.ecr.us-east-1.amazonaws.com/successor-staging-1/server@sha256:0e7d1055fba3787c35c9c367d0d3b07136f95d47decb919cb0c873bd1d994040 ``` -The release evidence is under: - -```text -~/dev/releases/successor-alpha-b9262b21-20260729 -``` +The release evidence is identified by public-journey digest +`7df2b5fbd681bda99d0eadf664d1b631517f4b9bdd8961aa4bb7daaecd405cc6`; +its storage location is environment-specific and is not part of the +verification contract. The final authenticated public journey passed 25 checks. It proved: @@ -326,28 +382,22 @@ authority restart, repaired public launch, or authenticated world entry. ## Repository recovery -The pre-consolidation archive is: - -```text -~/dev/releases/successor-preconsolidation-20260728T1811-MDT -``` - -It contains the verified all-refs bundle for the former 210 worktrees, dirty -patches and untracked payloads, the corrupt-checkout raw archive, and the -unfinished property/farming patch. Read-only integrity checks are: +The pre-consolidation archive contains the verified all-refs bundle for the +former worktrees, dirty patches and untracked payloads, the corrupt-checkout +raw archive, and the unfinished property/farming patch. Its storage location is +environment-specific and must be supplied explicitly: ```bash -archive=~/dev/releases/successor-preconsolidation-20260728T1811-MDT +archive="${SUCCESSOR_RECOVERY_ARCHIVE:?set recovery archive path}" +repo="${SUCCESSOR_REPO_ROOT:-$PWD}" sha256sum --check "$archive/SHA256SUMS" -git -C ~/dev/games/successor bundle verify \ - "$archive/successor-all-refs.bundle" -git -C ~/dev/games/successor bundle list-heads \ - "$archive/successor-all-refs.bundle" +git -C "$repo" bundle verify "$archive/successor-all-refs.bundle" +git -C "$repo" bundle list-heads "$archive/successor-all-refs.bundle" ``` -Do not restore the whole worktree farm. Extract one named commit, patch, or -payload into an isolated disposable worktree, revalidate it against `main`, -then either integrate it or remove the worktree. +Do not restore the former worktree farm. Extract one named commit, patch, or +payload into an isolated checkout, revalidate it against the current source +tree, then either integrate it or remove the temporary checkout. ## Before committing @@ -358,7 +408,8 @@ then either integrate it or remove the worktree. presented as shipped behavior. 3. Run focused checks, `pnpm run ci`, and `pnpm hygiene:rust`. 4. Rebuild and smoke the desktop package when its inputs changed. -5. Commit and push only the intended files to `main`. +5. Commit or otherwise preserve only the intended files in the requested + source destination. 6. Report source, built, published, promoted, and player-verified states separately. diff --git a/docs/future/README.md b/docs/future/README.md index 0eebb90e..9657794c 100644 --- a/docs/future/README.md +++ b/docs/future/README.md @@ -6,7 +6,7 @@ revisiting. They do not report current implementation status. Before building from one of these files: -1. Compare its assumptions with `main`. +1. Compare its assumptions with the current source tree. 2. Replace stale paths, hashes, and numeric allocations. 3. Move any implemented contract into `docs/specs/` or the relevant canonical document in the same change. diff --git a/docs/future/agriculture-architecture.md b/docs/future/agriculture-architecture.md index c3571675..18877c21 100644 --- a/docs/future/agriculture-architecture.md +++ b/docs/future/agriculture-architecture.md @@ -2,7 +2,7 @@ > Preserved on 2026-07-28. This is design source, not current runtime > documentation. Recheck every code path, hash, and implementation-status claim -> against `main` before using it. Current truth lives in +> against the current source tree before using it. Current truth lives in > `docs/CANONICAL_CONTEXT.md`, `docs/CURRENT_PROJECT_STATE.md`, and > `docs/VERIFICATION.md`. diff --git a/docs/future/bioengineer-and-crop-engineering.md b/docs/future/bioengineer-and-crop-engineering.md index 0c05f8f4..522ddb5b 100644 --- a/docs/future/bioengineer-and-crop-engineering.md +++ b/docs/future/bioengineer-and-crop-engineering.md @@ -2,7 +2,7 @@ > Preserved on 2026-07-28. This is design source, not current runtime > documentation. Recheck every code path, hash, and implementation-status claim -> against `main` before using it. Current truth lives in +> against the current source tree before using it. Current truth lives in > `docs/CANONICAL_CONTEXT.md`, `docs/CURRENT_PROJECT_STATE.md`, and > `docs/VERIFICATION.md`. diff --git a/docs/future/compress-bridge-plugin.md b/docs/future/compress-bridge-plugin.md index 16936eab..a9d798ac 100644 --- a/docs/future/compress-bridge-plugin.md +++ b/docs/future/compress-bridge-plugin.md @@ -2,7 +2,7 @@ > Preserved on 2026-07-28. This is design source, not current runtime > documentation. Recheck every code path, hash, and implementation-status claim -> against `main` before using it. Current truth lives in +> against the current source tree before using it. Current truth lives in > `docs/CANONICAL_CONTEXT.md`, `docs/CURRENT_PROJECT_STATE.md`, and > `docs/VERIFICATION.md`. @@ -11,7 +11,7 @@ **Hard law:** DEV/LOCAL ComPress docker stack ONLY (`compressmain-db-1` pg 5435 / `compressmain-redis-1` 6381 / mailpit 8025). ZERO prod mutation. Prod cutover = W3 behind §8.1 pre-cutover check. ## Grounding (verified this pass) -- ComPressMain on `enterprise-refactor@9534ce63` (live branch is `master`; a huge per-store-RLS refactor is in flight — `AGENTS.md` + `docs/cutover/*`; prod at migration **0096**). Local dev stack is UP (docker `compressmain-*`). +- ComPress revision `9534ce63` was the studied source; a large per-store-RLS refactor was in flight, and production was at migration **0096**. Reverify all of this against current ComPress instructions. - Historical plugin ownership rule, to be reverified against current ComPress instructions: **core owns models+migrations; plugins own services/routes/types/settings**; `ComPressPlugin` @@ -24,7 +24,7 @@ ## OPEN DECISIONS FOR MAIN (ruling before build) 1. **Dev DB isolation.** The shared `compressmain` mirror is the enterprise-refactor test mirror (rebuilt by `pnpm parity:refresh`), so mutating it (my migration + a Successor store) risks colliding with in-flight refactor work AND gets wiped on the next refresh. **RECOMMEND: a dedicated ephemeral successor dev DB** (own compose project / DB name, mirrors the bazaar's isolation model) so W2 never touches the refactor mirror; fall back to the shared mirror only if you want it in the parity lineage now. **(D1)** 2. **Migration + cutover ledger.** Core migration adds `successor_profiles`/`successor_characters`/reservations. Prod is 0096; the cutover ledger (`docs/cutover/CUTOVER.md`) is a PROD artifact. **RECOMMEND: author the migration as the next number but DO NOT append it to the cutover ledger in W2** (dev-apply only); the ledger row + FORCE-RLS wiring is W3's ceremony. **(D2)** -3. **Branch.** **RECOMMEND: isolated worktree off `enterprise-refactor@9534ce63`** (matches the studied code + bazaar base). No push to any branch in W2. **(D3)** +3. **Source revision.** **RECOMMEND: use the exact studied ComPress revision `9534ce63` in an isolated checkout.** Preserve changes only in the requested destination. **(D3)** 4. **Redemption transport.** **RECOMMEND: keep the game's existing HTTP redeem** (extend `tickets.ts`) against the successor plugin's `GET …/session-ticket/:ticket` (reads+consumes the Redis ticket, returns the payload). Shared-Redis is a W3 hardening option. **(D4)** ## 1. DATA MODEL (core migration — dev-apply only in W2) @@ -47,7 +47,7 @@ Entitlement summary computed with `hasEntitlement` (login gate) + highest `succe - One recurring **product + product_plan** (`billingModel:'recurring'`, `intervalUnit:'month'`, `amount:499`, `billingEngine:'stripe_billing'`, **test mode**) whose `entitlementBundle = [{kind:'feature',ref:'successor.access'},{kind:'feature',ref:'successor.tier.premium',metadata:{characterSlots:10}}]`. - Stripe test webhook → `stripe-gateway` bridge → `subscriptions` lifecycle → `entitlement-hooks` grants `activeUntil=currentPeriodEnd` (grace = the §3d clamp, inherited). -## 4. GAME-SIDE (successor repo, worktree off the W1 branch/train tip) +## 4. GAME-SIDE (successor repo at the exact requested source revision) - Extend `server/src/auth/tickets.ts` response schema: add `profileId, characterId, characterName, entitlement{access,tier,characterSlots,activeUntil}, moderation`. - `colyseusRoom.identityFromOptions` ticket path becomes REAL: resolve `characterId` → load sim-state, set `ownerRef=profileId`, spawn/appearance/worn from the character record; carry the entitlement onto the session. - **Login gate:** reject the join (`1008`) if `!entitlement.access`. `GAME_ALLOW_DEV_IDENTITY` stays ON locally (W1) so dev/harness paths still bypass; the ticket path enforces the gate. diff --git a/docs/future/grid-structure-framework.md b/docs/future/grid-structure-framework.md index c6dbf86e..43747857 100644 --- a/docs/future/grid-structure-framework.md +++ b/docs/future/grid-structure-framework.md @@ -2,7 +2,7 @@ > Preserved on 2026-07-28. This is design source, not current runtime > documentation. Recheck every code path, hash, and implementation-status claim -> against `main` before using it. Current truth lives in +> against the current source tree before using it. Current truth lives in > `docs/CANONICAL_CONTEXT.md`, `docs/CURRENT_PROJECT_STATE.md`, and > `docs/VERIFICATION.md`. diff --git a/docs/future/multiplayer-situation-catalog.md b/docs/future/multiplayer-situation-catalog.md index bae72d4f..d6c9a171 100644 --- a/docs/future/multiplayer-situation-catalog.md +++ b/docs/future/multiplayer-situation-catalog.md @@ -2,7 +2,7 @@ > Preserved on 2026-07-28. This is design source, not current runtime > documentation. Recheck every code path, hash, and implementation-status claim -> against `main` before using it. Current truth lives in +> against the current source tree before using it. Current truth lives in > `docs/CANONICAL_CONTEXT.md`, `docs/CURRENT_PROJECT_STATE.md`, and > `docs/VERIFICATION.md`. diff --git a/docs/future/network-engineer-and-player-networks.md b/docs/future/network-engineer-and-player-networks.md index c9fdddd9..f01e59d5 100644 --- a/docs/future/network-engineer-and-player-networks.md +++ b/docs/future/network-engineer-and-player-networks.md @@ -2,7 +2,7 @@ > Preserved on 2026-07-28. This is design source, not current runtime > documentation. Recheck every code path, hash, and implementation-status claim -> against `main` before using it. Current truth lives in +> against the current source tree before using it. Current truth lives in > `docs/CANONICAL_CONTEXT.md`, `docs/CURRENT_PROJECT_STATE.md`, and > `docs/VERIFICATION.md`. @@ -10,7 +10,7 @@ **Sibling docs:** `docs/future/bioengineer-and-crop-engineering.md` (profession-shape precedent), `docs/future/grid-structure-framework.md` (placement primitive every device here rides on) **Owner directive:** a Network Engineer profession with real infrastructure and real economics, plus a bounded creative-logic layer with 2003-sandbox energy — the thing Wiremod gave Garry's Mod — without ever letting a player script run wild on the authority server. -This doc separates **current runtime truth** (cited to files in `successor` as of today's worktree) from **proposal** (everything in §1 onward). No proposed type, command, or item id in this document exists in the codebase. Numeric item ids are deliberately not assigned here; the taken bands are listed in §12.6 and allocation happens at implementation time against the live `authority.rs` constants. +This doc separates **observed runtime truth** (cited to files at the studied source revision) from **proposal** (everything in §1 onward). No proposed type, command, or item id in this document exists in the codebase. Numeric item ids are deliberately not assigned here; the taken bands are listed in §12.6 and allocation happens at implementation time against the live `authority.rs` constants. Primary external source: the live Wiremod repository, `wiremod/wire`, pinned at commit `67cbe4a96caf7ab2aaf00df0b525710a2e80155e` (master as of 2026-07-19T01:47:46Z). All Wiremod citations below are blob URLs pinned to that commit, not mutable master. diff --git a/docs/future/parcel-lattice-contract.md b/docs/future/parcel-lattice-contract.md index 620ffeb1..c7504727 100644 --- a/docs/future/parcel-lattice-contract.md +++ b/docs/future/parcel-lattice-contract.md @@ -2,7 +2,7 @@ > Preserved on 2026-07-28. This is design source, not current runtime > documentation. Recheck every code path, hash, and implementation-status claim -> against `main` before using it. Current truth lives in +> against the current source tree before using it. Current truth lives in > `docs/CANONICAL_CONTEXT.md`, `docs/CURRENT_PROJECT_STATE.md`, and > `docs/VERIFICATION.md`. diff --git a/docs/future/profession-and-commando-direction.md b/docs/future/profession-and-commando-direction.md index 3cbfb6fe..57ee4388 100644 --- a/docs/future/profession-and-commando-direction.md +++ b/docs/future/profession-and-commando-direction.md @@ -2,7 +2,7 @@ > Preserved on 2026-07-28. This is design source, not current runtime > documentation. Recheck every code path, hash, and implementation-status claim -> against `main` before using it. Current truth lives in +> against the current source tree before using it. Current truth lives in > `docs/CANONICAL_CONTEXT.md`, `docs/CURRENT_PROJECT_STATE.md`, and > `docs/VERIFICATION.md`. diff --git a/docs/future/sim-players.md b/docs/future/sim-players.md index 6914c7e7..a0f38469 100644 --- a/docs/future/sim-players.md +++ b/docs/future/sim-players.md @@ -2,13 +2,13 @@ > Preserved on 2026-07-28. This is design source, not current runtime > documentation. Recheck every code path, hash, and implementation-status claim -> against `main` before using it. Current truth lives in +> against the current source tree before using it. Current truth lives in > `docs/CANONICAL_CONTEXT.md`, `docs/CURRENT_PROJECT_STATE.md`, and > `docs/VERIFICATION.md`. Status: DESIGN + working prototype landed in this lane. Prototype: `tools/verification/simplayer/`; entry `pnpm sim:players -- --minutes 20 --population 4`. -Base tip validated: main `797ee63` (also green on `796fd25`). +Base revision validated: `797ee63` (also green on `796fd25`). ## Mandate @@ -221,5 +221,5 @@ they are honest, expected outcomes of contested/again-later play. torn down and asserted inactive after the run. - **LootTables.** kills/hour telemetry shape agreed on IRC; `loottables_telemetry` ships per-hunter + pooled mob-only rates for the AFK calibration anchor. -- **Landing.** New directory + one `package.json` script line — near-zero conflict - surface; lands late in the queue on main's live tip with a re-run there. +- **Landing.** New directory + one `package.json` script line — near-zero + conflict surface; re-run against the exact integration revision before landing. diff --git a/docs/future/wardrobe-taxonomy.md b/docs/future/wardrobe-taxonomy.md index d63a221e..21b884a2 100644 --- a/docs/future/wardrobe-taxonomy.md +++ b/docs/future/wardrobe-taxonomy.md @@ -2,7 +2,7 @@ > Preserved on 2026-07-28. This is design source, not current runtime > documentation. Recheck every code path, hash, and implementation-status claim -> against `main` before using it. Current truth lives in +> against the current source tree before using it. Current truth lives in > `docs/CANONICAL_CONTEXT.md`, `docs/CURRENT_PROJECT_STATE.md`, and > `docs/VERIFICATION.md`. diff --git a/docs/future/weapon-asset-authoring.md b/docs/future/weapon-asset-authoring.md index 38618d68..d7e776fa 100644 --- a/docs/future/weapon-asset-authoring.md +++ b/docs/future/weapon-asset-authoring.md @@ -2,7 +2,7 @@ > Preserved on 2026-07-28. This is design source, not current runtime > documentation. Recheck every code path, hash, and implementation-status claim -> against `main` before using it. Current truth lives in +> against the current source tree before using it. Current truth lives in > `docs/CANONICAL_CONTEXT.md`, `docs/CURRENT_PROJECT_STATE.md`, and > `docs/VERIFICATION.md`. @@ -149,7 +149,7 @@ in-game matcap+post is authoritative. ```bash cd pawn-forge/pawnforgev2/_bakeoff/synty_weapons_20260708 FBX=stage/sw__SM_Wep_XXX.fbx; ID=wpn_xxx; CLS=rifle -SLUGTHROWER=/home/lycaon/dev/games/successor/client-3d/public/assets/pawn-pack/slugthrower_attach.json +SLUGTHROWER="${SUCCESSOR_REPO_ROOT:?set Successor repository root}/client-3d/public/assets/pawn-pack/slugthrower_attach.json" # 1) dark GLB (palette map + tint) blender -b --factory-startup -P scripts/convert_weapon.py -- --input $FBX --output glb/${ID}_dark.glb --id $ID --report qa/${ID}.json # 2) attach spec (grip formula, mount-transfer vs pawn rig) diff --git a/ops/deploy/README.md b/ops/deploy/README.md index 08808025..4e07fde7 100644 --- a/ops/deploy/README.md +++ b/ops/deploy/README.md @@ -16,7 +16,11 @@ state inputs, command output, or another host. - `../docker/Dockerfile` builds the TypeScript server, client slice, and Rust `authority_bridge_server` in a multi-stage image. The runtime is Node 22 on - Debian, UID 10001, and expects `linux/amd64`. + Debian, UID 10001, and expects `linux/amd64`. Builds require BuildKit + (default since Docker 23): the pnpm store and Cargo registry/target dirs are + `RUN --mount=type=cache` volumes, so source-only rebuilds skip dependency + fetches and crate recompiles; the finished bridge is copied out of the + target cache to `/out/authority_bridge_server` for the runtime stage. - `terraform/bootstrap` creates the versioned, encrypted private state bucket and Route53 hosted zone selected by operator tfvars. The committed `successor.compress.biz` default is historical scaffolding, not the current @@ -67,14 +71,38 @@ copy the staging backend values into `../envs/staging/backend.hcl`. Configure AWS credentials through the approved account boundary; never put access keys in this repository. -## Client 3D asset release (operator-run) +## Client asset release (operator-run) `publish-client-assets.mjs` is provider-free until `--apply`. It hashes every -file under `client-3d/dist`, writes a deterministic manifest, uploads immutable -objects at (one immutable tree preserving dist layout)`assets//`, uploads the immutable manifest at +file under the dist directory, writes a deterministic manifest, uploads +immutable objects, uploads the immutable manifest at `manifests/.json`, and uploads `current.json` last with -`no-store,no-cache,must-revalidate`. Every upload sets an explicit Content-Type; -modules, CSS, fonts, media, and GLB are covered. +`no-store,no-cache,must-revalidate`. Every upload sets an explicit +Content-Type; modules, CSS, fonts, media, GLB, and `.spak` packs are covered. + +Two storage layouts exist: + +- **Path layout (stable `client-3d` releases).** Immutable tree at + `releases//` preserving dist layout. Pass + `--baseline-manifest ` pointing at the currently live manifest: + files whose sha256 matches the baseline at the same path are copied + server-side from the previous release prefix (`s3api copy-object`, + zero transfer) instead of re-uploaded. A code-only release uploads only its + changed files. +- **Content-addressed object store (`--object-store`, Rust beta releases).** + Streamed payloads (paths under `assets/`, `successor-audio/`, + `successor-slice/`, `render/`, `packs/`) upload exactly once ever as + `objects/`, gzipped at level 9 with + `Content-Encoding: gzip` for text/GLB/wasm/pack types (content identity + stays the uncompressed hash; browsers decode transparently). Entry files + (`index.html`, `successor.js`, `successor.wasm`, `release-manifest.json`) + remain release-scoped under `releases//`. Objects already + present in the bucket are skipped (listing `objects/` once up front), so an + incremental beta publish uploads only newly minted content. The object store + is append-only: objects are never garbage-collected by the publisher. + +Uploads run 16-wide; ordering is objects, then release-scoped files, then the +manifest, and `current.json` strictly last. ```bash node ops/deploy/scripts/publish-client-assets.mjs \ @@ -83,7 +111,9 @@ node ops/deploy/scripts/publish-client-assets.mjs \ --cdn-origin https://REPLACE_WITH_ASSET_CDN_ORIGIN \ --store-origin https://REPLACE_WITH_COMPRESS_SUCCESSOR_STORE_ORIGIN \ --dry-run -# Review the printed operations and manifest, then use --apply with the approved bucket. +# Rust beta dists add --object-store. Repeat stable publishes add +# --baseline-manifest . Review the printed operations +# and manifest, then use --apply with the approved bucket. ``` `asset_cdn_origin` and `asset_manifest_url` Terraform outputs provide the CDN diff --git a/ops/deploy/contract-check.mjs b/ops/deploy/contract-check.mjs index 72c375b8..74b31265 100644 --- a/ops/deploy/contract-check.mjs +++ b/ops/deploy/contract-check.mjs @@ -34,6 +34,7 @@ for (const relative of [ "ops/deploy/scripts/backup.sh", "ops/deploy/scripts/restore.sh", "ops/deploy/scripts/publish-client-assets.mjs", + "ops/deploy/scripts/deploy-rust-beta.mjs", "ops/deploy/terraform/bootstrap/main.tf", "tools/release/seal.mjs", "tools/release/promote-staging.mjs", diff --git a/ops/deploy/scripts/deploy-rust-beta.mjs b/ops/deploy/scripts/deploy-rust-beta.mjs new file mode 100755 index 00000000..a3434c7e --- /dev/null +++ b/ops/deploy/scripts/deploy-rust-beta.mjs @@ -0,0 +1,416 @@ +#!/usr/bin/env node +import { readFile, mkdir, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const DEFAULT_BETA_POINTER = "https://www.successorgame.com/beta/release.json"; +const DEFAULT_SITE_ORIGIN = "https://www.successorgame.com"; +const DEFAULT_WORLD_ORIGIN = "https://world.successorgame.com"; +const DEFAULT_ASSET_ORIGIN = "https://d2kf3ri6r74a0m.cloudfront.net"; +const DEFAULT_ASSET_BUCKET = "successor-assets-5a537a77"; +const DEFAULT_SITE_BUCKET = "successor-site-5a537a77"; +const DEFAULT_INSTANCE_NAME = "successor-staging-1"; +const DEFAULT_REGION = "us-east-1"; +const ALLOWED_AWS_ENV = new Set([ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_PROFILE", +]); + +export function parseArgs(argv, environment = process.env) { + const args = { + apply: false, + dryRun: false, + site: false, + skipGates: false, + awsEnv: "", + instanceId: "", + instanceName: DEFAULT_INSTANCE_NAME, + region: DEFAULT_REGION, + serverReleaseId: "", + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--apply") args.apply = true; + else if (arg === "--dry-run") args.dryRun = true; + else if (arg === "--site") args.site = true; + else if (arg === "--skip-gates") args.skipGates = true; + else if (["--aws-env", "--instance-id", "--instance-name", "--region", "--server-release-id"].includes(arg)) { + const value = argv[++index]; + if (!value || value.startsWith("--")) throw new Error(`${arg} requires a value`); + const key = { + "--aws-env": "awsEnv", + "--instance-id": "instanceId", + "--instance-name": "instanceName", + "--region": "region", + "--server-release-id": "serverReleaseId", + }[arg]; + args[key] = value; + } else { + throw new Error(`unknown argument: ${arg}`); + } + } + if (args.apply === args.dryRun) throw new Error("choose exactly one of --dry-run or --apply"); + if (args.apply && !args.awsEnv && !environment.AWS_PROFILE && !environment.AWS_ACCESS_KEY_ID) { + throw new Error("--apply requires --aws-env or existing AWS credentials in the environment"); + } + if (args.instanceId && !/^i-[0-9a-f]{8,32}$/u.test(args.instanceId)) throw new Error("--instance-id is invalid"); + if (!/^[A-Za-z0-9._-]{2,128}$/u.test(args.instanceName)) throw new Error("--instance-name is invalid"); + if (!/^[a-z]{2}-[a-z]+-\d$/u.test(args.region)) throw new Error("--region is invalid"); + if (args.serverReleaseId && !isSafeReleaseValue(args.serverReleaseId)) throw new Error("--server-release-id is invalid"); + return args; +} + +function expandHome(path) { + if (path === "~") return homedir(); + if (path.startsWith("~/")) return join(homedir(), path.slice(2)); + return path; +} + +export function parseAwsEnv(source) { + const env = {}; + for (const [lineIndex, raw] of source.split(/\r?\n/u).entries()) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + const match = /^([A-Z_][A-Z0-9_]*)=(.*)$/u.exec(line); + if (!match) throw new Error(`invalid AWS env line ${lineIndex + 1}`); + const [, key, rawValue] = match; + if (!ALLOWED_AWS_ENV.has(key)) throw new Error(`unsupported AWS env key ${key}`); + let value = rawValue.trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (!value || /[\r\n\0]/u.test(value)) throw new Error(`invalid AWS env value for ${key}`); + env[key] = value; + } + return env; +} + +function isSafeReleaseValue(value) { + return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9@._-]{1,255}$/u.test(value); +} + +export function releaseIdentity(commit) { + if (!/^[0-9a-f]{40}$/u.test(commit)) throw new Error("source commit must be a 40-character lowercase SHA"); + const prefix = commit.slice(0, 16); + return { + sourceCommit: commit, + prefix, + clientReleaseId: `successor-rust-beta@${prefix}`, + }; +} + +export function buildRemoteAllowlistCommand(clientReleaseId) { + if (!/^successor-rust-beta@[0-9a-f]{16}$/u.test(clientReleaseId)) throw new Error("unsafe beta release id"); + return [ + "set -e", + `NEW=${clientReleaseId}`, + "FILE=/etc/successor/runtime.env", + 'if sudo grep -q "^SUCCESSOR_ALPHA_CLIENT_RELEASE_ALLOWLIST=.*${NEW}" "$FILE"', + 'then echo "allowlist already contains ${NEW}"', + 'else sudo sed -i "/^SUCCESSOR_ALPHA_CLIENT_RELEASE_ALLOWLIST=/ s/\\$/,${NEW}/" "$FILE" && echo "appended ${NEW}"', + "fi", + 'sudo grep ^SUCCESSOR_ALPHA_CLIENT_RELEASE_ALLOWLIST "$FILE"', + "sudo systemctl restart successor.service", + "sleep 10", + "systemctl is-active successor.service", + ].join("; "); +} + +export function instanceIdFromDescribeResponse(value) { + const ids = value?.Reservations?.flatMap((reservation) => reservation.Instances ?? []).map((instance) => instance.InstanceId) ?? []; + if (ids.length !== 1 || !/^i-[0-9a-f]{8,32}$/u.test(ids[0])) { + throw new Error(`expected exactly one running authority instance, found ${ids.length}`); + } + return ids[0]; +} + +function run(program, args, { env = process.env, capture = false } = {}) { + const result = spawnSync(program, args, { + cwd: ROOT, + env, + encoding: "utf8", + stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + const detail = capture ? `: ${(result.stderr || result.stdout || "").trim()}` : ""; + throw new Error(`${program} ${args.join(" ")} exited ${result.status}${detail}`); + } + return capture ? result.stdout.trim() : ""; +} + +async function writeJsonPlan(path, program, args) { + const output = run(program, args, { capture: true }); + let plan; + try { + plan = JSON.parse(output); + } catch { + throw new Error(`${program} dry run did not return one JSON plan`); + } + await writeFile(path, `${JSON.stringify(plan, null, 2)}\n`); + return plan; +} + +async function fetchJson(url, label) { + const response = await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(10_000) }); + if (!response.ok) throw new Error(`${label} returned HTTP ${response.status}`); + return response.json(); +} + +async function waitForAuthority() { + let last = "no response"; + for (let attempt = 0; attempt < 30; attempt += 1) { + try { + const [health, ready] = await Promise.all([ + fetch(`${DEFAULT_WORLD_ORIGIN}/healthz`, { cache: "no-store", signal: AbortSignal.timeout(5_000) }), + fetchJson(`${DEFAULT_WORLD_ORIGIN}/readyz`, "authority readiness"), + ]); + if (health.ok && ready.ready === true && Object.values(ready.checks ?? {}).every((value) => value === true)) return; + last = `health=${health.status} ready=${String(ready.ready)}`; + } catch (error) { + last = error instanceof Error ? error.message : String(error); + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 2_000)); + } + throw new Error(`authority did not recover: ${last}`); +} + +async function assertCleanAndPushed(args) { + const status = run("git", ["status", "--porcelain", "--untracked-files=all"], { capture: true }); + if (status) throw new Error("deployment requires a clean source tree"); + const commit = run("git", ["rev-parse", "HEAD"], { capture: true }); + const branch = run("git", ["branch", "--show-current"], { capture: true }); + if (!branch) throw new Error("deployment requires a named branch"); + const remote = run("git", ["ls-remote", "origin", `refs/heads/${branch}`], { capture: true }).split(/\s+/u)[0]; + if (remote !== commit) throw new Error(`origin/${branch} does not match HEAD`); + return commit; +} + +function deployPaths(prefix) { + const root = join(ROOT, "tmp", `rust-beta-deploy-${prefix}`); + return { + root, + betaPublish: join(root, "beta-publish"), + betaPromotion: join(root, "beta-promotion"), + sitePublish: join(root, "site-publish"), + sitePromotion: join(root, "site-promotion"), + ssmParameters: join(root, "ssm-parameters.json"), + }; +} + +export function publisherArgs(paths, mode) { + const args = [ + "ops/deploy/scripts/publish-client-assets.mjs", + "--dist", "client-rust/out/web-release", + "--object-store", + "--cdn-origin", DEFAULT_ASSET_ORIGIN, + "--store-origin", DEFAULT_SITE_ORIGIN, + "--output-dir", paths.betaPublish, + ]; + if (mode === "--apply") args.push("--bucket", `s3://${DEFAULT_ASSET_BUCKET}`); + args.push(mode); + return args; +} + +function betaPromotionArgs(paths, identity, serverReleaseId, mode) { + return [ + "ops/deploy/scripts/promote-client-runtime.mjs", + "--pointer", join(paths.betaPublish, "current.json"), + "--source-commit", identity.sourceCommit, + "--client-release-id", identity.clientReleaseId, + "--channel", "beta", + "--server-release-id", serverReleaseId, + "--destination", "site/current/beta/release.json", + "--site-bucket", DEFAULT_SITE_BUCKET, + "--output-dir", paths.betaPromotion, + mode, + ]; +} + +async function discoverInstanceId(args, env) { + if (args.instanceId) return args.instanceId; + const response = JSON.parse(run("aws", [ + "ec2", "describe-instances", + "--region", args.region, + "--filters", + `Name=tag:Name,Values=${args.instanceName}`, + "Name=instance-state-name,Values=running", + "--output", "json", + ], { env, capture: true })); + return instanceIdFromDescribeResponse(response); +} + +async function verifyPromotion(identity, manifestSha256, siteIdentity) { + let last = "no response"; + for (let attempt = 0; attempt < 30; attempt += 1) { + try { + const pointer = await fetchJson(`${DEFAULT_BETA_POINTER}?verify=${Date.now()}`, "public beta pointer"); + if ( + pointer.sourceCommit === identity.sourceCommit + && pointer.clientReleaseId === identity.clientReleaseId + && pointer.manifestSha256 === manifestSha256 + ) { + const entry = await fetch(pointer.entry, { cache: "no-store", signal: AbortSignal.timeout(10_000) }); + if (!entry.ok) throw new Error(`immutable beta entry returned HTTP ${entry.status}`); + if (siteIdentity) { + const betaPage = await fetch(`${DEFAULT_SITE_ORIGIN}/beta/?verify=${Date.now()}`, { cache: "no-store", signal: AbortSignal.timeout(10_000) }); + if (!betaPage.ok) throw new Error(`public beta shell returned HTTP ${betaPage.status}`); + } + return; + } + last = "pointer still names the previous release"; + } catch (error) { + last = error instanceof Error ? error.message : String(error); + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 2_000)); + } + throw new Error(`public beta verification timed out: ${last}`); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const sourceCommit = await assertCleanAndPushed(args); + const identity = releaseIdentity(sourceCommit); + const paths = deployPaths(identity.prefix); + await mkdir(paths.root, { recursive: true }); + + const awsEnv = args.awsEnv + ? { ...process.env, ...parseAwsEnv(await readFile(resolve(expandHome(args.awsEnv)), "utf8")) } + : process.env; + if (args.apply) run("aws", ["sts", "get-caller-identity", "--output", "json"], { env: awsEnv }); + const livePointer = await fetchJson(DEFAULT_BETA_POINTER, "live beta pointer"); + const serverReleaseId = args.serverReleaseId || livePointer.serverReleaseId; + if (!isSafeReleaseValue(serverReleaseId)) throw new Error("live beta pointer has no safe server release id"); + + if (!args.skipGates) { + for (const target of ["verify", "check-allocs", "runtime-check", "render-check", "terrain-check", "nostd"]) { + run("make", ["-C", "client-rust", target]); + } + if (args.site) run("pnpm", ["site:test"]); + } + if (args.site) run("pnpm", ["site:build"]); + + run("make", [ + "-C", "client-rust", "web-release", + `SOURCE_COMMIT=${identity.sourceCommit}`, + `CLIENT_RELEASE_ID=${identity.clientReleaseId}`, + `SERVER_RELEASE_ID=${serverReleaseId}`, + `STOREFRONT_ORIGIN=${DEFAULT_SITE_ORIGIN}`, + `GAME_ORIGIN=wss://world.successorgame.com`, + `CHAT_ORIGIN=wss://world.successorgame.com`, + ]); + const betaPublishPlan = await writeJsonPlan( + join(paths.root, "beta-publish-plan.json"), + "node", + publisherArgs(paths, "--dry-run"), + ); + await writeJsonPlan( + join(paths.root, "beta-promotion-plan.json"), + "node", + betaPromotionArgs(paths, identity, serverReleaseId, "--dry-run"), + ); + + let siteIdentity = null; + if (args.site) { + const date = new Date().toISOString().slice(0, 10).replaceAll("-", ""); + const siteReleaseId = `site-${identity.sourceCommit.slice(0, 7)}-${date}`; + await writeJsonPlan(join(paths.root, "site-publish-plan.json"), "node", [ + "ops/deploy/scripts/publish-site.mjs", + "--dist", "site/dist", + "--output-dir", paths.sitePublish, + "--site-release-id", siteReleaseId, + "--dry-run", + ]); + const siteManifest = JSON.parse(await readFile(join(paths.sitePublish, "site-manifest.json"), "utf8")); + siteIdentity = { siteReleaseId, manifestSha256: siteManifest.manifest_sha256 }; + await writeJsonPlan(join(paths.root, "site-promotion-plan.json"), "node", [ + "ops/deploy/scripts/promote-site.mjs", + "--manifest", join(paths.sitePublish, "site-manifest.json"), + "--bucket", `s3://${DEFAULT_SITE_BUCKET}`, + "--dist", "site/dist", + "--expected-manifest-sha256", siteIdentity.manifestSha256, + "--output-dir", paths.sitePromotion, + "--dry-run", + ]); + } + + if (args.dryRun) { + console.log(JSON.stringify({ mode: "dry-run", ...identity, serverReleaseId, manifestSha256: betaPublishPlan.manifest_sha256, site: siteIdentity, evidenceDir: paths.root }, null, 2)); + return; + } + + // AWS identity was verified before running gates and builds. + run("node", publisherArgs(paths, "--apply"), { env: awsEnv }); + const publishedPointer = JSON.parse(await readFile(join(paths.betaPublish, "current.json"), "utf8")); + + if (args.site) { + run("node", [ + "ops/deploy/scripts/publish-site.mjs", + "--dist", "site/dist", + "--output-dir", paths.sitePublish, + "--site-release-id", siteIdentity.siteReleaseId, + "--bucket", `s3://${DEFAULT_SITE_BUCKET}`, + "--apply", + ], { env: awsEnv }); + } + + const status = await fetchJson(`${DEFAULT_WORLD_ORIGIN}/game/status`, "authority status"); + if (status.sessionCount !== 0) throw new Error(`refusing authority restart with ${status.sessionCount} live session(s)`); + const instanceId = await discoverInstanceId(args, awsEnv); + await writeFile(paths.ssmParameters, `${JSON.stringify({ command: [buildRemoteAllowlistCommand(identity.clientReleaseId)] })}\n`, { mode: 0o600 }); + run("aws", [ + "ssm", "start-session", + "--target", instanceId, + "--region", args.region, + "--document-name", "AWS-StartInteractiveCommand", + "--parameters", `file://${paths.ssmParameters}`, + ], { env: awsEnv }); + await waitForAuthority(); + + if (args.site) { + run("node", [ + "ops/deploy/scripts/promote-site.mjs", + "--manifest", join(paths.sitePublish, "site-manifest.json"), + "--bucket", `s3://${DEFAULT_SITE_BUCKET}`, + "--dist", "site/dist", + "--expected-manifest-sha256", siteIdentity.manifestSha256, + "--output-dir", paths.sitePromotion, + "--apply", + ], { env: awsEnv }); + const authenticatedSitePointer = JSON.parse(run("aws", [ + "s3", "cp", `s3://${DEFAULT_SITE_BUCKET}/site/current.json`, "-", "--no-progress", + ], { env: awsEnv, capture: true })); + if ( + authenticatedSitePointer.site_release_id !== siteIdentity.siteReleaseId + || authenticatedSitePointer.manifest_sha256 !== siteIdentity.manifestSha256 + ) { + throw new Error("authenticated site pointer does not match the promoted release"); + } + } + run("node", betaPromotionArgs(paths, identity, serverReleaseId, "--apply"), { env: awsEnv }); + await verifyPromotion(identity, publishedPointer.manifestSha256, siteIdentity); + + console.log(JSON.stringify({ + mode: "apply", + ...identity, + serverReleaseId, + manifestSha256: publishedPointer.manifestSha256, + instanceId, + site: siteIdentity, + evidenceDir: paths.root, + }, null, 2)); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(`deploy-rust-beta: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); +} diff --git a/ops/deploy/scripts/deploy-rust-beta.test.mjs b/ops/deploy/scripts/deploy-rust-beta.test.mjs new file mode 100644 index 00000000..b8e678ce --- /dev/null +++ b/ops/deploy/scripts/deploy-rust-beta.test.mjs @@ -0,0 +1,109 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + buildRemoteAllowlistCommand, + instanceIdFromDescribeResponse, + parseArgs, + parseAwsEnv, + publisherArgs, + releaseIdentity, +} from "./deploy-rust-beta.mjs"; + +test("requires one explicit mode and keeps production-safe defaults", () => { + const args = parseArgs(["--dry-run"], {}); + assert.equal(args.dryRun, true); + assert.equal(args.apply, false); + assert.equal(args.site, false); + assert.equal(args.skipGates, false); + assert.equal(args.instanceName, "successor-staging-1"); + assert.equal(args.region, "us-east-1"); + assert.throws(() => parseArgs([], {}), /choose exactly one/); + assert.throws(() => parseArgs(["--apply", "--dry-run"], {}), /choose exactly one/); +}); + +test("parses the complete apply shape without evaluating credential files", () => { + const args = parseArgs([ + "--apply", + "--site", + "--aws-env", "~/successor-access/aws.env", + "--instance-id", "i-0123456789abcdef0", + "--region", "us-west-2", + "--server-release-id", "planetfall-v5", + "--skip-gates", + ], {}); + assert.equal(args.apply, true); + assert.equal(args.site, true); + assert.equal(args.awsEnv, "~/successor-access/aws.env"); + assert.equal(args.instanceId, "i-0123456789abcdef0"); + assert.equal(args.region, "us-west-2"); + assert.equal(args.serverReleaseId, "planetfall-v5"); + assert.equal(args.skipGates, true); + assert.throws(() => parseArgs(["--apply"], {}), /requires --aws-env/); +}); + +test("loads only inert AWS credential assignments", () => { + assert.deepEqual(parseAwsEnv([ + "# generated access", + "AWS_ACCESS_KEY_ID=AKIAEXAMPLE", + "AWS_SECRET_ACCESS_KEY='secret-value'", + "AWS_REGION=us-east-1", + "", + ].join("\n")), { + AWS_ACCESS_KEY_ID: "AKIAEXAMPLE", + AWS_SECRET_ACCESS_KEY: "secret-value", + AWS_REGION: "us-east-1", + }); + assert.throws(() => parseAwsEnv("export AWS_ACCESS_KEY_ID=bad"), /invalid AWS env line/); + assert.throws(() => parseAwsEnv("PATH=/tmp/bin"), /unsupported AWS env key/); + assert.throws(() => parseAwsEnv("AWS_ACCESS_KEY_ID=$(echo bad)\nNOT_A_LINE"), /invalid AWS env line/); +}); + +test("derives the beta identity from the exact source commit", () => { + const commit = "2d9f1e14467efcf526712ee54aa4840911d433aa"; + assert.deepEqual(releaseIdentity(commit), { + sourceCommit: commit, + prefix: "2d9f1e14467efcf5", + clientReleaseId: "successor-rust-beta@2d9f1e14467efcf5", + }); + assert.throws(() => releaseIdentity("2d9f1e1"), /40-character/); +}); + +test("builds one idempotent allowlist-and-restart command from a safe id", () => { + const releaseId = "successor-rust-beta@2d9f1e14467efcf5"; + const command = buildRemoteAllowlistCommand(releaseId); + assert.match(command, new RegExp(releaseId)); + assert.match(command, /grep -q/); + assert.match(command, /sed -i/); + assert.match(command, /systemctl restart successor\.service/); + assert.match(command, /systemctl is-active successor\.service/); + assert.throws( + () => buildRemoteAllowlistCommand("successor-rust-beta@ok; sudo reboot"), + /unsafe beta release id/, + ); +}); + +test("keeps dry-run publication offline and adds the real bucket only for apply", () => { + const paths = { betaPublish: "tmp/beta-publish" }; + const dryRun = publisherArgs(paths, "--dry-run"); + assert.equal(dryRun.includes("--bucket"), false); + assert.equal(dryRun.at(-1), "--dry-run"); + const apply = publisherArgs(paths, "--apply"); + assert.deepEqual( + apply.slice(apply.indexOf("--bucket"), apply.indexOf("--bucket") + 2), + ["--bucket", "s3://successor-assets-5a537a77"], + ); + assert.equal(apply.at(-1), "--apply"); +}); + +test("accepts exactly one running authority instance", () => { + assert.equal(instanceIdFromDescribeResponse({ + Reservations: [{ Instances: [{ InstanceId: "i-0123456789abcdef0" }] }], + }), "i-0123456789abcdef0"); + assert.throws(() => instanceIdFromDescribeResponse({ Reservations: [] }), /found 0/); + assert.throws(() => instanceIdFromDescribeResponse({ + Reservations: [{ Instances: [ + { InstanceId: "i-0123456789abcdef0" }, + { InstanceId: "i-0fedcba9876543210" }, + ] }], + }), /found 2/); +}); diff --git a/ops/deploy/scripts/promote-client-runtime.mjs b/ops/deploy/scripts/promote-client-runtime.mjs index 13d30e8e..30f0474b 100755 --- a/ops/deploy/scripts/promote-client-runtime.mjs +++ b/ops/deploy/scripts/promote-client-runtime.mjs @@ -4,23 +4,27 @@ import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; -const RELEASE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}(?:@[0-9a-f]{8,64})?$/u; +const CLIENT_RELEASE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}(?:@[0-9a-f]{8,64})?$/u; +const SERVER_RELEASE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u; const HASH = /^[0-9a-f]{64}$/u; const COMMIT = /^[0-9a-f]{40}$/u; export function parseArgs(argv) { - const args = { dryRun: false, apply: false, outputDir: ".successor-client-promotion" }; + const args = { dryRun: false, apply: false, outputDir: ".successor-client-promotion", destination: "site/current/client/release.json" }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === "--dry-run") args.dryRun = true; else if (arg === "--apply") args.apply = true; - else if (["--pointer", "--source-commit", "--client-release-id", "--site-bucket", "--output-dir"].includes(arg)) args[arg.slice(2).replaceAll("-", "_")] = argv[++i]; + else if (["--pointer", "--source-commit", "--client-release-id", "--server-release-id", "--channel", "--destination", "--site-bucket", "--output-dir"].includes(arg)) args[arg.slice(2).replaceAll("-", "_")] = argv[++i]; else throw new Error(`unknown argument: ${arg}`); } if (args.dryRun === args.apply) throw new Error("choose exactly one of --dry-run or --apply"); for (const name of ["pointer", "source_commit", "client_release_id"]) if (!args[name]) throw new Error(`--${name.replaceAll("_", "-")} is required`); if (args.apply && !args.site_bucket) throw new Error("--site-bucket is required with --apply"); if (args.site_bucket && !/^[A-Za-z0-9.!_-]{3,63}$/u.test(args.site_bucket)) throw new Error("--site-bucket must be an S3 bucket name"); + if (!/^site\/current\/(?:client|beta)\/release\.json$/u.test(args.destination)) throw new Error("--destination must be the stable or beta runtime pointer"); + if (args.channel !== undefined && args.channel !== "beta") throw new Error("--channel must be beta when supplied"); + if ((args.channel === "beta") !== Boolean(args.server_release_id)) throw new Error("beta promotion requires --channel beta and --server-release-id together"); return args; } @@ -29,33 +33,38 @@ function exactHttpsOrigin(value, field) { return value; } -export function buildPromotion(pointer, sourceCommit, clientReleaseId) { +export function buildPromotion(pointer, sourceCommit, clientReleaseId, options = {}) { if (!pointer || typeof pointer !== "object" || Array.isArray(pointer)) throw new Error("pointer must be a JSON object"); if (!COMMIT.test(sourceCommit)) throw new Error("--source-commit must be a 40-character lowercase commit"); - if (!RELEASE_ID.test(clientReleaseId)) throw new Error("--client-release-id is invalid"); + if (!CLIENT_RELEASE_ID.test(clientReleaseId)) throw new Error("--client-release-id is invalid"); const entry = pointer.launchPage; if (typeof entry !== "string" || Object.hasOwn(pointer, "entry")) throw new Error("asset pointer requires raw launchPage and must not use site entry shape"); const cdn = exactHttpsOrigin(pointer.cdnOrigin ?? pointer.cdn_origin, "asset pointer cdnOrigin"); - const store = exactHttpsOrigin(pointer.storeOrigin ?? pointer.store_origin, "asset pointer storeOrigin"); + exactHttpsOrigin(pointer.storeOrigin ?? pointer.store_origin, "asset pointer storeOrigin"); const manifestSha256 = pointer.manifestSha256 ?? pointer.manifest_sha256; if (!HASH.test(manifestSha256 ?? "")) throw new Error("asset pointer manifestSha256 must be a sha256"); let parsed; try { parsed = new URL(entry); } catch { throw new Error("asset pointer entry must be an absolute URL"); } if (parsed.origin !== cdn || parsed.search || parsed.hash) throw new Error("asset pointer entry must exactly use the CDN origin without query or fragment"); if (parsed.pathname !== `/releases/${manifestSha256}/index.html`) throw new Error("asset pointer entry must be the immutable release index"); - return { schema: "successor.client-runtime-pointer.v1", entry: parsed.href, manifestSha256, sourceCommit, clientReleaseId }; + const runtime = { schema: "successor.client-runtime-pointer.v1", entry: parsed.href, manifestSha256, sourceCommit, clientReleaseId }; + if (options.channel !== undefined) { + if (options.channel !== "beta" || !SERVER_RELEASE_ID.test(options.serverReleaseId ?? "")) throw new Error("beta pointer requires an exact server release id"); + return { ...runtime, serverReleaseId: options.serverReleaseId, channel: "beta" }; + } + return runtime; } -export async function promote({ pointerPath, sourceCommit, clientReleaseId, siteBucket, outputDir, apply }) { +export async function promote({ pointerPath, sourceCommit, clientReleaseId, serverReleaseId, channel, destination = "site/current/client/release.json", siteBucket, outputDir, apply }) { const pointer = JSON.parse(await readFile(resolve(pointerPath), "utf8")); - const runtimePointer = buildPromotion(pointer, sourceCommit, clientReleaseId); + const runtimePointer = buildPromotion(pointer, sourceCommit, clientReleaseId, { serverReleaseId, channel }); const out = resolve(outputDir); await mkdir(out, { recursive: true }); const proof = join(out, "release.json"); await writeFile(proof, `${JSON.stringify(runtimePointer, null, 2)}\n`); - const destination = `s3://${siteBucket}/site/current/client/release.json`; - const plan = { mode: apply ? "apply" : "dry-run", proof, destination, content_type: "application/json", cache_control: "no-store,no-cache,must-revalidate", pointer: runtimePointer }; + const object = `s3://${siteBucket}/${destination}`; + const plan = { mode: apply ? "apply" : "dry-run", proof, destination: object, content_type: "application/json", cache_control: "no-store,no-cache,must-revalidate", pointer: runtimePointer }; if (apply) { - const proc = spawnSync("aws", ["s3", "cp", proof, destination, "--content-type", "application/json", "--cache-control", "no-store,no-cache,must-revalidate", "--metadata-directive", "REPLACE"], { stdio: "inherit" }); + const proc = spawnSync("aws", ["s3", "cp", proof, object, "--content-type", "application/json", "--cache-control", "no-store,no-cache,must-revalidate", "--metadata-directive", "REPLACE"], { stdio: "inherit" }); if (proc.status !== 0) throw new Error("aws upload failed"); } return plan; @@ -63,7 +72,17 @@ export async function promote({ pointerPath, sourceCommit, clientReleaseId, site async function main() { const args = parseArgs(process.argv.slice(2)); - console.log(JSON.stringify(await promote({ pointerPath: args.pointer, sourceCommit: args.source_commit, clientReleaseId: args.client_release_id, siteBucket: args.site_bucket ?? "SITE_BUCKET", outputDir: args.output_dir, apply: args.apply }), null, 2)); + console.log(JSON.stringify(await promote({ + pointerPath: args.pointer, + sourceCommit: args.source_commit, + clientReleaseId: args.client_release_id, + serverReleaseId: args.server_release_id, + channel: args.channel, + destination: args.destination, + siteBucket: args.site_bucket ?? "SITE_BUCKET", + outputDir: args.output_dir, + apply: args.apply, + }), null, 2)); } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch((error) => { console.error(`promote-client-runtime: ${error.message}`); process.exit(1); }); diff --git a/ops/deploy/scripts/promote-client-runtime.test.mjs b/ops/deploy/scripts/promote-client-runtime.test.mjs index 38e02086..6ecf8540 100644 --- a/ops/deploy/scripts/promote-client-runtime.test.mjs +++ b/ops/deploy/scripts/promote-client-runtime.test.mjs @@ -9,6 +9,23 @@ const good = { launchPage: `https://cdn.example.test/releases/${hash}/index.html test("builds strict storefront pointer", () => { assert.deepEqual(buildPromotion(good, commit, "successor-alpha@a2d02071e180f9df"), { schema: "successor.client-runtime-pointer.v1", entry: good.launchPage, manifestSha256: hash, sourceCommit: commit, clientReleaseId: "successor-alpha@a2d02071e180f9df" }); }); +test("builds a separately versioned beta pointer", () => { + assert.deepEqual( + buildPromotion(good, commit, "successor-rust-beta@a2d02071e180f9df", { + channel: "beta", + serverReleaseId: "server-r1", + }), + { + schema: "successor.client-runtime-pointer.v1", + entry: good.launchPage, + manifestSha256: hash, + sourceCommit: commit, + clientReleaseId: "successor-rust-beta@a2d02071e180f9df", + serverReleaseId: "server-r1", + channel: "beta", + }, + ); +}); test("rejects raw mismatch and unsafe entries", () => { for (const pointer of [{ ...good, entry: good.launchPage }, { ...good, launchPage: `https://evil.example/releases/${hash}/index.html` }, { ...good, launchPage: `${good.launchPage}?x=1` }, { ...good, launchPage: `https://cdn.example.test/releases/${hash}/app.js` }, { ...good, manifestSha256: "nope" }]) assert.throws(() => buildPromotion(pointer, commit, "client-1")); }); diff --git a/ops/deploy/scripts/publish-client-assets.mjs b/ops/deploy/scripts/publish-client-assets.mjs old mode 100644 new mode 100755 index 6a6515e6..720ac901 --- a/ops/deploy/scripts/publish-client-assets.mjs +++ b/ops/deploy/scripts/publish-client-assets.mjs @@ -1,9 +1,10 @@ #!/usr/bin/env node import { createHash } from "node:crypto"; +import { gzipSync } from "node:zlib"; import { readFile, readdir, mkdir, writeFile } from "node:fs/promises"; import { join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; const CONTENT_TYPES = new Map([ [".js", "application/javascript"], @@ -27,21 +28,38 @@ const CONTENT_TYPES = new Map([ [".mp4", "video/mp4"], [".webm", "video/webm"], [".ttf", "font/ttf"], + [".spak", "application/octet-stream"], ]); +// Object-store uploads are precompressed for these types. .spak packs are +// dominated by uncompressed GLB payload, so they gzip well despite containing +// some already-compressed audio. Already-compressed media stays identity. +const GZIP_EXTENSIONS = new Set([".json", ".js", ".mjs", ".css", ".html", ".wasm", ".glb", ".gltf", ".spak"]); + +// Paths that become content-addressed objects under objects/ in +// --object-store mode. Mirrors the streaming predicate in +// client-rust/tools/web-release.mjs; everything else stays release-scoped. +const OBJECT_PATH_PREFIXES = ["assets/", "successor-audio/", "successor-slice/", "render/", "packs/"]; + +const UPLOAD_CONCURRENCY = 16; +const IMMUTABLE_CACHE = "public,max-age=31536000,immutable"; +const POINTER_CACHE = "no-store,no-cache,must-revalidate"; + function parseArgs(argv) { - const args = { dist: "client-3d/dist", outputDir: ".successor-client-publish", dryRun: false, apply: false }; + const args = { dist: "client-3d/dist", outputDir: ".successor-client-publish", dryRun: false, apply: false, objectStore: false }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === "--dry-run") args.dryRun = true; else if (arg === "--apply") args.apply = true; - else if (["--dist", "--output-dir", "--bucket", "--cdn-origin", "--store-origin"].includes(arg)) args[arg.slice(2).replaceAll("-", "_")] = argv[++i]; + else if (arg === "--object-store") args.objectStore = true; + else if (["--dist", "--output-dir", "--bucket", "--cdn-origin", "--store-origin", "--baseline-manifest", "--existing-objects"].includes(arg)) args[arg.slice(2).replaceAll("-", "_")] = argv[++i]; else throw new Error(`unknown argument: ${arg}`); } if (args.dryRun === args.apply) throw new Error("choose exactly one of --dry-run or --apply"); if (!args.cdn_origin) throw new Error("--cdn-origin is required"); if (!/^https:\/\/[^/*]+$/.test(args.store_origin ?? "")) throw new Error("--store-origin must be one exact https origin (no wildcard)"); if (args.apply && !/^s3:\/\/[^/]+$/.test(args.bucket ?? "")) throw new Error("--bucket s3://name is required with --apply"); + if (args.baseline_manifest && args.object_store) throw new Error("--baseline-manifest copy-forward only applies to path-layout (non-object-store) releases"); return args; } @@ -57,8 +75,10 @@ async function filesUnder(root, current = root) { } function sha256(bytes) { return createHash("sha256").update(bytes).digest("hex"); } -function contentType(path) { return CONTENT_TYPES.get(path.slice(path.lastIndexOf(".")).toLowerCase()) ?? "application/octet-stream"; } +function extensionOf(path) { return path.slice(path.lastIndexOf(".")).toLowerCase(); } +function contentType(path) { return CONTENT_TYPES.get(extensionOf(path)) ?? "application/octet-stream"; } function originUrl(origin, key) { return `${origin.replace(/\/$/, "")}/${key}`; } +function isObjectPath(path) { return OBJECT_PATH_PREFIXES.some((prefix) => path.startsWith(prefix)); } const FORBIDDEN_ROOT_ASSET_PREFIXES = [ "/successor-slice/", @@ -85,6 +105,82 @@ export async function assertNoUnprefixedRuntimeAssetPaths(dist) { if (offenders.length) throw new Error(`dist contains unprefixed runtime asset paths: ${offenders.join(", ")}`); return true; } +// "/objects/" is deliberately NOT a forbidden root prefix: content-addressed +// objects are immutable by construction, exactly like a release prefix. + +const REQUIRED_RUST_RUNTIME_ASSETS = [ + "assets/pawn-pack/pawn_male.glb", + "assets/pawn-pack/pawn_female.glb", +]; + +/** Fail closed before publication when a Rust runtime omits fatal body assets. */ +export function assertRequiredRustRuntimeAssets(releaseId, paths) { + if (!releaseId.startsWith("successor-rust-")) return true; + const available = new Set(paths); + const missing = REQUIRED_RUST_RUNTIME_ASSETS.filter((path) => !available.has(path)); + if (missing.length > 0) throw new Error(`Rust runtime is missing required assets: ${missing.join(", ")}`); + return true; +} + +async function rustReleaseDocument(root, releaseId) { + if (!releaseId.startsWith("successor-rust-")) return null; + const path = join(root, "release-manifest.json"); + let release; + try { + release = JSON.parse(await readFile(path, "utf8")); + } catch { + throw new Error("Rust runtime dist must contain release-manifest.json"); + } + if (release.clientReleaseId !== releaseId) throw new Error("Rust release manifest identity mismatch"); + return release; +} + +async function assertRustReleaseManifest(root, releaseId, entries) { + const release = await rustReleaseDocument(root, releaseId); + if (!release) return true; + const published = new Map(entries.map((entry) => [entry.path, entry])); + for (const expected of release.files ?? []) { + const actual = published.get(expected.path); + if (!actual || actual.size !== expected.bytes || actual.sha256 !== expected.sha256) { + throw new Error(`Rust release inventory mismatch: ${expected.path}`); + } + } + if (release.schema === "successor.rust-web-release.v2") { + if (!Array.isArray(release.boot) || release.boot.length === 0) { + throw new Error("Rust v2 release manifest requires a non-empty boot[]"); + } + const packIndex = release.packIndex && typeof release.packIndex === "object" ? release.packIndex : {}; + for (const [id, reference] of Object.entries(packIndex)) { + if (!reference || typeof reference.pack !== "string" || !published.has(reference.pack)) { + throw new Error(`Rust pack index references unpublished pack for: ${id}`); + } + } + for (const initial of release.boot) { + if (!published.has(initial) && !(initial in packIndex)) throw new Error(`Rust boot asset is not published: ${initial}`); + } + // Staged spawn-neighborhood region packs (Phase 5): optional, but every + // listed pack must be a published file. + if (release.bootPacks !== undefined) { + if (!Array.isArray(release.bootPacks) || release.bootPacks.some((path) => typeof path !== "string")) { + throw new Error("Rust v2 release manifest bootPacks must be a string array"); + } + for (const pack of release.bootPacks) { + if (!published.has(pack)) throw new Error(`Rust boot pack is not published: ${pack}`); + } + } + for (const required of REQUIRED_RUST_RUNTIME_ASSETS) { + if (!release.boot.includes(required)) throw new Error(`Rust boot stream omits required asset: ${required}`); + } + return true; + } + if (!Array.isArray(release.files) || !Array.isArray(release.initialAssets) || release.initialAssets.length === 0) { + throw new Error("Rust release manifest requires files[] and a non-empty initialAssets[]"); + } + for (const initial of release.initialAssets) { + if (!published.has(initial)) throw new Error(`Rust initial asset is not published: ${initial}`); + } + return true; +} function localPath(value, origin, field) { if (typeof value !== "string") throw new Error(`dist/current.json ${field} must be a URL`); @@ -97,7 +193,7 @@ function localPath(value, origin, field) { return path; } -export async function buildManifest(dist, cdnOrigin, storeOrigin) { +export async function buildManifest(dist, cdnOrigin, storeOrigin, { objectStore = false } = {}) { const root = resolve(dist); await assertNoUnprefixedRuntimeAssetPaths(root); const sourcePointer = JSON.parse(await readFile(join(root, "current.json"), "utf8")); @@ -121,14 +217,24 @@ export async function buildManifest(dist, cdnOrigin, storeOrigin) { const bytes = await readFile(absolute); const path = relative(root, absolute).split("\\").join("/"); const digest = sha256(bytes); - entries.push({ path, sha256: digest, size: bytes.length, content_type: contentType(path) }); + const entry = { path, sha256: digest, size: bytes.length, content_type: contentType(path) }; + if (objectStore) entry.content_encoding = GZIP_EXTENSIONS.has(extensionOf(path)) ? "gzip" : null; + entries.push(entry); } - const inventory = { schema: "successor-client-assets.v1", release_id: sourcePointer.releaseId, files: entries }; + const rustRelease = await rustReleaseDocument(root, sourcePointer.releaseId); + const rustV2 = rustRelease?.schema === "successor.rust-web-release.v2"; + if (!rustV2) assertRequiredRustRuntimeAssets(sourcePointer.releaseId, entries.map((entry) => entry.path)); + await assertRustReleaseManifest(root, sourcePointer.releaseId, entries); + const inventory = { + schema: objectStore ? "successor-client-assets.v2" : "successor-client-assets.v1", + release_id: sourcePointer.releaseId, + files: entries, + }; const inventoryCanonical = `${JSON.stringify(inventory, null, 2)}\n`; const manifestSha256 = sha256(Buffer.from(inventoryCanonical)); const releasePrefix = `releases/${manifestSha256}`; for (const item of entries) { - item.object_key = `${releasePrefix}/${item.path}`; + item.object_key = objectStore && isObjectPath(item.path) ? `objects/${item.sha256}` : `${releasePrefix}/${item.path}`; item.url = originUrl(cdnOrigin, item.object_key); } const byPath = new Map(entries.map((item) => [item.path, item.url])); @@ -146,27 +252,156 @@ export async function buildManifest(dist, cdnOrigin, storeOrigin) { function assertManifestIntegrity(root, result) { if (result.manifest.manifest_sha256 !== result.manifestSha256) throw new Error("manifest inventory hash mismatch"); for (const item of result.manifest.files) { - if (!/^releases\/[0-9a-f]{64}\/.+/.test(item.object_key) || !item.object_key.endsWith(item.path)) throw new Error(`invalid release object key for ${item.path}`); + const releaseScoped = /^releases\/[0-9a-f]{64}\/.+/.test(item.object_key) && item.object_key.endsWith(item.path); + const objectScoped = new RegExp(`^objects/${item.sha256}$`).test(item.object_key); + if (!releaseScoped && !objectScoped) throw new Error(`invalid release object key for ${item.path}`); } return true; } +async function loadBaselineManifest(value) { + if (!value) return null; + let parsed; + if (/^https:\/\//u.test(value)) { + const response = await fetch(value); + if (!response.ok) throw new Error(`baseline manifest fetch failed: ${response.status}`); + parsed = await response.json(); + } else { + parsed = JSON.parse(await readFile(resolve(value), "utf8")); + } + if (!Array.isArray(parsed.files)) throw new Error("baseline manifest requires files[]"); + const byPath = new Map(); + for (const file of parsed.files) { + if (typeof file.path === "string" && typeof file.sha256 === "string") byPath.set(file.path, file.sha256); + } + const prefixMatch = /^releases\/([0-9a-f]{64})\//.exec(parsed.files.find((file) => typeof file.object_key === "string")?.object_key ?? ""); + if (!prefixMatch) throw new Error("baseline manifest files carry no release prefix"); + return { byPath, releasePrefix: `releases/${prefixMatch[1]}` }; +} + +async function loadExistingObjects(args) { + if (args.existing_objects) { + const text = await readFile(resolve(args.existing_objects), "utf8"); + return new Set(text.split("\n").map((line) => line.trim()).filter(Boolean)); + } + if (!args.bucket) return new Set(); + const listing = await runAws(["s3", "ls", `${args.bucket}/objects/`, "--recursive"]); + const keys = new Set(); + for (const line of listing.split("\n")) { + const match = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s+\d+ (.+)$/u.exec(line.trim()); + if (match) keys.add(match[1]); + } + return keys; +} + +function runAws(args) { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn("aws", args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("error", (error) => rejectPromise(new Error(`aws ${args[0]} ${args[1] ?? ""} failed: ${error.message}`))); + child.on("close", (status) => { + if (status === 0) resolvePromise(stdout); + else rejectPromise(new Error(`aws ${args.slice(0, 2).join(" ")} exited ${status}: ${stderr.trim()}`)); + }); + }); +} + +async function runPool(items, worker, concurrency = UPLOAD_CONCURRENCY) { + let next = 0; + const lanes = Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (next < items.length) { + const item = items[next++]; + await worker(item); + } + }); + await Promise.all(lanes); +} + +/** + * Build the ordered upload/copy plan. Objects first (only keys not already in + * the bucket), then release-scoped files (copy-forward when the baseline + * carries identical content), then the manifest, and current.json strictly + * last so the mutable pointer never leads its immutable tree. + */ +export async function planOperations({ result, dist, out, bucket, baseline, existingObjects }) { + const root = resolve(dist); + const uploads = []; + const copies = []; + const gzDir = join(out, "objects-gz"); + const seenObjects = new Set(); + let skippedExisting = 0; + let copiedForward = 0; + for (const item of result.manifest.files) { + const objectScoped = item.object_key.startsWith("objects/"); + if (objectScoped && (existingObjects.has(item.object_key) || seenObjects.has(item.object_key))) { + skippedExisting += 1; + continue; + } + if (objectScoped) seenObjects.add(item.object_key); + const destination = `${bucket}/${item.object_key}`; + if (!objectScoped && baseline) { + const baselineSha = baseline.byPath.get(item.path); + if (baselineSha === item.sha256) { + copies.push({ kind: "copy", copy_source: `${bucket.replace(/^s3:\/\//u, "")}/${baseline.releasePrefix}/${item.path}`, key: item.object_key, destination }); + copiedForward += 1; + continue; + } + } + let source = join(root, item.path); + if (objectScoped && item.content_encoding === "gzip") { + await mkdir(gzDir, { recursive: true }); + const gzPath = join(gzDir, item.sha256); + await writeFile(gzPath, gzipSync(await readFile(source), { level: 9 })); + source = gzPath; + } + uploads.push({ kind: "cp", source, destination, content_type: item.content_type, cache_control: IMMUTABLE_CACHE, ...(objectScoped && item.content_encoding === "gzip" ? { content_encoding: "gzip" } : {}) }); + } + uploads.push({ kind: "cp", source: join(out, "manifest.json"), destination: `${bucket}/${result.manifestKey}`, content_type: "application/json", cache_control: IMMUTABLE_CACHE }); + uploads.push({ kind: "cp", source: join(out, "current.json"), destination: `${bucket}/current.json`, content_type: "application/json", cache_control: POINTER_CACHE }); + return { uploads, copies, skippedExisting, copiedForward }; +} + +async function applyPlan(plan, bucket) { + const bucketName = bucket.replace(/^s3:\/\//u, ""); + const cpArgs = (op) => { + const args = ["s3", "cp", op.source, op.destination, "--content-type", op.content_type, "--cache-control", op.cache_control, "--metadata-directive", "REPLACE"]; + if (op.content_encoding) args.push("--content-encoding", op.content_encoding); + return args; + }; + const manifestAndPointer = plan.uploads.slice(-2); + const objectUploads = plan.uploads.slice(0, -2).filter((op) => op.destination.includes("/objects/")); + const releaseUploads = plan.uploads.slice(0, -2).filter((op) => !op.destination.includes("/objects/")); + await runPool(objectUploads, async (op) => { await runAws(cpArgs(op)); }); + await runPool(releaseUploads, async (op) => { await runAws(cpArgs(op)); }); + await runPool(plan.copies, async (op) => { + await runAws(["s3api", "copy-object", "--bucket", bucketName, "--copy-source", op.copy_source, "--key", op.key, "--metadata-directive", "COPY"]); + }); + await runAws(cpArgs(manifestAndPointer[0])); + await runAws(cpArgs(manifestAndPointer[1])); +} + async function main() { const args = parseArgs(process.argv.slice(2)); - const result = await buildManifest(args.dist, args.cdn_origin, args.store_origin); + const result = await buildManifest(args.dist, args.cdn_origin, args.store_origin, { objectStore: args.objectStore }); assertManifestIntegrity(args.dist, result); const out = resolve(args.output_dir); await mkdir(out, { recursive: true }); await writeFile(join(out, "manifest.json"), result.canonical); await writeFile(join(out, "current.json"), `${JSON.stringify(result.pointer, null, 2)}\n`); - const operations = result.manifest.files.map((item) => ({ source: join(resolve(args.dist), item.path), destination: `${args.bucket ?? "s3://BUCKET"}/${item.object_key}`, content_type: item.content_type, cache_control: "public,max-age=31536000,immutable" })); - operations.push({ source: join(out, "manifest.json"), destination: `${args.bucket ?? "s3://BUCKET"}/${result.manifestKey}`, content_type: "application/json", cache_control: "public,max-age=31536000,immutable" }); - operations.push({ source: join(out, "current.json"), destination: `${args.bucket ?? "s3://BUCKET"}/current.json`, content_type: "application/json", cache_control: "no-store,no-cache,must-revalidate" }); - if (args.dryRun) { console.log(JSON.stringify({ mode: "dry-run", manifest_sha256: result.manifestSha256, manifest_url: result.pointer.manifestUrl, pointer_url: originUrl(args.cdn_origin, "current.json"), operations }, null, 2)); return; } - for (const op of operations) { - const proc = spawnSync("aws", ["s3", "cp", op.source, op.destination, "--content-type", op.content_type, "--cache-control", op.cache_control, "--metadata-directive", "REPLACE"], { stdio: "inherit" }); - if (proc.status !== 0) throw new Error(`aws upload failed for ${op.destination}`); + const baseline = await loadBaselineManifest(args.baseline_manifest); + const existingObjects = args.objectStore ? await loadExistingObjects(args) : new Set(); + const bucket = args.bucket ?? "s3://BUCKET"; + const plan = await planOperations({ result, dist: args.dist, out, bucket, baseline, existingObjects }); + const operations = [...plan.uploads.slice(0, -2), ...plan.copies, ...plan.uploads.slice(-2)]; + if (args.dryRun) { + console.log(JSON.stringify({ mode: "dry-run", manifest_sha256: result.manifestSha256, manifest_url: result.pointer.manifestUrl, pointer_url: originUrl(args.cdn_origin, "current.json"), skipped_existing_objects: plan.skippedExisting, copied_forward: plan.copiedForward, operations }, null, 2)); + return; } + await applyPlan(plan, bucket); + console.log(JSON.stringify({ mode: "apply", manifest_sha256: result.manifestSha256, uploaded: plan.uploads.length, copied_forward: plan.copiedForward, skipped_existing_objects: plan.skippedExisting }, null, 2)); } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch((error) => { console.error(`publish-client-assets: ${error.message}`); process.exit(1); }); diff --git a/ops/deploy/scripts/publish-client-assets.test.mjs b/ops/deploy/scripts/publish-client-assets.test.mjs index 179be0f6..b5060128 100644 --- a/ops/deploy/scripts/publish-client-assets.test.mjs +++ b/ops/deploy/scripts/publish-client-assets.test.mjs @@ -1,8 +1,14 @@ -import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; +import { mkdtemp, rm, writeFile, mkdir, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { createHash } from "node:crypto"; import { afterEach, describe, expect, it } from "vitest"; -import { assertNoUnprefixedRuntimeAssetPaths } from "./publish-client-assets.mjs"; +import { + assertNoUnprefixedRuntimeAssetPaths, + assertRequiredRustRuntimeAssets, + buildManifest, + planOperations, +} from "./publish-client-assets.mjs"; const roots = []; @@ -10,14 +16,229 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); +async function tempRoot() { + const root = await mkdtemp(join(tmpdir(), "successor-publish-assets-")); + roots.push(root); + return root; +} + +const POINTER = { + releaseId: "successor-rust-beta@test", + launchPage: "http://localhost:5179/index.html", + entryScript: "http://localhost:5179/successor.js", + styles: [], + assetBaseUrl: "http://localhost:5179/", + storeOrigin: "https://store.compress.biz", +}; + +async function rustDist(root, { schema = "successor.rust-web-release.v1", files = [] } = {}) { + const dist = join(root, "dist"); + await mkdir(join(dist, "assets/pawn-pack"), { recursive: true }); + await writeFile(join(dist, "index.html"), "\n"); + await writeFile(join(dist, "successor.js"), "// shim\n"); + await writeFile(join(dist, "assets/pawn-pack/pawn_male.glb"), Buffer.from([1, 2, 3])); + await writeFile(join(dist, "assets/pawn-pack/pawn_female.glb"), Buffer.from([4, 5, 6])); + for (const file of files) { + await mkdir(join(dist, file.path, ".."), { recursive: true }); + await writeFile(join(dist, file.path), file.bytes); + } + await writeFile(join(dist, "current.json"), JSON.stringify(POINTER)); + const releaseManifest = { + schema, + clientReleaseId: POINTER.releaseId, + files: [], + ...(schema === "successor.rust-web-release.v2" + ? { boot: [], packIndex: {} } + : { initialAssets: [] }), + }; + await writeFile(join(dist, "release-manifest.json"), JSON.stringify(releaseManifest)); + return { dist, releaseManifest }; +} + +async function finalizeReleaseManifest(dist, releaseManifest) { + const { readdir } = await import("node:fs/promises"); + const walk = async (dir, base = dir) => { + const out = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) out.push(...await walk(path, base)); + else if (entry.isFile()) out.push(path); + } + return out; + }; + const files = []; + for (const absolute of await walk(dist)) { + const path = absolute.slice(dist.length + 1).split("\\").join("/"); + if (path === "release-manifest.json" || path === "current.json") continue; + const bytes = await readFile(absolute); + files.push({ path, bytes: bytes.byteLength, sha256: createHash("sha256").update(bytes).digest("hex") }); + } + files.sort((a, b) => a.path.localeCompare(b.path)); + releaseManifest.files = files; + if (releaseManifest.schema === "successor.rust-web-release.v2") { + releaseManifest.boot = files.filter((f) => f.path.startsWith("assets/")).map((f) => f.path); + releaseManifest.packIndex = {}; + } else { + releaseManifest.initialAssets = files.map((f) => f.path); + } + await writeFile(join(dist, "release-manifest.json"), JSON.stringify(releaseManifest)); + return releaseManifest; +} + describe("publish client synthetic-prefix gate", () => { it("rejects root runtime asset requests and accepts release-prefixed requests", async () => { - const root = await mkdtemp(join(tmpdir(), "successor-publish-assets-")); - roots.push(root); + const root = await tempRoot(); await mkdir(join(root, "assets"), { recursive: true }); await writeFile(join(root, "assets", "bad.js"), 'fetch("/assets/pawn-pack/pawn_male.glb")'); await expect(assertNoUnprefixedRuntimeAssetPaths(root)).rejects.toThrow("/assets/"); await writeFile(join(root, "assets", "bad.js"), 'fetch("/releases/fakehash/assets/pawn-pack/pawn_male.glb")'); await expect(assertNoUnprefixedRuntimeAssetPaths(root)).resolves.toBe(true); }); + + it("accepts content-addressed object requests", async () => { + const root = await tempRoot(); + await mkdir(join(root, "assets"), { recursive: true }); + await writeFile(join(root, "assets", "ok.js"), 'fetch("/objects/" + "a".repeat(64))'); + await expect(assertNoUnprefixedRuntimeAssetPaths(root)).resolves.toBe(true); + }); +}); + +describe("Rust runtime required-asset gate", () => { + it("rejects a beta inventory without both authored pawn bodies", () => { + expect(() => assertRequiredRustRuntimeAssets( + "successor-rust-beta@abc123", + ["index.html", "successor.js", "assets/pawn-pack/pawn_male.glb"], + )).toThrow("assets/pawn-pack/pawn_female.glb"); + }); + + it("accepts a beta inventory containing both authored pawn bodies", () => { + expect(assertRequiredRustRuntimeAssets( + "successor-rust-beta@abc123", + [ + "index.html", + "successor.js", + "assets/pawn-pack/pawn_male.glb", + "assets/pawn-pack/pawn_female.glb", + ], + )).toBe(true); + }); +}); + +describe("object-store manifest", () => { + it("classifies stream paths as content objects and keeps entry files release-scoped", async () => { + const root = await tempRoot(); + const { dist, releaseManifest } = await rustDist(root); + await finalizeReleaseManifest(dist, releaseManifest); + const result = await buildManifest(dist, "https://cdn.example.test", "https://store.compress.biz", { objectStore: true }); + expect(result.manifest.schema).toBe("successor-client-assets.v2"); + const byPath = new Map(result.manifest.files.map((file) => [file.path, file])); + expect(byPath.get("assets/pawn-pack/pawn_male.glb").object_key).toBe(`objects/${byPath.get("assets/pawn-pack/pawn_male.glb").sha256}`); + expect(byPath.get("index.html").object_key).toBe(`releases/${result.manifestSha256}/index.html`); + expect(byPath.get("successor.js").object_key).toBe(`releases/${result.manifestSha256}/successor.js`); + expect(byPath.get("assets/pawn-pack/pawn_male.glb").content_encoding).toBe("gzip"); + expect(byPath.get("index.html").content_encoding).toBe("gzip"); + }); + + it("keeps legacy path layout and v1 schema without --object-store", async () => { + const root = await tempRoot(); + const { dist, releaseManifest } = await rustDist(root); + await finalizeReleaseManifest(dist, releaseManifest); + const result = await buildManifest(dist, "https://cdn.example.test", "https://store.compress.biz"); + expect(result.manifest.schema).toBe("successor-client-assets.v1"); + expect(result.manifest.files.every((file) => file.object_key.startsWith(`releases/${result.manifestSha256}/`))).toBe(true); + expect(result.manifest.files.every((file) => file.content_encoding === undefined)).toBe(true); + }); + + it("keeps the inventory hash independent of object key assignment", async () => { + const root = await tempRoot(); + const { dist, releaseManifest } = await rustDist(root); + await finalizeReleaseManifest(dist, releaseManifest); + const a = await buildManifest(dist, "https://cdn.example.test", "https://store.compress.biz", { objectStore: true }); + const b = await buildManifest(dist, "https://cdn.other.example", "https://store.compress.biz", { objectStore: true }); + expect(a.manifestSha256).toBe(b.manifestSha256); + }); + + it("validates a v2 boot stream against files and packIndex", async () => { + const root = await tempRoot(); + const { dist, releaseManifest } = await rustDist(root, { + schema: "successor.rust-web-release.v2", + files: [{ path: "packs/boot.spak", bytes: Buffer.from([9, 9, 9]) }], + }); + await finalizeReleaseManifest(dist, releaseManifest); + const result = await buildManifest(dist, "https://cdn.example.test", "https://store.compress.biz", { objectStore: true }); + expect(result.manifest.schema).toBe("successor-client-assets.v2"); + }); + + it("rejects a v2 manifest whose boot omits a required pawn body", async () => { + const root = await tempRoot(); + const { dist, releaseManifest } = await rustDist(root, { schema: "successor.rust-web-release.v2" }); + await finalizeReleaseManifest(dist, releaseManifest); + releaseManifest.boot = releaseManifest.boot.filter((path) => !path.includes("pawn_female")); + await writeFile(join(dist, "release-manifest.json"), JSON.stringify(releaseManifest)); + await expect(buildManifest(dist, "https://cdn.example.test", "https://store.compress.biz", { objectStore: true })) + .rejects.toThrow("pawn_female"); + }); +}); + +describe("publication planning", () => { + it("skips objects already present in the store and uploads pointer last", async () => { + const root = await tempRoot(); + const { dist, releaseManifest } = await rustDist(root); + await finalizeReleaseManifest(dist, releaseManifest); + const out = join(root, "publish"); + await mkdir(out, { recursive: true }); + const result = await buildManifest(dist, "https://cdn.example.test", "https://store.compress.biz", { objectStore: true }); + const male = result.manifest.files.find((file) => file.path === "assets/pawn-pack/pawn_male.glb"); + const existing = new Set([male.object_key]); + const first = await planOperations({ result, dist, out, bucket: "s3://BUCKET", baseline: null, existingObjects: existing }); + expect(first.skippedExisting).toBe(1); + expect(first.uploads.some((op) => op.destination.endsWith(male.object_key))).toBe(false); + expect(first.uploads.at(-1).destination).toBe("s3://BUCKET/current.json"); + expect(first.uploads.at(-2).destination).toContain("/manifests/"); + const everything = new Set(result.manifest.files.filter((file) => file.object_key.startsWith("objects/")).map((file) => file.object_key)); + const second = await planOperations({ result, dist, out, bucket: "s3://BUCKET", baseline: null, existingObjects: everything }); + expect(second.uploads.length).toBe(5); + expect(second.uploads.filter((op) => op.destination.includes("/objects/")).length).toBe(0); + }); + + it("writes gzip payloads only for compressible object types", async () => { + const root = await tempRoot(); + const { dist, releaseManifest } = await rustDist(root, { + files: [{ path: "successor-audio/sfx/clip.mp3", bytes: Buffer.from([73, 68, 51]) }], + }); + await finalizeReleaseManifest(dist, releaseManifest); + const out = join(root, "publish"); + await mkdir(out, { recursive: true }); + const result = await buildManifest(dist, "https://cdn.example.test", "https://store.compress.biz", { objectStore: true }); + const plan = await planOperations({ result, dist, out, bucket: "s3://BUCKET", baseline: null, existingObjects: new Set() }); + const glb = plan.uploads.find((op) => op.content_type === "model/gltf-binary"); + const mp3 = plan.uploads.find((op) => op.content_type === "audio/mpeg"); + expect(glb.content_encoding).toBe("gzip"); + expect(mp3.content_encoding).toBeUndefined(); + const gzBytes = await readFile(glb.source); + expect(gzBytes[0]).toBe(0x1f); + expect(gzBytes[1]).toBe(0x8b); + }); + + it("copies unchanged legacy files forward from the baseline release", async () => { + const root = await tempRoot(); + const { dist, releaseManifest } = await rustDist(root); + await finalizeReleaseManifest(dist, releaseManifest); + const out = join(root, "publish"); + await mkdir(out, { recursive: true }); + const previous = await buildManifest(dist, "https://cdn.example.test", "https://store.compress.biz"); + await writeFile(join(dist, "successor.js"), "// shim v2\n"); + await finalizeReleaseManifest(dist, releaseManifest); + const next = await buildManifest(dist, "https://cdn.example.test", "https://store.compress.biz"); + const baseline = { + byPath: new Map(previous.manifest.files.map((file) => [file.path, file.sha256])), + releasePrefix: `releases/${previous.manifestSha256}`, + }; + const plan = await planOperations({ result: next, dist, out, bucket: "s3://BUCKET", baseline, existingObjects: new Set() }); + const copiedPaths = plan.copies.map((op) => op.key); + expect(copiedPaths).toContain(`releases/${next.manifestSha256}/index.html`); + expect(copiedPaths).not.toContain(`releases/${next.manifestSha256}/successor.js`); + expect(plan.copies.every((op) => op.copy_source.startsWith(`BUCKET/releases/${previous.manifestSha256}/`))).toBe(true); + expect(plan.uploads.some((op) => op.destination.endsWith("/successor.js"))).toBe(true); + }); }); diff --git a/ops/deploy/scripts/publish-site.mjs b/ops/deploy/scripts/publish-site.mjs index 50e2ef19..57e00802 100644 --- a/ops/deploy/scripts/publish-site.mjs +++ b/ops/deploy/scripts/publish-site.mjs @@ -11,7 +11,11 @@ const CONTENT_TYPES = new Map([ [".woff", "font/woff"], [".woff2", "font/woff2"], [".ttf", "font/ttf"], [".otf", "font/otf"], [".mp3", "audio/mpeg"], [".ogg", "audio/ogg"], [".wav", "audio/wav"], [".mp4", "video/mp4"], [".webm", "video/webm"], ]); const SMOKE_EXTENSIONS = new Set([".html", ".mp3", ".ogg", ".wav", ".woff", ".woff2", ".ttf", ".otf"]); -export const RESERVED_SITE_PATHS = new Set(["downloads/manifest.json"]); +export const RESERVED_SITE_PATHS = new Set([ + "downloads/manifest.json", + "client/release.json", + "beta/release.json", +]); export const PUBLIC_SITE_POINTER_PATH = "/current.json"; export const PUBLIC_SITE_POINTER_OBJECT_KEY = "site/current.json"; diff --git a/ops/deploy/terraform/bootstrap/.terraform.lock.hcl b/ops/deploy/terraform/bootstrap/.terraform.lock.hcl index 02ad3f92..baf14984 100644 --- a/ops/deploy/terraform/bootstrap/.terraform.lock.hcl +++ b/ops/deploy/terraform/bootstrap/.terraform.lock.hcl @@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/aws" { version = "6.56.0" constraints = "~> 6.0" hashes = [ + "h1:MhideOrA+/8rovaA9BC23G4dDrFNZ0mfrRMNptZBIS0=", "h1:iWz9BgFQaDPA1ChVGYvgI3MlUnx3wSQCA2ggYqDPcz8=", "zh:2b3fbb3bebcc663b85d5fd9bbc2d131ab89322d696ff5c6ac6b7ffb7b5fe92e7", "zh:30e56ccc7f33a7778ab323a28fe893d8e9200dc5fb92ccb7023bee808db3c1b0", diff --git a/ops/deploy/terraform/envs/staging/.terraform.lock.hcl b/ops/deploy/terraform/envs/staging/.terraform.lock.hcl index 02ad3f92..baf14984 100644 --- a/ops/deploy/terraform/envs/staging/.terraform.lock.hcl +++ b/ops/deploy/terraform/envs/staging/.terraform.lock.hcl @@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/aws" { version = "6.56.0" constraints = "~> 6.0" hashes = [ + "h1:MhideOrA+/8rovaA9BC23G4dDrFNZ0mfrRMNptZBIS0=", "h1:iWz9BgFQaDPA1ChVGYvgI3MlUnx3wSQCA2ggYqDPcz8=", "zh:2b3fbb3bebcc663b85d5fd9bbc2d131ab89322d696ff5c6ac6b7ffb7b5fe92e7", "zh:30e56ccc7f33a7778ab323a28fe893d8e9200dc5fb92ccb7023bee808db3c1b0", diff --git a/ops/deploy/terraform/modules/successor-shard/site.tf b/ops/deploy/terraform/modules/successor-shard/site.tf index fd992a47..765a6a2c 100644 --- a/ops/deploy/terraform/modules/successor-shard/site.tf +++ b/ops/deploy/terraform/modules/successor-shard/site.tf @@ -237,6 +237,23 @@ resource "aws_cloudfront_distribution" "site" { } } + dynamic "ordered_cache_behavior" { + for_each = toset(["/client/release.json", "/beta/release.json"]) + content { + path_pattern = ordered_cache_behavior.value + target_origin_id = "site-s3" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + cache_policy_id = data.aws_cloudfront_cache_policy.site_dynamic.id + response_headers_policy_id = aws_cloudfront_response_headers_policy.site_security.id + function_association { + event_type = "viewer-request" + function_arn = aws_cloudfront_function.site_rewrite.arn + } + } + } + ordered_cache_behavior { path_pattern = "/alpha-api/*" target_origin_id = "successor-alb" diff --git a/ops/docker/Dockerfile b/ops/docker/Dockerfile index 67e48f82..455f3ac7 100644 --- a/ops/docker/Dockerfile +++ b/ops/docker/Dockerfile @@ -10,7 +10,9 @@ WORKDIR /workspace COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY server/package.json ./server/package.json COPY client/package.json ./client/package.json -RUN pnpm install --frozen-lockfile --filter @successor/server... --filter @successor/client... +# pnpm store cache: dependency tarballs survive source-only rebuilds. +RUN --mount=type=cache,id=successor-pnpm-store,target=/pnpm/store \ + pnpm install --frozen-lockfile --store-dir=/pnpm/store --filter @successor/server... --filter @successor/client... COPY server ./server COPY client ./client COPY tools/codegen/generated ./tools/codegen/generated @@ -31,8 +33,16 @@ COPY Cargo.toml Cargo.lock ./ COPY crates ./crates COPY client/public/successor-slice ./client/public/successor-slice COPY tools/denylist/denylist.txt ./tools/denylist/denylist.txt -RUN cargo build --locked --release -p successor-sim --example authority_bridge_server -RUN file target/release/examples/authority_bridge_server | grep -q "ELF 64-bit.*x86-64" +# Cargo registry + target caches: a TypeScript-only change must not replay a +# full Rust dependency build. The target dir is a cache mount, so the finished +# bridge is copied out of it into the stage filesystem for the runtime COPY. +RUN --mount=type=cache,id=successor-cargo-registry,target=/usr/local/cargo/registry \ + --mount=type=cache,id=successor-cargo-git,target=/usr/local/cargo/git \ + --mount=type=cache,id=successor-cargo-target,target=/workspace/target \ + mkdir -p /out \ + && cargo build --locked --release -p successor-sim --example authority_bridge_server \ + && cp target/release/examples/authority_bridge_server /out/authority_bridge_server \ + && file /out/authority_bridge_server | grep -q "ELF 64-bit.*x86-64" FROM --platform=$TARGETPLATFORM node:22-bookworm-slim@sha256:f32b81066cde10a75dbac96646099533316d94bac4150c55da1636e1f0ffdc46 AS runtime ARG TARGETARCH @@ -53,7 +63,7 @@ ENV NODE_ENV=production \ GAME_SLICE_PATH=/app/client/public/successor-slice/open-desert-slice.json COPY --from=node-build --chown=successor:successor /out/server/ ./ -COPY --from=rust-build --chown=successor:successor /workspace/target/release/examples/authority_bridge_server ./bin/authority_bridge_server +COPY --from=rust-build --chown=successor:successor /out/authority_bridge_server ./bin/authority_bridge_server USER successor EXPOSE 28093 diff --git a/ops/docker/Dockerfile.dev b/ops/docker/Dockerfile.dev new file mode 100644 index 00000000..33e63ae7 --- /dev/null +++ b/ops/docker/Dockerfile.dev @@ -0,0 +1,63 @@ +# Local DEV authority image (NOT for publication — ops/deploy owns releases). +# +# Differs from the production Dockerfile: builds natively for the host arch +# (no amd64 pin, no emulation), runs in `legacy` control-plane mode with dev +# identity enabled, ships `util-linux` so the state-lock supervisor's +# `/usr/bin/flock` exists, and builds the Rust bridge in debug for speed. This +# lets a native macOS client run a full local round-trip against a Linux +# container (macOS lacks flock). Persistence is left ON so logout/relog works. + +FROM node:22-bookworm-slim AS node-build +RUN npm install --global pnpm@10.33.0 +WORKDIR /workspace +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY server/package.json ./server/package.json +COPY client/package.json ./client/package.json +# pnpm store cache: dependency tarballs survive source-only rebuilds. +RUN --mount=type=cache,id=successor-pnpm-store-dev,target=/pnpm/store \ + pnpm install --frozen-lockfile --store-dir=/pnpm/store --filter @successor/server... --filter @successor/client... +COPY server ./server +COPY client ./client +COPY tools/codegen/generated ./tools/codegen/generated +RUN pnpm --dir server build \ + && pnpm --filter @successor/server --prod deploy --legacy /out/server \ + && cp -a server/dist /out/server/dist \ + && mkdir -p /out/server/client/public \ + && cp -a client/public/successor-slice /out/server/client/public/ + +FROM rust:1.85-bookworm AS rust-build +WORKDIR /workspace +COPY Cargo.toml Cargo.lock ./ +COPY crates ./crates +COPY client/public/successor-slice ./client/public/successor-slice +COPY tools/denylist/denylist.txt ./tools/denylist/denylist.txt +# Cargo registry + target caches (dev lane, host arch). The target dir is a +# cache mount, so the finished bridge is copied out of it for the runtime COPY. +RUN --mount=type=cache,id=successor-cargo-registry-dev,target=/usr/local/cargo/registry \ + --mount=type=cache,id=successor-cargo-git-dev,target=/usr/local/cargo/git \ + --mount=type=cache,id=successor-cargo-target-dev,target=/workspace/target \ + mkdir -p /out \ + && cargo build --locked -p successor-sim --example authority_bridge_server \ + && cp target/debug/examples/authority_bridge_server /out/authority_bridge_server + +FROM node:22-bookworm-slim AS runtime +RUN apt-get update \ + && apt-get install -y --no-install-recommends util-linux \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /var/lib/successor/state +WORKDIR /app +ENV HOST=0.0.0.0 \ + PORT=28093 \ + SUCCESSOR_CONTROL_PLANE_MODE=legacy \ + GAME_ALLOW_DEV_IDENTITY=1 \ + GAME_SHARD_ID=open-desert-dev \ + GAME_SHARD_PERSISTENCE=1 \ + GAME_CHARACTER_STORE_PATH=/var/lib/successor/characters.json \ + GAME_SHARD_STATE_DIR=/var/lib/successor/state \ + GAME_RUST_AUTHORITY_BRIDGE_BIN=/app/bin/authority_bridge_server \ + GAME_SLICE_PATH=/app/client/public/successor-slice/open-desert-slice.json +COPY --from=node-build /out/server/ ./ +COPY --from=rust-build /out/authority_bridge_server ./bin/authority_bridge_server +EXPOSE 28093 +ENTRYPOINT ["node"] +CMD ["dist/index.js"] diff --git a/ops/docker/Dockerfile.test.mjs b/ops/docker/Dockerfile.test.mjs index caa52124..01e0631f 100644 --- a/ops/docker/Dockerfile.test.mjs +++ b/ops/docker/Dockerfile.test.mjs @@ -8,7 +8,7 @@ const dockerignore = await readFile(new URL("../../.dockerignore", import.meta.u test("authority image uses a narrow cacheable Node build context", () => { assert.doesNotMatch(dockerfile, /COPY \. \./); const manifests = dockerfile.indexOf("COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./"); - const install = dockerfile.indexOf("pnpm install --frozen-lockfile --filter @successor/server... --filter @successor/client..."); + const install = dockerfile.indexOf("pnpm install --frozen-lockfile --store-dir=/pnpm/store --filter @successor/server... --filter @successor/client..."); const serverSource = dockerfile.indexOf("COPY server ./server"); assert.ok(manifests >= 0 && install > manifests && serverSource > install); assert.match(dockerfile, /COPY client \.\/client/); diff --git a/package.json b/package.json index 6bf3b57c..cc3832ee 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "packageManager": "pnpm@10.33.0", "scripts": { "build": "pnpm -r build", + "build:authority-bridge": "CARGO_PROFILE_DEV_OPT_LEVEL=2 cargo build --locked -q -p successor-sim --example authority_bridge_server", "dev": "pnpm -r --parallel dev", "desktop:dev": "pnpm --dir desktop dev", "desktop:build": "pnpm --dir client-3d build && pnpm --dir desktop build", @@ -16,6 +17,8 @@ "site:build": "pnpm --dir site build", "site:test": "pnpm --dir site test && pnpm --dir site check:budget", "server:local:persistent": "node scripts/server-local-persistent.mjs", + "client-rust:build": "make -C client-rust all", + "client-rust:verify": "make -C client-rust verify", "bugreports": "pnpm --dir server bugreports", "lint": "pnpm -r lint", "test": "pnpm -r test", @@ -26,6 +29,7 @@ "release:package:tui": "node tools/release/package-tui.mjs", "test:release:tui": "node --test tools/release/package-tui.test.mjs", "deploy:contract": "bash ops/deploy/validate.sh", + "deploy:rust-beta": "node ops/deploy/scripts/deploy-rust-beta.mjs", "hygiene": "bash tools/hygiene/run.sh", "hygiene:rust": "bash tools/hygiene/run.sh --rust", "hygiene:ts": "bash tools/hygiene/run.sh --ts", @@ -69,20 +73,10 @@ "farm:mac:plan": "node tools/verification/farm/dispatch.mjs plan --remote-host macbook", "denylist": "bash tools/denylist/check.sh", "security:audit": "pnpm audit --prod --audit-level high", - "ci": "pnpm verify:successor-context && pnpm check:commands && pnpm check:coverage && pnpm test:verification && pnpm deploy:contract && bash tools/denylist/check.sh && pnpm security:audit && pnpm -r build && pnpm -r test" + "ci": "pnpm verify:successor-context && pnpm check:commands && pnpm check:coverage && pnpm test:verification && pnpm deploy:contract && bash tools/denylist/check.sh && pnpm security:audit && pnpm build:authority-bridge && pnpm -r build && pnpm -r test" }, "engines": { "node": ">=22.13.0", "pnpm": ">=10.33.0" - }, - "pnpm": { - "overrides": { - "body-parser@<2.3.0": "2.3.0", - "brace-expansion@>=3.0.0 <5.0.8": "5.0.8", - "fast-uri": "3.1.4", - "find-my-way": "9.7.0", - "js-yaml@<4.3.0": "4.3.0", - "postcss@<8.5.18": "8.5.18" - } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bd1c66b..64c6d8e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,10 +7,13 @@ settings: overrides: body-parser@<2.3.0: 2.3.0 brace-expansion@>=3.0.0 <5.0.8: 5.0.8 - fast-uri: 3.1.4 - find-my-way: 9.7.0 + fast-uri@<3.1.5: 3.1.5 + find-my-way@<9.7.0: 9.7.0 js-yaml@<4.3.0: 4.3.0 postcss@<8.5.18: 8.5.18 + qs@>=6.11.1 <=6.15.1: 6.15.2 + undici@>=7.0.0 <7.29.0: 7.29.0 + ws@>=8.0.0 <8.21.0: 8.21.0 importers: @@ -1377,8 +1380,8 @@ packages: fast-querystring@1.1.2: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} - fast-uri@3.1.4: - resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} @@ -2049,8 +2052,8 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} unpipe@1.0.0: @@ -2274,7 +2277,7 @@ snapshots: semver: 7.8.0 sumchecker: 3.0.1 optionalDependencies: - undici: 7.28.0 + undici: 7.29.0 transitivePeerDependencies: - supports-color @@ -2472,7 +2475,7 @@ snapshots: dependencies: ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) - fast-uri: 3.1.4 + fast-uri: 3.1.5 '@fastify/error@4.2.0': {} @@ -2834,7 +2837,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -3169,7 +3172,7 @@ snapshots: '@fastify/merge-json-schemas': 0.2.1 ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-ref-resolver: 3.0.0 rfdc: 1.4.1 @@ -3179,7 +3182,7 @@ snapshots: dependencies: fast-decode-uri-component: 1.0.1 - fast-uri@3.1.4: {} + fast-uri@3.1.5: {} fastify-plugin@5.1.0: {} @@ -3853,7 +3856,7 @@ snapshots: undici-types@7.16.0: {} - undici@7.28.0: + undici@7.29.0: optional: true unpipe@1.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 22f9cec9..7e7b0606 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,7 +13,13 @@ allowBuilds: blockExoticSubdeps: true minimumReleaseAge: 1440 overrides: + body-parser@<2.3.0: 2.3.0 + brace-expansion@>=3.0.0 <5.0.8: 5.0.8 + fast-uri@<3.1.5: 3.1.5 + find-my-way@<9.7.0: 9.7.0 + js-yaml@<4.3.0: 4.3.0 + postcss@<8.5.18: 8.5.18 qs@>=6.11.1 <=6.15.1: 6.15.2 - undici@>=7.0.0 <7.28.0: 7.28.0 + undici@>=7.0.0 <7.29.0: 7.29.0 ws@>=8.0.0 <8.21.0: 8.21.0 strictDepBuilds: true diff --git a/runtime-buildings/market/valley_market.glb b/runtime-buildings/market/valley_market.glb new file mode 100644 index 00000000..8eace2cf --- /dev/null +++ b/runtime-buildings/market/valley_market.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f4e5e0d52e4fe504dcd70ed7b6590e45dc5c2cb69781ac7bab7e530ca40a1a3 +size 9724088 diff --git a/runtime-buildings/market/valley_market.provenance.json b/runtime-buildings/market/valley_market.provenance.json new file mode 100644 index 00000000..4455e9ee --- /dev/null +++ b/runtime-buildings/market/valley_market.provenance.json @@ -0,0 +1,167 @@ +{ + "schema": "successor-asset-provenance/1", + "asset_id": "valley_market", + "asset_kind": "model_glb", + "asset_path": "runtime-buildings/market/valley_market.glb", + "asset_hash": "sha256:1f4e5e0d52e4fe504dcd70ed7b6590e45dc5c2cb69781ac7bab7e530ca40a1a3", + "source_blend_or_script": "tools/successor/assets/market-opus5-rebuild/src/build_market.py (deterministic Blender authoring source)", + "package_script": "tools/successor/assets/market-opus5-rebuild/src/package_runtime.mjs", + "regeneration_command": "cd tools/successor/assets/market-opus5-rebuild && python3 src/gen_textures.py && blender -b --factory-startup -noaudio --python-exit-code 1 -P src/build_market.py -- --stage full && node src/post_gltf.mjs build/market_house_furnished.glb 1.0 && python3 src/manifest.py && node src/package_runtime.mjs", + "source_inputs": { + "source_manifest_sha256": "sha256:965ba1f1bc7b3fd148b5b6849a337b67dec54aad1f9864e9d241ddf9fc4d150b", + "source_collision_sha256": "sha256:4fc7c186fc5dcb7da97e51939958bbf3fc1ebdfa88b90a91d6ce4ba0d74d0e4d", + "furnished_source_sha256": "sha256:1f4e5e0d52e4fe504dcd70ed7b6590e45dc5c2cb69781ac7bab7e530ca40a1a3", + "imported_props": [ + { + "file": "client-3d/public/assets/world-items/bank_terminal_civic.glb", + "sha256": "eb825f81370b014660499bcb7fc67480895b3734683ba6647234203b996c2151", + "bytes": 1034480, + "provenance": "client-3d/public/assets/world-items/bank_terminal_civic.provenance.json", + "manifest": "client-3d/public/assets/world-items/bank_terminal_civic_manifest.json", + "role": "bank / private-value service point, docked in its niche, screens facing the customer approach (+Z)", + "placement_asset_space": { + "x": -2.375, + "y": 0.02, + "z": -1.927, + "yaw_deg_about_Y": 0 + }, + "note": "asset space is glTF: +Y up, +Z public front" + }, + { + "file": "client-3d/public/assets/world-items/trade_terminal.glb", + "sha256": "90a2602192261b3e5f14cc738c49d240ae44776166acf1779239527d23179805", + "bytes": 955232, + "provenance": "client-3d/public/assets/world-items/trade_terminal.provenance.json", + "manifest": "client-3d/public/assets/world-items/trade_terminal_manifest.json", + "role": "trade / exchange service point, docked in its niche, scan bed and readout facing the customer approach (+Z)", + "placement_asset_space": { + "x": 0.475, + "y": 0.02, + "z": -1.9, + "yaw_deg_about_Y": 0 + }, + "note": "asset space is glTF: +Y up, +Z public front" + }, + { + "file": "client-3d/public/assets/world-items/pa_terminal.glb", + "sha256": "237c21c6284b7f6617ae4bc7fbff3e8230888064a6bf5f240c3389abce15086a", + "bytes": 939596, + "provenance": "client-3d/public/assets/world-items/pa_terminal.provenance.json", + "manifest": "client-3d/public/assets/world-items/pa_terminal_manifest.json", + "role": "player-association registry point, docked in its niche, roster screen and reader podium facing the customer approach (+Z)", + "placement_asset_space": { + "x": 3.325, + "y": 0.02, + "z": -1.931, + "yaw_deg_about_Y": 0 + }, + "note": "asset space is glTF: +Y up, +Z public front" + }, + { + "file": "client-3d/public/assets/world-items/chair_frontier_a.glb", + "sha256": "04575d7fe54e7762bedcf1622a15aa31f2b76decca8c2c6f6ce2305a3d03bcb6", + "bytes": 3423744, + "provenance": "client-3d/public/assets/world-items/chair_frontier_a.provenance.json", + "manifest": "client-3d/public/assets/world-items/chair_frontier_a_manifest.json", + "role": "trainer's seat behind the consultation table, facing the customer approach", + "placement_asset_space": { + "x": 4.3, + "y": 0.02, + "z": 3.3, + "yaw_deg_about_Y": -180 + }, + "note": "asset space is glTF: +Y up, +Z public front" + }, + { + "file": "client-3d/public/assets/world-items/chair_frontier_b.glb", + "sha256": "ba6d6901bfde01a5c858fb80db89b29f5e18dd5cb42bb59946fe5d55893c15f2", + "bytes": 3384220, + "provenance": "client-3d/public/assets/world-items/chair_frontier_b.provenance.json", + "manifest": "client-3d/public/assets/world-items/chair_frontier_b_manifest.json", + "role": "visitor seat drawn up to the north side of the consultation table", + "placement_asset_space": { + "x": 3.43, + "y": 0.02, + "z": 2.14, + "yaw_deg_about_Y": 0 + }, + "note": "asset space is glTF: +Y up, +Z public front" + }, + { + "file": "client-3d/public/assets/world-items/crate_planked.glb", + "sha256": "d5ac6f0d9a939d0f4cb77ced8594b013db1d40ebe895c0e1871c1543ea83dfa6", + "bytes": 2336804, + "provenance": "client-3d/public/assets/world-items/crate_planked.provenance.json", + "manifest": null, + "role": "vendor goods delivered at the display line's south end, clear of the counter, the south wall and the queue", + "placement_asset_space": { + "x": -3.2, + "y": 0.02, + "z": 2.1, + "yaw_deg_about_Y": -12 + }, + "note": "asset space is glTF: +Y up, +Z public front" + }, + { + "file": "client-3d/public/assets/world-items/footlocker_frontier.glb", + "sha256": "f8bbc53ff96b0020466da861e6cdce2ac43090db6384d14e8656fa83a71e75ad", + "bytes": 3178584, + "provenance": "client-3d/public/assets/world-items/footlocker_frontier.provenance.json", + "manifest": "client-3d/public/assets/world-items/footlocker_frontier_manifest.json", + "role": "back-of-house storage locker, against the rear wall west of the staff route, clear of the proven corridor", + "placement_asset_space": { + "x": -4.7, + "y": 0.02, + "z": -3.293, + "yaw_deg_about_Y": 0 + }, + "note": "asset space is glTF: +Y up, +Z public front" + } + ] + }, + "contract": { + "runtime_key": "valley_market", + "footprint_cells": [ + 12, + 9 + ], + "footprint_m": [ + 11.4, + 8.55 + ], + "floor_top_y_m": 0.02, + "exterior_entry_facing": "south", + "terminal_cells_kept_clear": { + "bank_terminal_civic": [ + 3, + 3 + ], + "trade_terminal": [ + 6, + 3 + ], + "pa_terminal": [ + 9, + 3 + ], + "trainer_npc": [ + 10, + 6 + ] + }, + "door_node": "door_slide" + }, + "validation": { + "image_embedding": { + "image_count": 41, + "external_image_uri_count": 0 + }, + "authored_clearance_checks": 23, + "authored_clearance_issues": [], + "verifier": "tools/successor/assets/market-opus5-rebuild/src/verify_runtime_package.mjs" + }, + "rights": { + "redistribution_status": "authorized for Successor runtime distribution only; no standalone reuse grant", + "source_license": "Successor proprietary project asset; all rights reserved" + } +} diff --git a/runtime-buildings/market/valley_market_collision.json b/runtime-buildings/market/valley_market_collision.json new file mode 100644 index 00000000..2552085b --- /dev/null +++ b/runtime-buildings/market/valley_market_collision.json @@ -0,0 +1,641 @@ +{ + "schema": "successor.structure-collision.v3", + "source": "valley_market.glb", + "generatedBy": "tools/successor/assets/market-opus5-rebuild/src/package_runtime.mjs", + "contract": { + "runtimeKey": "valley_market", + "footprint_cells": [ + 12, + 9 + ], + "cell_m": 0.95, + "floor_top_y_m": 0.02, + "entry_facing": "south", + "entry_axis": "+Z", + "structuralRoles": [ + "authored_shell", + "service_wall", + "niches", + "flank_recesses", + "entry_structure" + ], + "furnitureRoles": [ + "authored_furnished_interior", + "trainer_consultation", + "back_of_house", + "service_hardware" + ], + "decorativeExcluded": [ + "roof", + "clerestory", + "trim", + "lights", + "loose_visual_props" + ], + "terminal_cells_kept_clear": { + "bank_terminal_civic": [ + 3, + 3 + ], + "trade_terminal": [ + 6, + 3 + ], + "pa_terminal": [ + 9, + 3 + ], + "trainer_npc": [ + 10, + 6 + ] + } + }, + "footprint": { + "minX": -5.7, + "minZ": -4.275, + "maxX": 5.7, + "maxZ": 4.275, + "spanX": 11.4, + "spanZ": 8.55, + "centerX": 0, + "centerZ": 0 + }, + "floor": { + "topY": 0.02, + "slabThicknessM": 0.32 + }, + "walls": [ + { + "id": "shell__wall_west_0", + "minX": -5.56, + "minZ": 2.6, + "maxX": -5.22, + "maxZ": 3.12 + }, + { + "id": "shell__wall_west_1", + "minX": -5.56, + "minZ": -3.95, + "maxX": -5.22, + "maxZ": -1.34 + }, + { + "id": "shell__wall_west_back_0", + "minX": -5.08, + "minZ": -1.34, + "maxX": -4.92, + "maxZ": 2.6 + }, + { + "id": "shell__wall_east_0", + "minX": 5.22, + "minZ": 2.66, + "maxX": 5.56, + "maxZ": 4.06 + }, + { + "id": "shell__wall_east_1", + "minX": 5.22, + "minZ": -2.24, + "maxX": 5.56, + "maxZ": 0.62 + }, + { + "id": "shell__wall_east_2", + "minX": 5.22, + "minZ": -3.95, + "maxX": 5.56, + "maxZ": -3.66 + }, + { + "id": "shell__wall_east_back_0", + "minX": 4.94, + "minZ": 0.62, + "maxX": 5.1, + "maxZ": 2.66 + }, + { + "id": "shell__wall_east_back_1", + "minX": 5.22, + "minZ": -3.66, + "maxX": 5.32, + "maxZ": -2.24 + }, + { + "id": "shell__cheek_w0_s", + "minX": -5.56, + "minZ": 2.6, + "maxX": -4.92, + "maxZ": 2.94 + }, + { + "id": "shell__cheek_w0_n", + "minX": -5.56, + "minZ": -1.68, + "maxX": -4.92, + "maxZ": -1.34 + }, + { + "id": "shell__cheek_e0_s", + "minX": 4.94, + "minZ": 2.66, + "maxX": 5.56, + "maxZ": 3 + }, + { + "id": "shell__cheek_e0_n", + "minX": 4.94, + "minZ": 0.28, + "maxX": 5.56, + "maxZ": 0.62 + }, + { + "id": "shell__cheek_e1_s", + "minX": 5.22, + "minZ": -2.24, + "maxX": 5.56, + "maxZ": -1.9 + }, + { + "id": "shell__cheek_e1_n", + "minX": 5.22, + "minZ": -4, + "maxX": 5.56, + "maxZ": -3.66 + }, + { + "id": "shell__arc_pier", + "minX": -5.56, + "minZ": 0.68, + "maxX": -5.08, + "maxZ": 1.04 + }, + { + "id": "shell__plb_mullion", + "minX": 5.32, + "minZ": -3.23, + "maxX": 5.56, + "maxZ": -2.94 + }, + { + "id": "shell__wall_north_w", + "minX": -5.56, + "minZ": -3.95, + "maxX": 0.725, + "maxZ": -3.61 + }, + { + "id": "shell__wall_north_e", + "minX": 1.875, + "minZ": -3.95, + "maxX": 5.56, + "maxZ": -3.61 + }, + { + "id": "shell__wall_south_w", + "minX": -5.56, + "minZ": 2.78, + "maxX": -1.72, + "maxZ": 3.12 + }, + { + "id": "shell__wall_south_e", + "minX": 1.12, + "minZ": 2.78, + "maxX": 2.72, + "maxZ": 3.12 + }, + { + "id": "shell__bay_south", + "minX": 2.72, + "minZ": 3.72, + "maxX": 5.56, + "maxZ": 4.06 + }, + { + "id": "shell__bay_west", + "minX": 2.72, + "minZ": 2.78, + "maxX": 3.06, + "maxZ": 4.06 + }, + { + "id": "shell__hood_pier_w", + "minX": -2.2, + "minZ": 3.12, + "maxX": -1.72, + "maxZ": 4.16 + }, + { + "id": "shell__hood_pier_e", + "minX": 1.12, + "minZ": 3.12, + "maxX": 1.6, + "maxZ": 4.16 + }, + { + "id": "shell__loggia_pier_0", + "minX": -5.53, + "minZ": 3.75, + "maxX": -5.07, + "maxZ": 4.21 + }, + { + "id": "shell__loggia_pier_1", + "minX": -4.05, + "minZ": 3.79, + "maxX": -3.67, + "maxZ": 4.17 + }, + { + "id": "shell__loggia_pier_2", + "minX": -2.78, + "minZ": 3.82, + "maxX": -2.46, + "maxZ": 4.14 + }, + { + "id": "shell__lowcorner_corner_sw", + "minX": -5.645, + "minZ": 2.78, + "maxX": -5.22, + "maxZ": 3.205 + }, + { + "id": "shell__lowcorner_corner_nw", + "minX": -5.645, + "minZ": -4.035, + "maxX": -5.22, + "maxZ": -3.61 + }, + { + "id": "shell__lowcorner_corner_ne", + "minX": 5.22, + "minZ": -4.035, + "maxX": 5.645, + "maxZ": -3.61 + }, + { + "id": "shell__lowcorner_corner_bay_se", + "minX": 5.22, + "minZ": 3.72, + "maxX": 5.645, + "maxZ": 4.145 + }, + { + "id": "shell__lowcorner_corner_bay_sw", + "minX": 2.635, + "minZ": 3.72, + "maxX": 3.06, + "maxZ": 4.145 + }, + { + "id": "shell__lowcorner_corner_bay_re", + "minX": 2.635, + "minZ": 2.78, + "maxX": 3.06, + "maxZ": 3.205 + }, + { + "id": "shell__west_buttress", + "minX": -5.66, + "minZ": -2.02, + "maxX": -5.56, + "maxZ": -1.34 + }, + { + "id": "shell__service_wall_0", + "minX": -5.22, + "minZ": -2.19, + "maxX": -5.135, + "maxZ": -1.85 + }, + { + "id": "shell__service_wall_1", + "minX": -4.185, + "minZ": -2.19, + "maxX": -3.045, + "maxZ": -1.85 + }, + { + "id": "shell__service_wall_2", + "minX": -1.705, + "minZ": -2.19, + "maxX": -0.195, + "maxZ": -1.85 + }, + { + "id": "shell__service_wall_3", + "minX": 1.145, + "minZ": -2.19, + "maxX": 2.655, + "maxZ": -1.85 + }, + { + "id": "shell__service_wall_4", + "minX": 3.995, + "minZ": -2.19, + "maxX": 5.22, + "maxZ": -1.85 + }, + { + "id": "shell__niche_bank_cheek_w", + "minX": -3.215, + "minZ": -2.32, + "maxX": -3.045, + "maxZ": -2.19 + }, + { + "id": "shell__niche_bank_cheek_e", + "minX": -1.705, + "minZ": -2.32, + "maxX": -1.535, + "maxZ": -2.19 + }, + { + "id": "shell__niche_bank_back", + "minX": -3.045, + "minZ": -2.45, + "maxX": -1.705, + "maxZ": -2.32 + }, + { + "id": "shell__niche_trade_cheek_w", + "minX": -0.365, + "minZ": -2.32, + "maxX": -0.195, + "maxZ": -2.19 + }, + { + "id": "shell__niche_trade_cheek_e", + "minX": 1.145, + "minZ": -2.32, + "maxX": 1.315, + "maxZ": -2.19 + }, + { + "id": "shell__niche_trade_back", + "minX": -0.195, + "minZ": -2.45, + "maxX": 1.145, + "maxZ": -2.32 + }, + { + "id": "shell__niche_assoc_cheek_w", + "minX": 2.485, + "minZ": -2.32, + "maxX": 2.655, + "maxZ": -2.19 + }, + { + "id": "shell__niche_assoc_cheek_e", + "minX": 3.995, + "minZ": -2.32, + "maxX": 4.165, + "maxZ": -2.19 + }, + { + "id": "shell__niche_assoc_back", + "minX": 2.655, + "minZ": -2.45, + "maxX": 3.995, + "maxZ": -2.32 + } + ], + "furniture": [ + { + "id": "fixture__dock_bank_fin_w", + "minX": -3.19, + "minZ": -1.85, + "maxX": -3.07, + "maxZ": -1.5 + }, + { + "id": "fixture__dock_bank_fin_e", + "minX": -1.68, + "minZ": -1.85, + "maxX": -1.56, + "maxZ": -1.5 + }, + { + "id": "fixture__dock_bank_drawer", + "minX": -3.475, + "minZ": -1.85, + "maxX": -3.075, + "maxZ": -1.6 + }, + { + "id": "fixture__dock_bank_ledge", + "minX": -1.705, + "minZ": -1.85, + "maxX": -1.245, + "maxZ": -1.51 + }, + { + "id": "fixture__dock_trade_leg_w", + "minX": -0.465, + "minZ": -1.66, + "maxX": -0.405, + "maxZ": -1.6 + }, + { + "id": "fixture__dock_trade_leg_e", + "minX": 1.355, + "minZ": -1.66, + "maxX": 1.415, + "maxZ": -1.6 + }, + { + "id": "fixture__dock_trade_shelf", + "minX": 1.225, + "minZ": -1.85, + "maxX": 1.625, + "maxZ": -1.55 + }, + { + "id": "fixture__dock_assoc_notice", + "minX": 2.135, + "minZ": -1.85, + "maxX": 2.715, + "maxZ": -1.76 + }, + { + "id": "fixture__dock_assoc_bench", + "minX": 3.955, + "minZ": -1.85, + "maxX": 4.495, + "maxZ": -1.45 + }, + { + "id": "fixture__vendor_counter", + "minX": -4.92, + "minZ": -1.12, + "maxX": -4.18, + "maxZ": 2.2 + }, + { + "id": "fixture__goods_counter", + "minX": -5.46, + "minZ": -1.19, + "maxX": -5.08, + "maxZ": 0.13 + }, + { + "id": "fixture__stock_ledge", + "minX": -5.46, + "minZ": 1.1, + "maxX": -5.08, + "maxZ": 2.34 + }, + { + "id": "fixture__water_basin", + "minX": 5.1, + "minZ": 1.43, + "maxX": 5.55, + "maxZ": 2.29 + }, + { + "id": "fixture__isolator_cabinet", + "minX": 5.32, + "minZ": -3.57, + "maxX": 5.55, + "maxZ": -3.23 + }, + { + "id": "fixture__trainer_table", + "minX": 3.5, + "minZ": 2.5, + "maxX": 4.9, + "maxZ": 2.92 + }, + { + "id": "fixture__trainer_credenza", + "minX": 3.1, + "minZ": 3.34, + "maxX": 3.86, + "maxZ": 3.66 + }, + { + "id": "fixture__boh_bench", + "minX": 4.21, + "minZ": -2.57, + "maxX": 5.25, + "maxZ": -2.2 + }, + { + "id": "fixture__boh_shelving", + "minX": -4.14, + "minZ": -2.57, + "maxX": -3.26, + "maxZ": -2.2 + }, + { + "id": "fixture__boh_switchgear", + "minX": 4.95, + "minZ": -3.55, + "maxX": 5.22, + "maxZ": -2.75 + }, + { + "id": "fixture__boh_filter_0", + "minX": -1.395, + "minZ": -2.575, + "maxX": -0.985, + "maxZ": -2.165 + }, + { + "id": "fixture__boh_filter_1", + "minX": -0.915, + "minZ": -2.575, + "maxX": -0.505, + "maxZ": -2.165 + }, + { + "id": "fixture__boh_valve_panel", + "minX": -0.3, + "minZ": -2.57, + "maxX": 0.04, + "maxZ": -2.45 + }, + { + "id": "fixture__boh_ladder", + "minX": -2.55, + "minZ": -3.61, + "maxX": -2.05, + "maxZ": -3.5 + }, + { + "id": "fixture__nook_bench", + "minX": 4.69, + "minZ": -1.2, + "maxX": 5.22, + "maxZ": 0.08 + }, + { + "id": "fixture__queue_rail", + "minX": -1.59, + "minZ": 1.51, + "maxX": 1.69, + "maxZ": 1.59 + }, + { + "id": "fixture__loggia_ledge", + "minX": -5.25, + "minZ": 3.33, + "maxX": -2.95, + "maxZ": 3.95 + }, + { + "id": "fixture__loggia_bench", + "minX": -3.75, + "minZ": 3.32, + "maxX": -2.65, + "maxZ": 3.76 + } + ], + "door": { + "node": "door_slide", + "closed": { + "id": "closed_door_panel", + "minX": -1.52, + "minZ": 2.66, + "maxX": 0.92, + "maxZ": 2.75 + } + }, + "interiorRegions": [ + { + "id": "hall", + "minX": -5.22, + "minZ": -1.85, + "maxX": 5.22, + "maxZ": 2.7800000000000002, + "floorTopY": 0.02 + }, + { + "id": "back_of_house", + "minX": -5.22, + "minZ": -3.6100000000000003, + "maxX": 5.22, + "maxZ": -2.32, + "floorTopY": 0.02 + }, + { + "id": "trainer_bay", + "minX": 3.06, + "minZ": 2.7800000000000002, + "maxX": 5.22, + "maxZ": 3.7199999999999998, + "floorTopY": 0.02 + } + ], + "sourceCollision": { + "file": "market_house.collision.json", + "sha256": "sha256:4fc7c186fc5dcb7da97e51939958bbf3fc1ebdfa88b90a91d6ce4ba0d74d0e4d", + "authored_box_count": 81, + "body_height_max_m": 1.85, + "body_height_box_count": 76, + "excluded_overhead_box_count": 5 + } +} diff --git a/runtime-buildings/market/valley_market_manifest.json b/runtime-buildings/market/valley_market_manifest.json new file mode 100644 index 00000000..6ed378ce --- /dev/null +++ b/runtime-buildings/market/valley_market_manifest.json @@ -0,0 +1,229 @@ +{ + "schema": "successor.runtime-building-manifest.v1", + "building": "valley_market", + "runtimeKey": "valley_market", + "label": "Valley Market", + "units": "m", + "front": "+Z", + "footprintCells": [ + 12, + 9 + ], + "footprint_max_m": [ + 11.4, + 8.55 + ], + "runtime_scale_at_12x9_cells": 1.0526315789473684, + "floorHeightM": 0.02, + "floorTopY": 0.02, + "exteriorEntry": { + "facing": "south", + "frontAxis": "+Z", + "doorNode": "door_slide", + "doorCells": [ + [ + 4, + 7 + ], + [ + 5, + 7 + ], + [ + 6, + 7 + ] + ] + }, + "door": { + "node": "door_slide", + "node_parent": null, + "movable_child": "door_slide__leaf", + "local_axis": "X", + "closed_local_x_m": 0, + "open_local_x_m": -2.6, + "travel_abs_m": 2.6, + "direction": "opens toward -X (asset west) in local space", + "clips": [ + "door_open", + "door_close" + ], + "clip_seconds": 0.8, + "fps": 30, + "frames": [ + 0, + 24 + ], + "leaf_size_m": [ + 2.44, + 2.52, + 0.09 + ], + "opening_size_m": [ + 2.2, + 2.4 + ], + "axisLocal": [ + -1, + 0, + 0 + ], + "slide_axis_local": [ + -1, + 0, + 0 + ], + "slide_distance_m": 2.6, + "facing": "south" + }, + "door_cells": [ + [ + 4, + 7 + ], + [ + 5, + 7 + ], + [ + 6, + 7 + ] + ], + "service_anchor_cells": { + "bank_terminal_civic": [ + 3, + 3 + ], + "trade_terminal": [ + 6, + 3 + ], + "pa_terminal": [ + 9, + 3 + ], + "trainer_npc": [ + 10, + 6 + ] + }, + "promised_clear_cells": { + "bank_terminal_civic": [ + 3, + 3 + ], + "trade_terminal": [ + 6, + 3 + ], + "pa_terminal": [ + 9, + 3 + ], + "trainer_npc": [ + 10, + 6 + ] + }, + "collisionProxy": { + "source": "valley_market", + "sidecar": "valley_market_collision.json", + "structuralOnly": false, + "includesFurniture": true, + "wallCount": 48, + "furnitureCount": 27 + }, + "cutaway": { + "faces": { + "front": "wall_front__", + "back": "wall_back__", + "left": "wall_left__", + "right": "wall_right__" + }, + "hide": [ + "roof__", + "wall_front__", + "wall_back__", + "wall_left__", + "wall_right__" + ], + "revealPrefixes": [ + "roof__", + "wall_front__", + "wall_back__", + "wall_left__", + "wall_right__" + ], + "keep": [ + "floor__", + "interior__" + ], + "stub_by_face": true + }, + "interiorRegions": [ + { + "id": "hall", + "minX": -5.22, + "minZ": -1.85, + "maxX": 5.22, + "maxZ": 2.7800000000000002, + "floorTopY": 0.02 + }, + { + "id": "back_of_house", + "minX": -5.22, + "minZ": -3.6100000000000003, + "maxX": 5.22, + "maxZ": -2.32, + "floorTopY": 0.02 + }, + { + "id": "trainer_bay", + "minX": 3.06, + "minZ": 2.7800000000000002, + "maxX": 5.22, + "maxZ": 3.7199999999999998, + "floorTopY": 0.02 + } + ], + "bbox_span_m": [ + 11.378, + 7.13, + 8.524 + ], + "tri_count": 75456, + "materials": [ + "MKT_Brass", + "MKT_Ceramic", + "MKT_Glass", + "MKT_LampCyan", + "MKT_LampKey", + "MKT_LampWarm", + "MKT_Plaster", + "MKT_RoofMetal", + "MKT_Screed", + "MKT_Seal", + "MKT_Sinter", + "MKT_Steel" + ], + "asset": { + "file": "valley_market.glb", + "bytes": 9724088, + "sha256": "sha256:1f4e5e0d52e4fe504dcd70ed7b6590e45dc5c2cb69781ac7bab7e530ca40a1a3", + "images": { + "image_count": 41, + "external_image_uri_count": 0 + } + }, + "source": { + "furnished_glb": "market_house_furnished.glb", + "furnished_sha256": "sha256:1f4e5e0d52e4fe504dcd70ed7b6590e45dc5c2cb69781ac7bab7e530ca40a1a3", + "generator": "tools/successor/assets/market-opus5-rebuild/src/build_market.py", + "generator_sha256": "sha256:917c2ee9756a4e0d88add914fd12d96081805482511badcc30db0dcc06977635", + "post_export": "src/post_gltf.mjs (occlusion wiring + unused-attribute prune)", + "git_rev": "751d6650", + "authored_clearance_check_count": 23, + "authored_clearance_issues": [] + } +} diff --git a/scripts/server-local-persistent.mjs b/scripts/server-local-persistent.mjs index 5181fe1c..d379d32b 100644 --- a/scripts/server-local-persistent.mjs +++ b/scripts/server-local-persistent.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import fs from "node:fs/promises"; import fssync from "node:fs"; import path from "node:path"; @@ -27,6 +27,9 @@ const requestedMaxPacketBytes = process.env.GAME_MAX_PACKET_BYTES ?? "65536"; const expectedSourceStateHash = process.env.GAME_EXPECT_SOURCE_STATE_HASH; const expectedSourceActorCount = integerEnv("GAME_EXPECT_SOURCE_ACTOR_COUNT"); +const argv = new Set(process.argv.slice(2)); +const skipBuild = argv.has("--skip-build") || process.env.SERVER_LOCAL_SKIP_BUILD === "1"; +const stopOnly = argv.has("--stop"); const startedAt = new Date().toISOString(); let lockHandle = null; @@ -36,8 +39,40 @@ try { await fs.mkdir(stateDir, { recursive: true }); lockHandle = await acquireLock(lockPath); - await runForeground("pnpm", ["--dir", "server", "build"]); - await runForeground("cargo", ["build", "-p", "successor-sim", "--example", "authority_bridge_server"], rustAuthorityBridgeCargoEnv()); + if (stopOnly) { + const pids = []; + for (const file of [pidPath, listenerPidPath]) { + try { + for (const line of (await fs.readFile(file, "utf8")).split("\n")) { + const pid = Number(line.trim()); + if (Number.isInteger(pid) && pid > 0) pids.push(pid); + } + } catch { /* absent pid file means nothing to stop */ } + } + await stopPids(pids, port); + await removeIfExists(pidPath); + await removeIfExists(listenerPidPath); + await removeIfExists(metaPath); + console.log(JSON.stringify({ status: "stopped", port, pids })); + process.exit(0); + } + + if (skipBuild) { + const serverDist = path.join(serverRoot, "dist", "index.js"); + const bridgeBin = path.join(repoRoot, "target", "debug", "examples", "authority_bridge_server"); + try { + await fs.access(serverDist); + await fs.access(bridgeBin); + console.log(JSON.stringify({ status: "skip-build", skipped: ["pnpm --dir server build", "cargo build -p successor-sim --example authority_bridge_server"] })); + } catch { + console.log(JSON.stringify({ status: "skip-build-ignored", reason: "build artifacts absent; running full build" })); + await runForeground("pnpm", ["--dir", "server", "build"]); + await runForeground("cargo", ["build", "-p", "successor-sim", "--example", "authority_bridge_server"], rustAuthorityBridgeCargoEnv()); + } + } else { + await runForeground("pnpm", ["--dir", "server", "build"]); + await runForeground("cargo", ["build", "-p", "successor-sim", "--example", "authority_bridge_server"], rustAuthorityBridgeCargoEnv()); + } const beforeScan = await scanPort(port); assertNoUnmanagedListeners(beforeScan, "before restart"); @@ -284,7 +319,15 @@ async function waitForExactLoopbackPortClear(listenPort, watchedPids, timeoutMs) .some((processInfo) => watchedPids.includes(processInfo.pid)); } +// Listener discovery is platform-split: Linux reads `/proc`, macOS asks `lsof`. +// Both produce the same socket/process shape so the ownership assertions below +// stay identical on either host. async function scanPort(listenPort) { + if (process.platform === "linux") return scanPortProc(listenPort); + return scanPortLsof(listenPort); +} + +async function scanPortProc(listenPort) { const sockets = await listeningSocketsForPort(listenPort); const exactSockets = sockets.filter((socket) => socket.exactLoopbackV4); const unmanagedSockets = sockets.filter((socket) => !socket.exactLoopbackV4); @@ -293,6 +336,64 @@ async function scanPort(listenPort) { return { sockets, exactSockets, unmanagedSockets, exactProcesses, unmanagedProcesses }; } +// `lsof` resolves the owning pid with the socket, so there is no inode step. +async function scanPortLsof(listenPort) { + const sockets = []; + const listing = await runCapture("lsof", ["-nP", `-iTCP:${listenPort}`, "-sTCP:LISTEN", "-F", "pn"]); + let pid = null; + for (const line of listing.split("\n")) { + if (line.startsWith("p")) { + pid = Number.parseInt(line.slice(1), 10); + continue; + } + if (!line.startsWith("n") || !Number.isInteger(pid)) continue; + const name = line.slice(1); + const separator = name.lastIndexOf(":"); + if (separator < 0 || name.slice(separator + 1) !== String(listenPort)) continue; + const address = name.slice(0, separator); + const family = address.startsWith("[") ? "tcp6" : "tcp4"; + sockets.push({ + family, + label: name, + inode: `${pid}:${name}`, + pid, + exactLoopbackV4: family === "tcp4" && address === host, + }); + } + const exactSockets = sockets.filter((socket) => socket.exactLoopbackV4); + const unmanagedSockets = sockets.filter((socket) => !socket.exactLoopbackV4); + return { + sockets, + exactSockets, + unmanagedSockets, + exactProcesses: await processesForPids(exactSockets.map((socket) => socket.pid)), + unmanagedProcesses: await processesForPids(unmanagedSockets.map((socket) => socket.pid)), + }; +} + +async function processesForPids(pids) { + const unique = [...new Set(pids)].sort((left, right) => left - right); + return Promise.all(unique.map(async (pid) => ({ + pid, + cwd: await lsofCwd(pid), + cmdline: (await runCapture("ps", ["-p", String(pid), "-o", "args="])).trim(), + }))); +} + +async function lsofCwd(pid) { + const listing = await runCapture("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-F", "n"]); + const line = listing.split("\n").find((entry) => entry.startsWith("n")); + return line ? line.slice(1) : null; +} + +// Ownership checks must not be defeated by a missing tool: an empty capture +// reads as "no listener", which fails closed in every caller. +function runCapture(command, args) { + return new Promise((resolve) => { + execFile(command, args, { encoding: "utf8" }, (_error, stdout) => resolve(stdout ?? "")); + }); +} + function assertNoUnmanagedListeners(scan, phase) { if (scan.unmanagedSockets.length > 0) { throw new Error(`Refusing to manage non-IPv4-loopback listener(s) on port ${port} ${phase}: ${formatSocketList(scan.unmanagedSockets)} ${formatProcessList(scan.unmanagedProcesses)}`.trim()); @@ -374,9 +475,7 @@ function formatProcessList(processes) { } function formatSocketList(sockets) { - return sockets - .map((socket) => `${socket.family}:${socket.localAddressHex}:${socket.portHex} inode=${socket.inode}`) - .join("; "); + return sockets.map((socket) => `${socket.family}:${socket.label} inode=${socket.inode}`).join("; "); } async function listeningSocketsForPort(listenPort) { @@ -404,8 +503,7 @@ async function readProcTcp(file, listenPort, family, sockets) { if (state !== "0A" || portHex.toUpperCase() !== expectedPortHex || !inode) continue; sockets.push({ family, - localAddressHex: addressHex.toUpperCase(), - portHex: portHex.toUpperCase(), + label: `${addressHex.toUpperCase()}:${portHex.toUpperCase()}`, inode, exactLoopbackV4: family === "tcp4" && addressHex.toUpperCase() === "0100007F", }); diff --git a/server/.local-state-archive-20260803/open-desert-persistent.checkpoint.json b/server/.local-state-archive-20260803/open-desert-persistent.checkpoint.json new file mode 100644 index 00000000..f9d2d449 --- /dev/null +++ b/server/.local-state-archive-20260803/open-desert-persistent.checkpoint.json @@ -0,0 +1,16862 @@ +{ + "schema": "successor.game-shard-checkpoint.v1", + "shardId": "open-desert-persistent", + "sliceHash": "db907f43d0d19b3863db5b54508cea2f3f31e2962b83e0967be07c7248310190", + "sourceStateHash": "planetfall-v5-seed-424242-size-1024-rogues-18-desert-critters-48-verdance-critters-24-areas-open-desert-overworld-verdance-forest-overworld", + "tick": 79658, + "tickRateHz": 30, + "nextCombatEventId": 1, + "nextBotSeq": 27, + "counters": { + "acceptedCommands": 1408, + "rejectedCommands": 1140, + "shotsFired": 0, + "hits": 0, + "deaths": 12, + "packetsIn": 2589, + "packetsOut": 85048, + "bytesOut": 472736168, + "rejectedPackets": 4 + }, + "actors": [ + { + "id": "camp-trainer", + "wornColors": {}, + "label": "Knox Vale", + "displayName": "Knox Vale", + "linkDead": false, + "appearance": { + "skin": "#c78f62", + "hair": "hair_mop", + "hair_mat": "hair_raven", + "face": null + }, + "sprite": "adventurer-premium-male", + "role": "profession_trainer", + "factionId": "desert_wardens", + "socialGroup": "open_desert_camp", + "pvpStatus": "none", + "willAutoAggro": false, + "playerOrganizationId": null, + "playerOrganizationTag": null, + "areaId": "open-desert-overworld", + "x": 510, + "y": 504, + "direction": "front", + "posture": "standing", + "postureUntilTick": 0, + "inCombat": false, + "peaceRequested": false, + "engagementTargetId": null, + "scale": 1, + "professionIds": [ + "craftsman", + "marksman", + "medic", + "brawler", + "scout", + "bioengineer" + ], + "skillBoxIds": [ + "marksman-novice", + "brawler-novice", + "scout-novice", + "craftsman-novice", + "medic-novice", + "bioengineer-novice" + ], + "activeTitleId": "craftsman-novice", + "careerGoalId": null, + "credits": 5000, + "lifeState": "alive", + "lifecycleSeq": 1, + "vitals": { + "health": 75, + "action": 75, + "spirit": 75 + }, + "maxVitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "effectiveStats": { + "traits": { + "body": 75, + "spirit": 75 + }, + "spawnVitals": { + "health": 75, + "action": 75, + "spirit": 75 + }, + "maxVitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "regenRatesPerSecond": { + "health": 0.83, + "action": 1.91, + "spirit": 1.61 + }, + "movementSpeedMultiplier": 0.94, + "aimStability": 0.88, + "recoilRecoveryMultiplier": 0.9, + "suppressionResistance": 0.75, + "suppressionThreshold": 18, + "panicThreshold": 18, + "panicDurationMultiplier": 1.05, + "bleedTolerance": 0.89, + "woundTolerance": 0.89, + "dodgeChance": 0, + "cloneSicknessTolerance": 0.87 + }, + "bleed": { + "active": false, + "stackCount": 0, + "severity": 0, + "remainingMs": 0, + "ratesPerSecond": { + "health": 0, + "action": 0, + "spirit": 0 + }, + "stacks": [] + }, + "statuses": [], + "suppression": { + "pressure": 0, + "source": null + }, + "sleep": { + "active": false, + "stacks": 0, + "remainingMs": 0 + }, + "nextMoveTick": 0, + "sprintRegenBlockedUntilTick": 0, + "sprintActionDrainMilli": 0, + "nextBleedStackId": 1, + "recentIncomingDamage": 0, + "respawnAtTick": 0, + "nextSampleTick": 0, + "bodyVanishAtTick": 0, + "lootable": false, + "hasLoot": false, + "lootRightsActorId": null, + "cloneSicknessRemainingMs": 0, + "incapRemainingMs": 0, + "incapCount": 0, + "incapWindowMs": 0, + "route": [], + "routeIndex": 0, + "homeAreaId": "open-desert-overworld", + "homeCell": { + "x": 510, + "y": 504 + }, + "homeDirection": "front", + "homeRoute": [] + }, + { + "id": "grok", + "wornColors": {}, + "label": "GR0K", + "displayName": "GR0K", + "linkDead": false, + "appearance": { + "skin": "#c78f62", + "hair": "hair_mop", + "hair_mat": "hair_raven", + "face": null + }, + "sprite": "droid-grok-humanoid", + "role": "scripted_player", + "factionId": "desert_wardens", + "socialGroup": "open_desert_camp", + "pvpStatus": "none", + "willAutoAggro": false, + "playerOrganizationId": null, + "playerOrganizationTag": null, + "areaId": "open-desert-overworld", + "x": 510, + "y": 514, + "direction": "right", + "posture": "standing", + "postureUntilTick": 0, + "inCombat": false, + "peaceRequested": false, + "engagementTargetId": null, + "scale": 1, + "activeTitleId": null, + "careerGoalId": null, + "credits": 5000, + "lifeState": "alive", + "lifecycleSeq": 1, + "vitals": { + "health": 88, + "action": 87, + "spirit": 82 + }, + "maxVitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "effectiveStats": { + "traits": { + "body": 88, + "spirit": 82 + }, + "spawnVitals": { + "health": 88, + "action": 87.1, + "spirit": 82 + }, + "maxVitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "regenRatesPerSecond": { + "health": 0.91, + "action": 2.17, + "spirit": 1.78 + }, + "movementSpeedMultiplier": 0.97, + "aimStability": 0.92, + "recoilRecoveryMultiplier": 0.94, + "suppressionResistance": 0.82, + "suppressionThreshold": 19.68, + "panicThreshold": 19.68, + "panicDurationMultiplier": 1.02, + "bleedTolerance": 0.95, + "woundTolerance": 0.95, + "dodgeChance": 0, + "cloneSicknessTolerance": 0.92 + }, + "bleed": { + "active": false, + "stackCount": 0, + "severity": 0, + "remainingMs": 0, + "ratesPerSecond": { + "health": 0, + "action": 0, + "spirit": 0 + }, + "stacks": [] + }, + "statuses": [], + "suppression": { + "pressure": 0, + "source": null + }, + "sleep": { + "active": false, + "stacks": 0, + "remainingMs": 0 + }, + "nextMoveTick": 0, + "sprintRegenBlockedUntilTick": 0, + "sprintActionDrainMilli": 0, + "nextBleedStackId": 1, + "recentIncomingDamage": 0, + "respawnAtTick": 0, + "nextSampleTick": 0, + "bodyVanishAtTick": 0, + "lootable": false, + "hasLoot": false, + "lootRightsActorId": null, + "cloneSicknessRemainingMs": 0, + "incapRemainingMs": 0, + "incapCount": 0, + "incapWindowMs": 0, + "route": [], + "routeIndex": 0, + "homeAreaId": "open-desert-overworld", + "homeCell": { + "x": 510, + "y": 514 + }, + "homeDirection": "right", + "homeRoute": [] + } + ], + "propStates": { + "dustgate-cloning-facility": { + "doorOpen": false + }, + "dustgate-commerce-facility": { + "doorOpen": false + }, + "dustgate-home-starter": { + "doorOpen": false + }, + "dustgate-home-court": { + "doorOpen": false + }, + "dustgate-home-wing": { + "doorOpen": false + }, + "open-desert-rogue-zone-001-footlocker": { + "cacheEmptied": false + }, + "open-desert-rogue-zone-002-footlocker": { + "cacheEmptied": false + }, + "open-desert-rogue-zone-003-footlocker": { + "cacheEmptied": false + }, + "open-desert-rogue-zone-004-footlocker": { + "cacheEmptied": false + }, + "open-desert-rogue-zone-005-footlocker": { + "cacheEmptied": false + }, + "open-desert-rogue-zone-006-footlocker": { + "cacheEmptied": false + }, + "open-desert-rogue-zone-007-footlocker": { + "cacheEmptied": false + }, + "open-desert-rogue-zone-008-footlocker": { + "cacheEmptied": false + }, + "open-desert-rogue-zone-009-footlocker": { + "cacheEmptied": false + }, + "open-desert-rogue-zone-010-footlocker": { + "cacheEmptied": false + }, + "open-desert-rogue-zone-011-footlocker": { + "cacheEmptied": false + }, + "open-desert-rogue-zone-012-footlocker": { + "cacheEmptied": false + }, + "open-desert-rogue-zone-013-footlocker": { + "cacheEmptied": false + }, + "open-desert-rogue-zone-014-footlocker": { + "cacheEmptied": false + }, + "open-desert-rogue-zone-015-footlocker": { + "cacheEmptied": false + }, + "open-desert-rogue-zone-016-footlocker": { + "cacheEmptied": false + }, + "open-desert-rogue-zone-017-footlocker": { + "cacheEmptied": false + }, + "open-desert-rogue-zone-018-footlocker": { + "cacheEmptied": false + }, + "open-desert-sparring-zone-footlocker": { + "cacheEmptied": false + } + }, + "travelTickets": [], + "manifest": { + "schema": "successor.state-generation-manifest.v1", + "release": "0.0.1", + "sourceStateHash": "planetfall-v5-seed-424242-size-1024-rogues-18-desert-critters-48-verdance-critters-24-areas-open-desert-overworld-verdance-forest-overworld", + "fixtureHash": "db907f43d0d19b3863db5b54508cea2f3f31e2962b83e0967be07c7248310190", + "mapBundleHash": "4f0181134e7f0c37fa0189a89fb3ea145c3a6a34271d97a5b2f8e6f8c0dd8738", + "wireSchema": "successor.authoritative-shard-delta.v1", + "saveSchema": "successor.game-shard-checkpoint.v1", + "journalAnchor": "checkpoint", + "characterMirror": "successor.character-store.v2", + "craftRollKeyId": "9992a41bb9ccdfaa956b4e925c29daa279b63b219923cc2f852f560cd11ebfdb", + "controlSchemaHead": { + "version": 0, + "checksum": "" + }, + "generation": "6dde80906b96b2eb8a62d214af30d0870bb4c4668f75aecf8897ccb0db33eaba" + }, + "savedAt": "2026-08-04T03:56:57.541Z", + "stateHash": "bee77e3f1909750cc065c0a63eb3c7cf4edcc8401e73c15e9b03ef9ec3e65214", + "projectionStateHash": "b30cf95f72224c461defded58344bc550708aa0cb57d30ac74ac5e8043287f01", + "rustAuthority": { + "schema": "authority.checkpoint-state.v1", + "version": 1, + "tick": 79658, + "stateHash": "bee77e3f1909750cc065c0a63eb3c7cf4edcc8401e73c15e9b03ef9ec3e65214", + "state": { + "schema": "authority.checkpoint.v1", + "version": 1, + "stateHash": "bee77e3f1909750cc065c0a63eb3c7cf4edcc8401e73c15e9b03ef9ec3e65214", + "payloadHash": "75fa91021aa99e183ead58e16e993927a0b0b3500f516cd4c1f59f738cddc682", + "state": { + "tick": 79658, + "actors": { + "camp-trainer": { + "id": "camp-trainer", + "entity": "1:2", + "label": "Knox Vale", + "display_name": "Knox Vale", + "descriptor": "a profession trainer", + "link_dead": false, + "link_dead_expires_tick": 0, + "appearance": { + "skin": "#c78f62", + "hair": "hair_mop", + "hair_mat": "hair_raven" + }, + "worn": [], + "equipped_clothing": [], + "worn_colors": {}, + "sprite": "adventurer-premium-male", + "template_id": null, + "spawn_zone_id": null, + "role": "profession_trainer", + "faction": { + "faction_id": "desert_wardens", + "social_group": "open_desert_camp", + "pvp_status": "None" + }, + "player_organization_id": null, + "player_organization_tag": null, + "area_id": "open-desert-overworld", + "cell": { + "x": 510, + "y": 504 + }, + "position": { + "x": 510000, + "y": 504000 + }, + "direction": "front", + "scale": 1, + "home_area_id": "open-desert-overworld", + "home_cell": { + "x": 510, + "y": 504 + }, + "home_direction": "front", + "home_route": [], + "life_state": "alive", + "lifecycle_seq": 1, + "posture": "standing", + "posture_until_tick": 0, + "vitals": { + "health": 75, + "action": 75, + "spirit": 75 + }, + "max_vitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "effective_stats": { + "traits": { + "body": 75, + "spirit": 75 + }, + "spawn_vitals": { + "health": 75, + "action": 75, + "spirit": 75 + }, + "max_vitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "regen_rates_milli_per_second": { + "health": 832, + "action": 19130, + "spirit": 1613 + }, + "movement_speed_multiplier_milli": 937, + "suppression_resistance_milli": 750, + "suppression_threshold_milli": 18000, + "panic_duration_multiplier_milli": 1050, + "bleed_tolerance_milli": 890, + "dodge_chance_milli": 0, + "clone_sickness_tolerance_milli": 866, + "defense_vs_state_milli": 0 + }, + "professions": { + "learned": [ + "craftsman", + "medic", + "scout", + "marksman", + "brawler", + "bio_engineer" + ], + "xp": {}, + "track_xp": {}, + "skill_boxes": [ + "bioengineer-novice", + "brawler-novice", + "craftsman-novice", + "marksman-novice", + "medic-novice", + "scout-novice" + ], + "active_title_id": "craftsman-novice", + "credits": 5000, + "skill_point_cap": 250 + }, + "capabilities": { + "granted": [ + "combat:melee_basic", + "combat:ranged_basic", + "craft:ammo", + "craft:medicine", + "craft:profession_tool", + "craft:scout_processing", + "gather:iron", + "harvest:ammo", + "harvest:creature", + "haul:exchange", + "medical:revive_basic", + "sample:flora", + "self-preserve", + "splice:genome" + ] + }, + "capability_grants": [], + "career_goal_id": null, + "passive_regen_milli": { + "health": 0, + "action": 0, + "spirit": 0 + }, + "weather_damage_accumulated_milli": 0, + "bleed_stacks": [], + "consumable_effects": [], + "service_buffs": [], + "personal_shield": null, + "sprint_action_drain_milli": 0, + "sprint_recovery_locked": false, + "sprint_recovery_regen_carry": 0, + "sprint_regen_block_until_tick": 0, + "mobility": { + "sprint_moves": 0, + "sprint_ticks": 0, + "sprint_action_spent_milli": 0, + "sprint_distance_milli": 0, + "tactical_sprint_moves": 0, + "tactical_sprint_ticks": 0, + "tactical_sprint_action_spent_milli": 0, + "tactical_sprint_distance_milli": 0, + "last_sprint_tick": null, + "last_sprint_reason": null + }, + "downed_action_drain_milli": 0, + "downed_spirit_drain_milli": 0, + "incap_expires_tick": 0, + "incap_count": 0, + "incap_window_start_tick": 0, + "incap_grace_until_tick": 0, + "sleep": { + "stacks": 0, + "remaining_ticks": 0 + }, + "suppression": { + "pressure_milli": 0, + "source": null + }, + "ai": null, + "next_fire_tick": 0, + "weapon_recoil_heat_milli": 0, + "weapon_recoil_last_tick": 0, + "equipped_weapon_id": "Vibrosword", + "equipped_weapon_item_id": 0, + "equipped_weapon_variant_id": 0, + "slugthrower_magazine": { + "loaded_rounds": 30, + "reload_until_tick": 0 + }, + "combat_queue": { + "next_ready_tick": 0, + "entries": [], + "repeat_intent": null, + "pending_posture": null, + "sequence": 0 + }, + "combat_until_tick": 0, + "engagement_target_id": null, + "peace_requested": false, + "next_move_tick": 0, + "move_intent": null, + "next_economy_action_tick": 0, + "next_resource_survey_tick": 0, + "pending_resource_sample": null, + "resource_sample_loop": null, + "cranking_extractor_id": null, + "known_recipe_ids": [], + "scanned_genomes": [], + "next_starter_tool_request_tick": 0, + "shots_fired": 0, + "last_shot_tick": null, + "last_moved_tick": null, + "stats": { + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0, + "last_damage_dealt_tick": null, + "last_damage_taken_tick": null, + "last_kill_tick": null, + "last_death": null, + "buckets": [ + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + } + ] + }, + "route": [], + "route_index": 0, + "next_route_tick": 18, + "player_damage_ledger": [], + "loot_rights_actor_id": null, + "body_vanish_tick": 0, + "respawn_tick": 0, + "corpse_exhausted_tick": null, + "creature_corpse_harvested_tick": null, + "clone_sickness_ticks": 0, + "respawn_return": [] + }, + "dev-1": { + "id": "dev-1", + "entity": "dev-1", + "label": "dev-1", + "display_name": "dev-1", + "descriptor": "", + "link_dead": false, + "link_dead_expires_tick": 0, + "appearance": { + "skin": "#c78f62", + "hair": "hair_mop", + "hair_mat": "hair_raven" + }, + "worn": [], + "equipped_clothing": [], + "worn_colors": {}, + "sprite": "adventurer-premium-male", + "template_id": null, + "spawn_zone_id": null, + "role": "player", + "faction": { + "faction_id": "desert_wardens", + "social_group": "open_desert_player", + "pvp_status": "Overt" + }, + "player_organization_id": null, + "player_organization_tag": null, + "area_id": "open-desert-overworld", + "cell": { + "x": 516, + "y": 506 + }, + "position": { + "x": 516000, + "y": 506000 + }, + "direction": "front", + "scale": 1, + "home_area_id": "open-desert-overworld", + "home_cell": { + "x": 516, + "y": 506 + }, + "home_direction": "front", + "home_route": [], + "life_state": "alive", + "lifecycle_seq": 1, + "posture": "standing", + "posture_until_tick": 0, + "vitals": { + "health": 280, + "action": 160, + "spirit": 100 + }, + "max_vitals": { + "health": 280, + "action": 160, + "spirit": 100 + }, + "effective_stats": { + "traits": { + "body": 100, + "spirit": 100 + }, + "spawn_vitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "max_vitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "regen_rates_milli_per_second": { + "health": 6000, + "action": 24000, + "spirit": 2100 + }, + "movement_speed_multiplier_milli": 1000, + "suppression_resistance_milli": 1000, + "suppression_threshold_milli": 24000, + "panic_duration_multiplier_milli": 950, + "bleed_tolerance_milli": 1004, + "dodge_chance_milli": 100, + "clone_sickness_tolerance_milli": 1005, + "defense_vs_state_milli": 0 + }, + "professions": { + "learned": [ + "marksman" + ], + "xp": { + "marksman": 0 + }, + "track_xp": {}, + "skill_boxes": [ + "marksman-novice", + "marksman-rifle-i" + ], + "active_title_id": "marksman-novice", + "credits": 5000, + "skill_point_cap": 250 + }, + "capabilities": { + "granted": [ + "combat:ranged_basic", + "harvest:ammo", + "haul:exchange", + "self-preserve" + ] + }, + "capability_grants": [], + "career_goal_id": null, + "passive_regen_milli": { + "health": 0, + "action": 0, + "spirit": 0 + }, + "weather_damage_accumulated_milli": 0, + "bleed_stacks": [], + "consumable_effects": [], + "service_buffs": [], + "personal_shield": null, + "sprint_action_drain_milli": 0, + "sprint_recovery_locked": false, + "sprint_recovery_regen_carry": 0, + "sprint_regen_block_until_tick": 0, + "mobility": { + "sprint_moves": 0, + "sprint_ticks": 0, + "sprint_action_spent_milli": 0, + "sprint_distance_milli": 0, + "tactical_sprint_moves": 0, + "tactical_sprint_ticks": 0, + "tactical_sprint_action_spent_milli": 0, + "tactical_sprint_distance_milli": 0, + "last_sprint_tick": null, + "last_sprint_reason": null + }, + "downed_action_drain_milli": 0, + "downed_spirit_drain_milli": 0, + "incap_expires_tick": 0, + "incap_count": 0, + "incap_window_start_tick": 0, + "incap_grace_until_tick": 0, + "sleep": { + "stacks": 0, + "remaining_ticks": 0 + }, + "suppression": { + "pressure_milli": 0, + "source": null + }, + "ai": null, + "next_fire_tick": 0, + "weapon_recoil_heat_milli": 0, + "weapon_recoil_last_tick": 1039, + "equipped_weapon_id": "Slugthrower", + "equipped_weapon_item_id": 0, + "equipped_weapon_variant_id": 0, + "slugthrower_magazine": { + "loaded_rounds": 30, + "reload_until_tick": 0 + }, + "combat_queue": { + "next_ready_tick": 0, + "entries": [], + "repeat_intent": null, + "pending_posture": null, + "sequence": 0 + }, + "combat_until_tick": 0, + "engagement_target_id": null, + "peace_requested": false, + "next_move_tick": 0, + "move_intent": null, + "next_economy_action_tick": 0, + "next_resource_survey_tick": 0, + "pending_resource_sample": null, + "resource_sample_loop": null, + "cranking_extractor_id": null, + "known_recipe_ids": [], + "scanned_genomes": [], + "next_starter_tool_request_tick": 0, + "shots_fired": 0, + "last_shot_tick": null, + "last_moved_tick": null, + "stats": { + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0, + "last_damage_dealt_tick": null, + "last_damage_taken_tick": null, + "last_kill_tick": null, + "last_death": null, + "buckets": [ + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + } + ] + }, + "route": [], + "route_index": 0, + "next_route_tick": 1057, + "player_damage_ledger": [], + "loot_rights_actor_id": null, + "body_vanish_tick": 0, + "respawn_tick": 0, + "corpse_exhausted_tick": null, + "creature_corpse_harvested_tick": null, + "clone_sickness_ticks": 0, + "respawn_return": [] + }, + "dev-4": { + "id": "dev-4", + "entity": "dev-4", + "label": "dev-4", + "display_name": "dev-4", + "descriptor": "", + "link_dead": false, + "link_dead_expires_tick": 0, + "appearance": { + "skin": "#c78f62", + "hair": "hair_mop", + "hair_mat": "hair_raven" + }, + "worn": [], + "equipped_clothing": [], + "worn_colors": {}, + "sprite": "adventurer-premium-male", + "template_id": null, + "spawn_zone_id": null, + "role": "player", + "faction": { + "faction_id": "desert_wardens", + "social_group": "open_desert_player", + "pvp_status": "Overt" + }, + "player_organization_id": null, + "player_organization_tag": null, + "area_id": "open-desert-overworld", + "cell": { + "x": 516, + "y": 506 + }, + "position": { + "x": 516000, + "y": 506000 + }, + "direction": "front", + "scale": 1, + "home_area_id": "open-desert-overworld", + "home_cell": { + "x": 516, + "y": 506 + }, + "home_direction": "front", + "home_route": [], + "life_state": "alive", + "lifecycle_seq": 1, + "posture": "standing", + "posture_until_tick": 0, + "vitals": { + "health": 280, + "action": 160, + "spirit": 100 + }, + "max_vitals": { + "health": 280, + "action": 160, + "spirit": 100 + }, + "effective_stats": { + "traits": { + "body": 100, + "spirit": 100 + }, + "spawn_vitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "max_vitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "regen_rates_milli_per_second": { + "health": 6000, + "action": 24000, + "spirit": 2100 + }, + "movement_speed_multiplier_milli": 1000, + "suppression_resistance_milli": 1000, + "suppression_threshold_milli": 24000, + "panic_duration_multiplier_milli": 950, + "bleed_tolerance_milli": 1004, + "dodge_chance_milli": 100, + "clone_sickness_tolerance_milli": 1005, + "defense_vs_state_milli": 0 + }, + "professions": { + "learned": [ + "marksman" + ], + "xp": { + "marksman": 0 + }, + "track_xp": {}, + "skill_boxes": [ + "marksman-novice", + "marksman-rifle-i" + ], + "active_title_id": "marksman-novice", + "credits": 5000, + "skill_point_cap": 250 + }, + "capabilities": { + "granted": [ + "combat:ranged_basic", + "harvest:ammo", + "haul:exchange", + "self-preserve" + ] + }, + "capability_grants": [], + "career_goal_id": null, + "passive_regen_milli": { + "health": 0, + "action": 0, + "spirit": 0 + }, + "weather_damage_accumulated_milli": 0, + "bleed_stacks": [], + "consumable_effects": [], + "service_buffs": [], + "personal_shield": null, + "sprint_action_drain_milli": 0, + "sprint_recovery_locked": false, + "sprint_recovery_regen_carry": 0, + "sprint_regen_block_until_tick": 0, + "mobility": { + "sprint_moves": 0, + "sprint_ticks": 0, + "sprint_action_spent_milli": 0, + "sprint_distance_milli": 0, + "tactical_sprint_moves": 0, + "tactical_sprint_ticks": 0, + "tactical_sprint_action_spent_milli": 0, + "tactical_sprint_distance_milli": 0, + "last_sprint_tick": null, + "last_sprint_reason": null + }, + "downed_action_drain_milli": 0, + "downed_spirit_drain_milli": 0, + "incap_expires_tick": 0, + "incap_count": 0, + "incap_window_start_tick": 0, + "incap_grace_until_tick": 0, + "sleep": { + "stacks": 0, + "remaining_ticks": 0 + }, + "suppression": { + "pressure_milli": 0, + "source": null + }, + "ai": null, + "next_fire_tick": 0, + "weapon_recoil_heat_milli": 0, + "weapon_recoil_last_tick": 4782, + "equipped_weapon_id": "Slugthrower", + "equipped_weapon_item_id": 0, + "equipped_weapon_variant_id": 0, + "slugthrower_magazine": { + "loaded_rounds": 30, + "reload_until_tick": 0 + }, + "combat_queue": { + "next_ready_tick": 0, + "entries": [], + "repeat_intent": null, + "pending_posture": null, + "sequence": 0 + }, + "combat_until_tick": 0, + "engagement_target_id": null, + "peace_requested": false, + "next_move_tick": 0, + "move_intent": null, + "next_economy_action_tick": 0, + "next_resource_survey_tick": 0, + "pending_resource_sample": null, + "resource_sample_loop": null, + "cranking_extractor_id": null, + "known_recipe_ids": [], + "scanned_genomes": [], + "next_starter_tool_request_tick": 0, + "shots_fired": 0, + "last_shot_tick": null, + "last_moved_tick": null, + "stats": { + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0, + "last_damage_dealt_tick": null, + "last_damage_taken_tick": null, + "last_kill_tick": null, + "last_death": null, + "buckets": [ + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + } + ] + }, + "route": [], + "route_index": 0, + "next_route_tick": 4800, + "player_damage_ledger": [], + "loot_rights_actor_id": null, + "body_vanish_tick": 0, + "respawn_tick": 0, + "corpse_exhausted_tick": null, + "creature_corpse_harvested_tick": null, + "clone_sickness_ticks": 0, + "respawn_return": [] + }, + "dev-5": { + "id": "dev-5", + "entity": "dev-5", + "label": "dev-5", + "display_name": "dev-5", + "descriptor": "", + "link_dead": false, + "link_dead_expires_tick": 0, + "appearance": { + "skin": "#c78f62", + "hair": "hair_mop", + "hair_mat": "hair_raven" + }, + "worn": [], + "equipped_clothing": [], + "worn_colors": {}, + "sprite": "adventurer-premium-male", + "template_id": null, + "spawn_zone_id": null, + "role": "player", + "faction": { + "faction_id": "desert_wardens", + "social_group": "open_desert_player", + "pvp_status": "Overt" + }, + "player_organization_id": null, + "player_organization_tag": null, + "area_id": "open-desert-overworld", + "cell": { + "x": 516, + "y": 506 + }, + "position": { + "x": 516000, + "y": 506000 + }, + "direction": "front", + "scale": 1, + "home_area_id": "open-desert-overworld", + "home_cell": { + "x": 516, + "y": 506 + }, + "home_direction": "front", + "home_route": [], + "life_state": "alive", + "lifecycle_seq": 1, + "posture": "standing", + "posture_until_tick": 0, + "vitals": { + "health": 280, + "action": 160, + "spirit": 100 + }, + "max_vitals": { + "health": 280, + "action": 160, + "spirit": 100 + }, + "effective_stats": { + "traits": { + "body": 100, + "spirit": 100 + }, + "spawn_vitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "max_vitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "regen_rates_milli_per_second": { + "health": 6000, + "action": 24000, + "spirit": 2100 + }, + "movement_speed_multiplier_milli": 1000, + "suppression_resistance_milli": 1000, + "suppression_threshold_milli": 24000, + "panic_duration_multiplier_milli": 950, + "bleed_tolerance_milli": 1004, + "dodge_chance_milli": 100, + "clone_sickness_tolerance_milli": 1005, + "defense_vs_state_milli": 0 + }, + "professions": { + "learned": [ + "marksman" + ], + "xp": { + "marksman": 0 + }, + "track_xp": {}, + "skill_boxes": [ + "marksman-novice", + "marksman-rifle-i" + ], + "active_title_id": "marksman-novice", + "credits": 5000, + "skill_point_cap": 250 + }, + "capabilities": { + "granted": [ + "combat:ranged_basic", + "harvest:ammo", + "haul:exchange", + "self-preserve" + ] + }, + "capability_grants": [], + "career_goal_id": null, + "passive_regen_milli": { + "health": 0, + "action": 0, + "spirit": 0 + }, + "weather_damage_accumulated_milli": 0, + "bleed_stacks": [], + "consumable_effects": [], + "service_buffs": [], + "personal_shield": null, + "sprint_action_drain_milli": 0, + "sprint_recovery_locked": false, + "sprint_recovery_regen_carry": 0, + "sprint_regen_block_until_tick": 0, + "mobility": { + "sprint_moves": 0, + "sprint_ticks": 0, + "sprint_action_spent_milli": 0, + "sprint_distance_milli": 0, + "tactical_sprint_moves": 0, + "tactical_sprint_ticks": 0, + "tactical_sprint_action_spent_milli": 0, + "tactical_sprint_distance_milli": 0, + "last_sprint_tick": null, + "last_sprint_reason": null + }, + "downed_action_drain_milli": 0, + "downed_spirit_drain_milli": 0, + "incap_expires_tick": 0, + "incap_count": 0, + "incap_window_start_tick": 0, + "incap_grace_until_tick": 0, + "sleep": { + "stacks": 0, + "remaining_ticks": 0 + }, + "suppression": { + "pressure_milli": 0, + "source": null + }, + "ai": null, + "next_fire_tick": 0, + "weapon_recoil_heat_milli": 0, + "weapon_recoil_last_tick": 7111, + "equipped_weapon_id": "Slugthrower", + "equipped_weapon_item_id": 0, + "equipped_weapon_variant_id": 0, + "slugthrower_magazine": { + "loaded_rounds": 30, + "reload_until_tick": 0 + }, + "combat_queue": { + "next_ready_tick": 0, + "entries": [], + "repeat_intent": null, + "pending_posture": null, + "sequence": 0 + }, + "combat_until_tick": 0, + "engagement_target_id": null, + "peace_requested": false, + "next_move_tick": 0, + "move_intent": null, + "next_economy_action_tick": 0, + "next_resource_survey_tick": 0, + "pending_resource_sample": null, + "resource_sample_loop": null, + "cranking_extractor_id": null, + "known_recipe_ids": [], + "scanned_genomes": [], + "next_starter_tool_request_tick": 0, + "shots_fired": 0, + "last_shot_tick": null, + "last_moved_tick": null, + "stats": { + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0, + "last_damage_dealt_tick": null, + "last_damage_taken_tick": null, + "last_kill_tick": null, + "last_death": null, + "buckets": [ + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + } + ] + }, + "route": [], + "route_index": 0, + "next_route_tick": 7129, + "player_damage_ledger": [], + "loot_rights_actor_id": null, + "body_vanish_tick": 0, + "respawn_tick": 0, + "corpse_exhausted_tick": null, + "creature_corpse_harvested_tick": null, + "clone_sickness_ticks": 0, + "respawn_return": [] + }, + "grok": { + "id": "grok", + "entity": "1:3", + "label": "GR0K", + "display_name": "GR0K", + "descriptor": "a humanoid droid", + "link_dead": false, + "link_dead_expires_tick": 0, + "appearance": { + "skin": "#c78f62", + "hair": "hair_mop", + "hair_mat": "hair_raven" + }, + "worn": [], + "equipped_clothing": [], + "worn_colors": {}, + "sprite": "droid-grok-humanoid", + "template_id": null, + "spawn_zone_id": null, + "role": "scripted_player", + "faction": { + "faction_id": "desert_wardens", + "social_group": "open_desert_camp", + "pvp_status": "None" + }, + "player_organization_id": null, + "player_organization_tag": null, + "area_id": "open-desert-overworld", + "cell": { + "x": 510, + "y": 514 + }, + "position": { + "x": 510000, + "y": 514000 + }, + "direction": "right", + "scale": 1, + "home_area_id": "open-desert-overworld", + "home_cell": { + "x": 510, + "y": 514 + }, + "home_direction": "right", + "home_route": [], + "life_state": "alive", + "lifecycle_seq": 1, + "posture": "standing", + "posture_until_tick": 0, + "vitals": { + "health": 88, + "action": 87, + "spirit": 82 + }, + "max_vitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "effective_stats": { + "traits": { + "body": 88, + "spirit": 82 + }, + "spawn_vitals": { + "health": 88, + "action": 87, + "spirit": 82 + }, + "max_vitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "regen_rates_milli_per_second": { + "health": 911, + "action": 21660, + "spirit": 1782 + }, + "movement_speed_multiplier_milli": 966, + "suppression_resistance_milli": 820, + "suppression_threshold_milli": 19680, + "panic_duration_multiplier_milli": 1022, + "bleed_tolerance_milli": 950, + "dodge_chance_milli": 0, + "clone_sickness_tolerance_milli": 922, + "defense_vs_state_milli": 0 + }, + "professions": { + "learned": [], + "xp": {}, + "track_xp": {}, + "skill_boxes": [], + "active_title_id": null, + "credits": 5000, + "skill_point_cap": 250 + }, + "capabilities": { + "granted": [ + "haul:exchange", + "self-preserve" + ] + }, + "capability_grants": [], + "career_goal_id": null, + "passive_regen_milli": { + "health": 0, + "action": 0, + "spirit": 0 + }, + "weather_damage_accumulated_milli": 0, + "bleed_stacks": [], + "consumable_effects": [], + "service_buffs": [], + "personal_shield": null, + "sprint_action_drain_milli": 0, + "sprint_recovery_locked": false, + "sprint_recovery_regen_carry": 0, + "sprint_regen_block_until_tick": 0, + "mobility": { + "sprint_moves": 0, + "sprint_ticks": 0, + "sprint_action_spent_milli": 0, + "sprint_distance_milli": 0, + "tactical_sprint_moves": 0, + "tactical_sprint_ticks": 0, + "tactical_sprint_action_spent_milli": 0, + "tactical_sprint_distance_milli": 0, + "last_sprint_tick": null, + "last_sprint_reason": null + }, + "downed_action_drain_milli": 0, + "downed_spirit_drain_milli": 0, + "incap_expires_tick": 0, + "incap_count": 0, + "incap_window_start_tick": 0, + "incap_grace_until_tick": 0, + "sleep": { + "stacks": 0, + "remaining_ticks": 0 + }, + "suppression": { + "pressure_milli": 0, + "source": null + }, + "ai": null, + "next_fire_tick": 0, + "weapon_recoil_heat_milli": 0, + "weapon_recoil_last_tick": 0, + "equipped_weapon_id": "Slugthrower", + "equipped_weapon_item_id": 0, + "equipped_weapon_variant_id": 0, + "slugthrower_magazine": { + "loaded_rounds": 30, + "reload_until_tick": 0 + }, + "combat_queue": { + "next_ready_tick": 0, + "entries": [], + "repeat_intent": null, + "pending_posture": null, + "sequence": 0 + }, + "combat_until_tick": 0, + "engagement_target_id": null, + "peace_requested": false, + "next_move_tick": 0, + "move_intent": null, + "next_economy_action_tick": 0, + "next_resource_survey_tick": 0, + "pending_resource_sample": null, + "resource_sample_loop": null, + "cranking_extractor_id": null, + "known_recipe_ids": [], + "scanned_genomes": [], + "next_starter_tool_request_tick": 0, + "shots_fired": 0, + "last_shot_tick": null, + "last_moved_tick": null, + "stats": { + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0, + "last_damage_dealt_tick": null, + "last_damage_taken_tick": null, + "last_kill_tick": null, + "last_death": null, + "buckets": [ + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + } + ] + }, + "route": [], + "route_index": 0, + "next_route_tick": 18, + "player_damage_ledger": [], + "loot_rights_actor_id": null, + "body_vanish_tick": 0, + "respawn_tick": 0, + "corpse_exhausted_tick": null, + "creature_corpse_harvested_tick": null, + "clone_sickness_ticks": 0, + "respawn_return": [] + }, + "open-desert-rogue-011-01": { + "id": "open-desert-rogue-011-01", + "entity": "skirmisher:open-desert-rogue-011-01", + "label": "Wrenn Vale", + "display_name": "Wrenn Vale", + "descriptor": "a rogue drifter", + "link_dead": false, + "link_dead_expires_tick": 0, + "appearance": { + "skin": "#c78f62", + "hair": "hair_mop", + "hair_mat": "hair_raven" + }, + "worn": [], + "equipped_clothing": [], + "worn_colors": {}, + "sprite": "adventurer-premium-male", + "template_id": "open-desert-rogue-trooper", + "spawn_zone_id": "open-desert-rogue-zone-011", + "role": "skirmisher", + "faction": { + "faction_id": "rogue_troopers", + "social_group": "open_desert_rogues", + "pvp_status": "Overt" + }, + "player_organization_id": null, + "player_organization_tag": null, + "area_id": "open-desert-overworld", + "cell": { + "x": 611, + "y": 570 + }, + "position": { + "x": 611000, + "y": 570000 + }, + "direction": "left", + "scale": 1, + "home_area_id": "open-desert-overworld", + "home_cell": { + "x": 611, + "y": 570 + }, + "home_direction": "left", + "home_route": [], + "life_state": "alive", + "lifecycle_seq": 1, + "posture": "standing", + "posture_until_tick": 0, + "vitals": { + "health": 74, + "action": 73, + "spirit": 68 + }, + "max_vitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "effective_stats": { + "traits": { + "body": 74, + "spirit": 68 + }, + "spawn_vitals": { + "health": 74, + "action": 73, + "spirit": 68 + }, + "max_vitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "regen_rates_milli_per_second": { + "health": 819, + "action": 18930, + "spirit": 1509 + }, + "movement_speed_multiplier_milli": 900, + "suppression_resistance_milli": 680, + "suppression_threshold_milli": 16320, + "panic_duration_multiplier_milli": 1078, + "bleed_tolerance_milli": 886, + "dodge_chance_milli": 0, + "clone_sickness_tolerance_milli": 844, + "defense_vs_state_milli": 0 + }, + "professions": { + "learned": [ + "marksman" + ], + "xp": {}, + "track_xp": {}, + "skill_boxes": [ + "marksman-novice" + ], + "active_title_id": "marksman-novice", + "credits": 5000, + "skill_point_cap": 250 + }, + "capabilities": { + "granted": [ + "combat:ranged_basic", + "harvest:ammo", + "haul:exchange", + "self-preserve" + ] + }, + "capability_grants": [], + "career_goal_id": null, + "passive_regen_milli": { + "health": 0, + "action": 0, + "spirit": 0 + }, + "weather_damage_accumulated_milli": 0, + "bleed_stacks": [], + "consumable_effects": [], + "service_buffs": [], + "personal_shield": null, + "sprint_action_drain_milli": 0, + "sprint_recovery_locked": false, + "sprint_recovery_regen_carry": 0, + "sprint_regen_block_until_tick": 0, + "mobility": { + "sprint_moves": 0, + "sprint_ticks": 0, + "sprint_action_spent_milli": 0, + "sprint_distance_milli": 0, + "tactical_sprint_moves": 0, + "tactical_sprint_ticks": 0, + "tactical_sprint_action_spent_milli": 0, + "tactical_sprint_distance_milli": 0, + "last_sprint_tick": null, + "last_sprint_reason": null + }, + "downed_action_drain_milli": 0, + "downed_spirit_drain_milli": 0, + "incap_expires_tick": 0, + "incap_count": 0, + "incap_window_start_tick": 0, + "incap_grace_until_tick": 0, + "sleep": { + "stacks": 0, + "remaining_ticks": 0 + }, + "suppression": { + "pressure_milli": 0, + "source": null + }, + "ai": { + "Skirmisher": { + "mode": "HoldCover", + "target_actor_id": null, + "target": null, + "cover": null, + "next_decision_tick": 79659, + "next_shot_tick": 0, + "burst_shots_remaining": 0, + "attitude": "Passive", + "alert_until_tick": 0, + "next_update_tick": 0, + "last_update_tick": 0, + "last_move_dx_milli": 0, + "last_move_dy_milli": 0, + "last_move_tick": 0, + "progress_target": null, + "progress_best_distance_milli": 2147483647, + "progress_last_improved_tick": 0, + "blocked_target": null, + "blocked_target_until_tick": 0, + "seed": 4227280687 + } + }, + "next_fire_tick": 0, + "weapon_recoil_heat_milli": 0, + "weapon_recoil_last_tick": 73871, + "equipped_weapon_id": "Slugthrower", + "equipped_weapon_item_id": 0, + "equipped_weapon_variant_id": 0, + "slugthrower_magazine": { + "loaded_rounds": 30, + "reload_until_tick": 0 + }, + "combat_queue": { + "next_ready_tick": 0, + "entries": [], + "repeat_intent": null, + "pending_posture": null, + "sequence": 0 + }, + "combat_until_tick": 0, + "engagement_target_id": null, + "peace_requested": false, + "next_move_tick": 0, + "move_intent": null, + "next_economy_action_tick": 0, + "next_resource_survey_tick": 0, + "pending_resource_sample": null, + "resource_sample_loop": null, + "cranking_extractor_id": null, + "known_recipe_ids": [], + "scanned_genomes": [], + "next_starter_tool_request_tick": 0, + "shots_fired": 0, + "last_shot_tick": null, + "last_moved_tick": null, + "stats": { + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0, + "last_damage_dealt_tick": null, + "last_damage_taken_tick": null, + "last_kill_tick": null, + "last_death": null, + "buckets": [ + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + } + ] + }, + "route": [ + { + "x": 611, + "y": 570 + }, + { + "x": 612, + "y": 570 + } + ], + "route_index": 1, + "next_route_tick": 73889, + "player_damage_ledger": [], + "loot_rights_actor_id": null, + "body_vanish_tick": 0, + "respawn_tick": 0, + "corpse_exhausted_tick": null, + "creature_corpse_harvested_tick": null, + "clone_sickness_ticks": 0, + "respawn_return": [] + }, + "open-desert-rogue-011-02": { + "id": "open-desert-rogue-011-02", + "entity": "skirmisher:open-desert-rogue-011-02", + "label": "Mika Coil", + "display_name": "Mika Coil", + "descriptor": "a rogue drifter", + "link_dead": false, + "link_dead_expires_tick": 0, + "appearance": { + "skin": "#c78f62", + "hair": "hair_mop", + "hair_mat": "hair_raven" + }, + "worn": [], + "equipped_clothing": [], + "worn_colors": {}, + "sprite": "adventurer-premium-male", + "template_id": "open-desert-rogue-trooper", + "spawn_zone_id": "open-desert-rogue-zone-011", + "role": "skirmisher", + "faction": { + "faction_id": "rogue_troopers", + "social_group": "open_desert_rogues", + "pvp_status": "Overt" + }, + "player_organization_id": null, + "player_organization_tag": null, + "area_id": "open-desert-overworld", + "cell": { + "x": 612, + "y": 570 + }, + "position": { + "x": 612000, + "y": 570000 + }, + "direction": "left", + "scale": 1, + "home_area_id": "open-desert-overworld", + "home_cell": { + "x": 612, + "y": 570 + }, + "home_direction": "left", + "home_route": [], + "life_state": "alive", + "lifecycle_seq": 1, + "posture": "standing", + "posture_until_tick": 0, + "vitals": { + "health": 74, + "action": 73, + "spirit": 68 + }, + "max_vitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "effective_stats": { + "traits": { + "body": 74, + "spirit": 68 + }, + "spawn_vitals": { + "health": 74, + "action": 73, + "spirit": 68 + }, + "max_vitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "regen_rates_milli_per_second": { + "health": 819, + "action": 18930, + "spirit": 1509 + }, + "movement_speed_multiplier_milli": 900, + "suppression_resistance_milli": 680, + "suppression_threshold_milli": 16320, + "panic_duration_multiplier_milli": 1078, + "bleed_tolerance_milli": 886, + "dodge_chance_milli": 0, + "clone_sickness_tolerance_milli": 844, + "defense_vs_state_milli": 0 + }, + "professions": { + "learned": [ + "marksman" + ], + "xp": {}, + "track_xp": {}, + "skill_boxes": [ + "marksman-novice" + ], + "active_title_id": "marksman-novice", + "credits": 5000, + "skill_point_cap": 250 + }, + "capabilities": { + "granted": [ + "combat:ranged_basic", + "harvest:ammo", + "haul:exchange", + "self-preserve" + ] + }, + "capability_grants": [], + "career_goal_id": null, + "passive_regen_milli": { + "health": 0, + "action": 0, + "spirit": 0 + }, + "weather_damage_accumulated_milli": 0, + "bleed_stacks": [], + "consumable_effects": [], + "service_buffs": [], + "personal_shield": null, + "sprint_action_drain_milli": 0, + "sprint_recovery_locked": false, + "sprint_recovery_regen_carry": 0, + "sprint_regen_block_until_tick": 0, + "mobility": { + "sprint_moves": 0, + "sprint_ticks": 0, + "sprint_action_spent_milli": 0, + "sprint_distance_milli": 0, + "tactical_sprint_moves": 0, + "tactical_sprint_ticks": 0, + "tactical_sprint_action_spent_milli": 0, + "tactical_sprint_distance_milli": 0, + "last_sprint_tick": null, + "last_sprint_reason": null + }, + "downed_action_drain_milli": 0, + "downed_spirit_drain_milli": 0, + "incap_expires_tick": 0, + "incap_count": 0, + "incap_window_start_tick": 0, + "incap_grace_until_tick": 0, + "sleep": { + "stacks": 0, + "remaining_ticks": 0 + }, + "suppression": { + "pressure_milli": 0, + "source": null + }, + "ai": { + "Skirmisher": { + "mode": "HoldCover", + "target_actor_id": null, + "target": null, + "cover": null, + "next_decision_tick": 79659, + "next_shot_tick": 0, + "burst_shots_remaining": 0, + "attitude": "Passive", + "alert_until_tick": 0, + "next_update_tick": 0, + "last_update_tick": 0, + "last_move_dx_milli": 0, + "last_move_dy_milli": 0, + "last_move_tick": 0, + "progress_target": null, + "progress_best_distance_milli": 2147483647, + "progress_last_improved_tick": 0, + "blocked_target": null, + "blocked_target_until_tick": 0, + "seed": 4244058306 + } + }, + "next_fire_tick": 0, + "weapon_recoil_heat_milli": 0, + "weapon_recoil_last_tick": 73871, + "equipped_weapon_id": "Slugthrower", + "equipped_weapon_item_id": 0, + "equipped_weapon_variant_id": 0, + "slugthrower_magazine": { + "loaded_rounds": 30, + "reload_until_tick": 0 + }, + "combat_queue": { + "next_ready_tick": 0, + "entries": [], + "repeat_intent": null, + "pending_posture": null, + "sequence": 0 + }, + "combat_until_tick": 0, + "engagement_target_id": null, + "peace_requested": false, + "next_move_tick": 0, + "move_intent": null, + "next_economy_action_tick": 0, + "next_resource_survey_tick": 0, + "pending_resource_sample": null, + "resource_sample_loop": null, + "cranking_extractor_id": null, + "known_recipe_ids": [], + "scanned_genomes": [], + "next_starter_tool_request_tick": 0, + "shots_fired": 0, + "last_shot_tick": null, + "last_moved_tick": null, + "stats": { + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0, + "last_damage_dealt_tick": null, + "last_damage_taken_tick": null, + "last_kill_tick": null, + "last_death": null, + "buckets": [ + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + } + ] + }, + "route": [ + { + "x": 612, + "y": 570 + }, + { + "x": 611, + "y": 570 + } + ], + "route_index": 1, + "next_route_tick": 73889, + "player_damage_ledger": [], + "loot_rights_actor_id": null, + "body_vanish_tick": 0, + "respawn_tick": 0, + "corpse_exhausted_tick": null, + "creature_corpse_harvested_tick": null, + "clone_sickness_ticks": 0, + "respawn_return": [] + }, + "open-desert-snufflefin-07-13": { + "id": "open-desert-snufflefin-07-13", + "entity": "creature:open-desert-snufflefin-07-13", + "label": "snufflefin grazer", + "display_name": "snufflefin grazer", + "descriptor": "a snufflefin", + "link_dead": false, + "link_dead_expires_tick": 0, + "appearance": { + "skin": "#c78f62", + "hair": "hair_mop", + "hair_mat": "hair_raven" + }, + "worn": [], + "equipped_clothing": [], + "worn_colors": {}, + "sprite": "creature-snufflefin-adult", + "template_id": "open-desert-snufflefin", + "spawn_zone_id": "open-desert-snufflefin-zone-07", + "role": "creature", + "faction": { + "faction_id": "gaia", + "social_group": "open_desert_snufflefins", + "pvp_status": "Overt" + }, + "player_organization_id": null, + "player_organization_tag": null, + "area_id": "open-desert-overworld", + "cell": { + "x": 436, + "y": 595 + }, + "position": { + "x": 436332, + "y": 595000 + }, + "direction": "left", + "scale": 1, + "home_area_id": "open-desert-overworld", + "home_cell": { + "x": 436, + "y": 595 + }, + "home_direction": "left", + "home_route": [], + "life_state": "alive", + "lifecycle_seq": 1, + "posture": "standing", + "posture_until_tick": 0, + "vitals": { + "health": 60, + "action": 45, + "spirit": 40 + }, + "max_vitals": { + "health": 60, + "action": 45, + "spirit": 40 + }, + "effective_stats": { + "traits": { + "body": 58, + "spirit": 42 + }, + "spawn_vitals": { + "health": 58, + "action": 56, + "spirit": 42 + }, + "max_vitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "regen_rates_milli_per_second": { + "health": 699, + "action": 15810, + "spirit": 1057 + }, + "movement_speed_multiplier_milli": 885, + "suppression_resistance_milli": 420, + "suppression_threshold_milli": 10080, + "panic_duration_multiplier_milli": 1182, + "bleed_tolerance_milli": 813, + "dodge_chance_milli": 0, + "clone_sickness_tolerance_milli": 727, + "defense_vs_state_milli": 0 + }, + "professions": { + "learned": [], + "xp": {}, + "track_xp": {}, + "skill_boxes": [], + "active_title_id": null, + "credits": 5000, + "skill_point_cap": 250 + }, + "capabilities": { + "granted": [ + "haul:exchange", + "self-preserve" + ] + }, + "capability_grants": [], + "career_goal_id": null, + "passive_regen_milli": { + "health": 0, + "action": 0, + "spirit": 0 + }, + "weather_damage_accumulated_milli": 0, + "bleed_stacks": [], + "consumable_effects": [], + "service_buffs": [], + "personal_shield": null, + "sprint_action_drain_milli": 0, + "sprint_recovery_locked": false, + "sprint_recovery_regen_carry": 0, + "sprint_regen_block_until_tick": 0, + "mobility": { + "sprint_moves": 0, + "sprint_ticks": 0, + "sprint_action_spent_milli": 0, + "sprint_distance_milli": 0, + "tactical_sprint_moves": 0, + "tactical_sprint_ticks": 0, + "tactical_sprint_action_spent_milli": 0, + "tactical_sprint_distance_milli": 0, + "last_sprint_tick": null, + "last_sprint_reason": null + }, + "downed_action_drain_milli": 0, + "downed_spirit_drain_milli": 0, + "incap_expires_tick": 0, + "incap_count": 0, + "incap_window_start_tick": 0, + "incap_grace_until_tick": 0, + "sleep": { + "stacks": 0, + "remaining_ticks": 0 + }, + "suppression": { + "pressure_milli": 0, + "source": null + }, + "ai": { + "PassiveCreature": { + "mode": "Roam", + "target": null, + "threat": null, + "panic_until_tick": 0, + "chase_until_tick": 0, + "next_attack_tick": 0, + "next_decision_tick": 0, + "next_update_tick": 0, + "last_update_tick": 0, + "seed": 2818254465 + } + }, + "next_fire_tick": 0, + "weapon_recoil_heat_milli": 0, + "weapon_recoil_last_tick": 76443, + "equipped_weapon_id": "Slugthrower", + "equipped_weapon_item_id": 0, + "equipped_weapon_variant_id": 0, + "slugthrower_magazine": { + "loaded_rounds": 30, + "reload_until_tick": 0 + }, + "combat_queue": { + "next_ready_tick": 0, + "entries": [], + "repeat_intent": null, + "pending_posture": null, + "sequence": 0 + }, + "combat_until_tick": 0, + "engagement_target_id": null, + "peace_requested": false, + "next_move_tick": 0, + "move_intent": null, + "next_economy_action_tick": 0, + "next_resource_survey_tick": 0, + "pending_resource_sample": null, + "resource_sample_loop": null, + "cranking_extractor_id": null, + "known_recipe_ids": [], + "scanned_genomes": [], + "next_starter_tool_request_tick": 0, + "shots_fired": 0, + "last_shot_tick": null, + "last_moved_tick": 79656, + "stats": { + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 177668, + "last_damage_dealt_tick": null, + "last_damage_taken_tick": null, + "last_kill_tick": null, + "last_death": null, + "buckets": [ + { + "start_tick": 79200, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8334 + }, + { + "start_tick": 79350, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8334 + }, + { + "start_tick": 79500, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8332 + }, + { + "start_tick": 79650, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 501 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 76350, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 2167 + }, + { + "start_tick": 76500, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8334 + }, + { + "start_tick": 76650, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8334 + }, + { + "start_tick": 76800, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8332 + }, + { + "start_tick": 76950, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8334 + }, + { + "start_tick": 77100, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8334 + }, + { + "start_tick": 77250, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8332 + }, + { + "start_tick": 77400, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8334 + }, + { + "start_tick": 77550, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8334 + }, + { + "start_tick": 77700, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8332 + }, + { + "start_tick": 77850, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8334 + }, + { + "start_tick": 78000, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8334 + }, + { + "start_tick": 78150, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8332 + }, + { + "start_tick": 78300, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8334 + }, + { + "start_tick": 78450, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8334 + }, + { + "start_tick": 78600, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8332 + }, + { + "start_tick": 78750, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8334 + }, + { + "start_tick": 78900, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8334 + }, + { + "start_tick": 79050, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 8332 + } + ] + }, + "route": [ + { + "x": 436, + "y": 595 + }, + { + "x": 437, + "y": 595 + } + ], + "route_index": 0, + "next_route_tick": 79659, + "player_damage_ledger": [], + "loot_rights_actor_id": null, + "body_vanish_tick": 0, + "respawn_tick": 0, + "corpse_exhausted_tick": null, + "creature_corpse_harvested_tick": null, + "clone_sickness_ticks": 0, + "respawn_return": [] + }, + "player": { + "id": "player", + "entity": "1:1", + "label": "shot-1", + "display_name": "shot-1", + "descriptor": "", + "link_dead": false, + "link_dead_expires_tick": 0, + "appearance": { + "skin": "#c78f62", + "hair": "hair_mop", + "hair_mat": "hair_raven" + }, + "worn": [], + "equipped_clothing": [], + "worn_colors": {}, + "sprite": "adventurer-premium-male", + "template_id": null, + "spawn_zone_id": null, + "role": "player", + "faction": { + "faction_id": "desert_wardens", + "social_group": "open_desert_player", + "pvp_status": "Overt" + }, + "player_organization_id": null, + "player_organization_tag": null, + "area_id": "open-desert-overworld", + "cell": { + "x": 516, + "y": 511 + }, + "position": { + "x": 516000, + "y": 511000 + }, + "direction": "front", + "scale": 1, + "home_area_id": "open-desert-overworld", + "home_cell": { + "x": 516, + "y": 511 + }, + "home_direction": "front", + "home_route": [], + "life_state": "alive", + "lifecycle_seq": 1, + "posture": "standing", + "posture_until_tick": 0, + "vitals": { + "health": 280, + "action": 160, + "spirit": 100 + }, + "max_vitals": { + "health": 280, + "action": 160, + "spirit": 100 + }, + "effective_stats": { + "traits": { + "body": 100, + "spirit": 100 + }, + "spawn_vitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "max_vitals": { + "health": 100, + "action": 100, + "spirit": 100 + }, + "regen_rates_milli_per_second": { + "health": 6000, + "action": 24000, + "spirit": 2100 + }, + "movement_speed_multiplier_milli": 1000, + "suppression_resistance_milli": 1000, + "suppression_threshold_milli": 24000, + "panic_duration_multiplier_milli": 950, + "bleed_tolerance_milli": 1004, + "dodge_chance_milli": 100, + "clone_sickness_tolerance_milli": 1005, + "defense_vs_state_milli": 0 + }, + "professions": { + "learned": [ + "marksman" + ], + "xp": {}, + "track_xp": {}, + "skill_boxes": [ + "marksman-novice", + "marksman-rifle-i" + ], + "active_title_id": "marksman-novice", + "credits": 5000, + "skill_point_cap": 250 + }, + "capabilities": { + "granted": [ + "combat:ranged_basic", + "harvest:ammo", + "haul:exchange", + "self-preserve" + ] + }, + "capability_grants": [], + "career_goal_id": null, + "passive_regen_milli": { + "health": 0, + "action": 0, + "spirit": 0 + }, + "weather_damage_accumulated_milli": 0, + "bleed_stacks": [], + "consumable_effects": [], + "service_buffs": [], + "personal_shield": null, + "sprint_action_drain_milli": 0, + "sprint_recovery_locked": false, + "sprint_recovery_regen_carry": 0, + "sprint_regen_block_until_tick": 0, + "mobility": { + "sprint_moves": 0, + "sprint_ticks": 0, + "sprint_action_spent_milli": 0, + "sprint_distance_milli": 0, + "tactical_sprint_moves": 0, + "tactical_sprint_ticks": 0, + "tactical_sprint_action_spent_milli": 0, + "tactical_sprint_distance_milli": 0, + "last_sprint_tick": null, + "last_sprint_reason": null + }, + "downed_action_drain_milli": 0, + "downed_spirit_drain_milli": 0, + "incap_expires_tick": 0, + "incap_count": 0, + "incap_window_start_tick": 0, + "incap_grace_until_tick": 0, + "sleep": { + "stacks": 0, + "remaining_ticks": 0 + }, + "suppression": { + "pressure_milli": 0, + "source": null + }, + "ai": null, + "next_fire_tick": 0, + "weapon_recoil_heat_milli": 0, + "weapon_recoil_last_tick": 79658, + "equipped_weapon_id": "Slugthrower", + "equipped_weapon_item_id": 0, + "equipped_weapon_variant_id": 0, + "slugthrower_magazine": { + "loaded_rounds": 30, + "reload_until_tick": 0 + }, + "combat_queue": { + "next_ready_tick": 0, + "entries": [], + "repeat_intent": null, + "pending_posture": null, + "sequence": 0 + }, + "combat_until_tick": 0, + "engagement_target_id": null, + "peace_requested": false, + "next_move_tick": 0, + "move_intent": null, + "next_economy_action_tick": 0, + "next_resource_survey_tick": 0, + "pending_resource_sample": null, + "resource_sample_loop": null, + "cranking_extractor_id": null, + "known_recipe_ids": [], + "scanned_genomes": [], + "next_starter_tool_request_tick": 0, + "shots_fired": 0, + "last_shot_tick": null, + "last_moved_tick": null, + "stats": { + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 61472, + "last_damage_dealt_tick": null, + "last_damage_taken_tick": null, + "last_kill_tick": null, + "last_death": null, + "buckets": [ + { + "start_tick": 7200, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 2932 + }, + { + "start_tick": 72150, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 6030 + }, + { + "start_tick": 72300, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 2700 + }, + { + "start_tick": 450, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 1665 + }, + { + "start_tick": 15000, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 3015 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 900, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 405 + }, + { + "start_tick": 4650, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 720 + }, + { + "start_tick": 69600, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 720 + }, + { + "start_tick": 69750, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 1350 + }, + { + "start_tick": 1500, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 2932 + }, + { + "start_tick": 73650, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 1735 + }, + { + "start_tick": 73800, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 2745 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 13200, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 2447 + }, + { + "start_tick": 13350, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 5851 + }, + { + "start_tick": 13500, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 6750 + }, + { + "start_tick": 13650, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 135 + }, + { + "start_tick": 71400, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 2212 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 0, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 0 + }, + { + "start_tick": 7050, + "damage_done": 0, + "damage_taken": 0, + "kills": 0, + "npc_kills": 0, + "player_kills": 0, + "deaths": 0, + "shots_fired": 0, + "hits_dealt": 0, + "hits_taken": 0, + "distance_moved_milli": 1350 + } + ] + }, + "route": [], + "route_index": 0, + "next_route_tick": 79676, + "player_damage_ledger": [], + "loot_rights_actor_id": null, + "body_vanish_tick": 0, + "respawn_tick": 0, + "corpse_exhausted_tick": null, + "creature_corpse_harvested_tick": null, + "clone_sickness_ticks": 0, + "respawn_return": [] + } + }, + "barkClaims": [], + "population": { + "templates": { + "open-desert-bellback": { + "id": "open-desert-bellback", + "label_prefix": "Bellback", + "labels": [], + "role": "creature", + "faction_id": "gaia", + "social_group": "open_desert_bellbacks", + "pvp_status": "overt", + "player_organization_id": null, + "player_organization_tag": null, + "profession_ids": [], + "skill_box_ids": [], + "credits": null, + "capabilities": [], + "career_goal_id": null, + "sprite": "creature-bellback-adult", + "pose_set": "idle", + "direction": "left", + "scale": 1, + "vitals": { + "health": 60, + "action": 45, + "spirit": 40 + }, + "max_vitals": { + "health": 60, + "action": 45, + "spirit": 40 + } + }, + "open-desert-pebblehorn": { + "id": "open-desert-pebblehorn", + "label_prefix": "Pebblehorn", + "labels": [], + "role": "creature", + "faction_id": "gaia", + "social_group": "open_desert_pebblehorns", + "pvp_status": "overt", + "player_organization_id": null, + "player_organization_tag": null, + "profession_ids": [], + "skill_box_ids": [], + "credits": null, + "capabilities": [], + "career_goal_id": null, + "sprite": "creature-pebblehorn-adult", + "pose_set": "idle", + "direction": "left", + "scale": 1, + "vitals": { + "health": 60, + "action": 45, + "spirit": 40 + }, + "max_vitals": { + "health": 60, + "action": 45, + "spirit": 40 + } + }, + "open-desert-pocketclod": { + "id": "open-desert-pocketclod", + "label_prefix": "Pocketclod", + "labels": [], + "role": "creature", + "faction_id": "gaia", + "social_group": "open_desert_pocketclods", + "pvp_status": "overt", + "player_organization_id": null, + "player_organization_tag": null, + "profession_ids": [], + "skill_box_ids": [], + "credits": null, + "capabilities": [], + "career_goal_id": null, + "sprite": "creature-pocketclod-adult", + "pose_set": "idle", + "direction": "left", + "scale": 1, + "vitals": { + "health": 60, + "action": 45, + "spirit": 40 + }, + "max_vitals": { + "health": 60, + "action": 45, + "spirit": 40 + } + }, + "open-desert-rogue-trooper": { + "id": "open-desert-rogue-trooper", + "label_prefix": "Rogue Drifter", + "labels": [], + "role": "skirmisher", + "faction_id": "rogue_troopers", + "social_group": "open_desert_rogues", + "pvp_status": "overt", + "player_organization_id": null, + "player_organization_tag": null, + "profession_ids": [ + "marksman" + ], + "skill_box_ids": [ + "marksman-novice" + ], + "credits": null, + "capabilities": [], + "career_goal_id": null, + "sprite": "adventurer-premium-male", + "pose_set": "idle", + "direction": "left", + "scale": 1, + "vitals": null, + "max_vitals": null + }, + "open-desert-snufflefin": { + "id": "open-desert-snufflefin", + "label_prefix": "Snufflefin", + "labels": [], + "role": "creature", + "faction_id": "gaia", + "social_group": "open_desert_snufflefins", + "pvp_status": "overt", + "player_organization_id": null, + "player_organization_tag": null, + "profession_ids": [], + "skill_box_ids": [], + "credits": null, + "capabilities": [], + "career_goal_id": null, + "sprite": "creature-snufflefin-adult", + "pose_set": "idle", + "direction": "left", + "scale": 1, + "vitals": { + "health": 60, + "action": 45, + "spirit": 40 + }, + "max_vitals": { + "health": 60, + "action": 45, + "spirit": 40 + } + }, + "verdance-dapplepod": { + "id": "verdance-dapplepod", + "label_prefix": "Dapplepod", + "labels": [], + "role": "creature", + "faction_id": "gaia", + "social_group": "verdance_dapplepods", + "pvp_status": "overt", + "player_organization_id": null, + "player_organization_tag": null, + "profession_ids": [], + "skill_box_ids": [], + "credits": null, + "capabilities": [], + "career_goal_id": null, + "sprite": "creature-dapplepod-adult", + "pose_set": "idle", + "direction": "left", + "scale": 1, + "vitals": { + "health": 60, + "action": 45, + "spirit": 40 + }, + "max_vitals": { + "health": 60, + "action": 45, + "spirit": 40 + } + }, + "verdance-mossmuff": { + "id": "verdance-mossmuff", + "label_prefix": "Mossmuff", + "labels": [], + "role": "creature", + "faction_id": "gaia", + "social_group": "verdance_mossmuffs", + "pvp_status": "overt", + "player_organization_id": null, + "player_organization_tag": null, + "profession_ids": [], + "skill_box_ids": [], + "credits": null, + "capabilities": [], + "career_goal_id": null, + "sprite": "creature-mossmuff-adult", + "pose_set": "idle", + "direction": "left", + "scale": 1, + "vitals": { + "health": 60, + "action": 45, + "spirit": 40 + }, + "max_vitals": { + "health": 60, + "action": 45, + "spirit": 40 + } + } + }, + "spawn_zones": { + "open-desert-bellback-zone-01": { + "id": "open-desert-bellback-zone-01", + "actor_id_prefix": "open-desert-bellback-01", + "template_id": "open-desert-bellback", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 193, + "y": 97 + }, + { + "x": 194, + "y": 97 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 4157550374, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-bellback-zone-02": { + "id": "open-desert-bellback-zone-02", + "actor_id_prefix": "open-desert-bellback-02", + "template_id": "open-desert-bellback", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 806, + "y": 181 + }, + { + "x": 807, + "y": 181 + }, + { + "x": 806, + "y": 182 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 2264818513, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-bellback-zone-03": { + "id": "open-desert-bellback-zone-03", + "actor_id_prefix": "open-desert-bellback-03", + "template_id": "open-desert-bellback", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 591, + "y": 239 + }, + { + "x": 592, + "y": 239 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 1165036907, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-bellback-zone-04": { + "id": "open-desert-bellback-zone-04", + "actor_id_prefix": "open-desert-bellback-04", + "template_id": "open-desert-bellback", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 912, + "y": 294 + }, + { + "x": 913, + "y": 294 + }, + { + "x": 912, + "y": 295 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 3543822507, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-bellback-zone-05": { + "id": "open-desert-bellback-zone-05", + "actor_id_prefix": "open-desert-bellback-05", + "template_id": "open-desert-bellback", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 641, + "y": 390 + }, + { + "x": 642, + "y": 390 + }, + { + "x": 641, + "y": 391 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 2127263, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-bellback-zone-06": { + "id": "open-desert-bellback-zone-06", + "actor_id_prefix": "open-desert-bellback-06", + "template_id": "open-desert-bellback", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 150, + "y": 450 + }, + { + "x": 151, + "y": 450 + }, + { + "x": 150, + "y": 451 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 3918069808, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-bellback-zone-07": { + "id": "open-desert-bellback-zone-07", + "actor_id_prefix": "open-desert-bellback-07", + "template_id": "open-desert-bellback", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 226, + "y": 536 + }, + { + "x": 227, + "y": 536 + }, + { + "x": 226, + "y": 537 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 1039871293, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-bellback-zone-08": { + "id": "open-desert-bellback-zone-08", + "actor_id_prefix": "open-desert-bellback-08", + "template_id": "open-desert-bellback", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 739, + "y": 606 + }, + { + "x": 740, + "y": 606 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 3883271687, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-bellback-zone-09": { + "id": "open-desert-bellback-zone-09", + "actor_id_prefix": "open-desert-bellback-09", + "template_id": "open-desert-bellback", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 828, + "y": 686 + }, + { + "x": 829, + "y": 686 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 3182836587, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-bellback-zone-10": { + "id": "open-desert-bellback-zone-10", + "actor_id_prefix": "open-desert-bellback-10", + "template_id": "open-desert-bellback", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 873, + "y": 776 + }, + { + "x": 874, + "y": 776 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 3171812756, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-bellback-zone-11": { + "id": "open-desert-bellback-zone-11", + "actor_id_prefix": "open-desert-bellback-11", + "template_id": "open-desert-bellback", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 441, + "y": 825 + }, + { + "x": 442, + "y": 825 + }, + { + "x": 441, + "y": 826 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 750540803, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-bellback-zone-12": { + "id": "open-desert-bellback-zone-12", + "actor_id_prefix": "open-desert-bellback-12", + "template_id": "open-desert-bellback", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 648, + "y": 884 + }, + { + "x": 649, + "y": 884 + }, + { + "x": 648, + "y": 885 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 3842422972, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pebblehorn-zone-01": { + "id": "open-desert-pebblehorn-zone-01", + "actor_id_prefix": "open-desert-pebblehorn-01", + "template_id": "open-desert-pebblehorn", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 892, + "y": 130 + }, + { + "x": 893, + "y": 130 + }, + { + "x": 892, + "y": 131 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 1113318254, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pebblehorn-zone-02": { + "id": "open-desert-pebblehorn-zone-02", + "actor_id_prefix": "open-desert-pebblehorn-02", + "template_id": "open-desert-pebblehorn", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 435, + "y": 184 + }, + { + "x": 436, + "y": 184 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 2190823743, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pebblehorn-zone-03": { + "id": "open-desert-pebblehorn-zone-03", + "actor_id_prefix": "open-desert-pebblehorn-03", + "template_id": "open-desert-pebblehorn", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 461, + "y": 240 + }, + { + "x": 462, + "y": 240 + }, + { + "x": 461, + "y": 241 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 3394213751, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pebblehorn-zone-04": { + "id": "open-desert-pebblehorn-zone-04", + "actor_id_prefix": "open-desert-pebblehorn-04", + "template_id": "open-desert-pebblehorn", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 412, + "y": 301 + }, + { + "x": 413, + "y": 301 + }, + { + "x": 412, + "y": 302 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 3685874249, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pebblehorn-zone-05": { + "id": "open-desert-pebblehorn-zone-05", + "actor_id_prefix": "open-desert-pebblehorn-05", + "template_id": "open-desert-pebblehorn", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 96, + "y": 408 + }, + { + "x": 97, + "y": 408 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 2765260347, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pebblehorn-zone-06": { + "id": "open-desert-pebblehorn-zone-06", + "actor_id_prefix": "open-desert-pebblehorn-06", + "template_id": "open-desert-pebblehorn", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 113, + "y": 510 + }, + { + "x": 114, + "y": 510 + }, + { + "x": 113, + "y": 511 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 2606480163, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pebblehorn-zone-07": { + "id": "open-desert-pebblehorn-zone-07", + "actor_id_prefix": "open-desert-pebblehorn-07", + "template_id": "open-desert-pebblehorn", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 284, + "y": 567 + }, + { + "x": 285, + "y": 567 + }, + { + "x": 284, + "y": 568 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 177182484, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pebblehorn-zone-08": { + "id": "open-desert-pebblehorn-zone-08", + "actor_id_prefix": "open-desert-pebblehorn-08", + "template_id": "open-desert-pebblehorn", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 651, + "y": 646 + }, + { + "x": 652, + "y": 646 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 3501380095, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pebblehorn-zone-09": { + "id": "open-desert-pebblehorn-zone-09", + "actor_id_prefix": "open-desert-pebblehorn-09", + "template_id": "open-desert-pebblehorn", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 705, + "y": 718 + }, + { + "x": 706, + "y": 718 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 1694998193, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pebblehorn-zone-10": { + "id": "open-desert-pebblehorn-zone-10", + "actor_id_prefix": "open-desert-pebblehorn-10", + "template_id": "open-desert-pebblehorn", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 692, + "y": 777 + }, + { + "x": 693, + "y": 777 + }, + { + "x": 692, + "y": 778 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 4007798128, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pebblehorn-zone-11": { + "id": "open-desert-pebblehorn-zone-11", + "actor_id_prefix": "open-desert-pebblehorn-11", + "template_id": "open-desert-pebblehorn", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 841, + "y": 838 + }, + { + "x": 842, + "y": 838 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 1482418977, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pebblehorn-zone-12": { + "id": "open-desert-pebblehorn-zone-12", + "actor_id_prefix": "open-desert-pebblehorn-12", + "template_id": "open-desert-pebblehorn", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 917, + "y": 893 + }, + { + "x": 918, + "y": 893 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 3711214550, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pocketclod-zone-01": { + "id": "open-desert-pocketclod-zone-01", + "actor_id_prefix": "open-desert-pocketclod-01", + "template_id": "open-desert-pocketclod", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 269, + "y": 175 + }, + { + "x": 270, + "y": 175 + }, + { + "x": 269, + "y": 176 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 1544895564, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pocketclod-zone-02": { + "id": "open-desert-pocketclod-zone-02", + "actor_id_prefix": "open-desert-pocketclod-02", + "template_id": "open-desert-pocketclod", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 924, + "y": 191 + }, + { + "x": 925, + "y": 191 + }, + { + "x": 924, + "y": 192 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 662510880, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pocketclod-zone-03": { + "id": "open-desert-pocketclod-zone-03", + "actor_id_prefix": "open-desert-pocketclod-03", + "template_id": "open-desert-pocketclod", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 330, + "y": 279 + }, + { + "x": 331, + "y": 279 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 3023524577, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pocketclod-zone-04": { + "id": "open-desert-pocketclod-zone-04", + "actor_id_prefix": "open-desert-pocketclod-04", + "template_id": "open-desert-pocketclod", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 827, + "y": 367 + }, + { + "x": 828, + "y": 367 + }, + { + "x": 827, + "y": 368 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 2627518319, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pocketclod-zone-05": { + "id": "open-desert-pocketclod-zone-05", + "actor_id_prefix": "open-desert-pocketclod-05", + "template_id": "open-desert-pocketclod", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 872, + "y": 447 + }, + { + "x": 873, + "y": 447 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 639661900, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pocketclod-zone-06": { + "id": "open-desert-pocketclod-zone-06", + "actor_id_prefix": "open-desert-pocketclod-06", + "template_id": "open-desert-pocketclod", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 773, + "y": 522 + }, + { + "x": 774, + "y": 522 + }, + { + "x": 773, + "y": 523 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 604355286, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pocketclod-zone-07": { + "id": "open-desert-pocketclod-zone-07", + "actor_id_prefix": "open-desert-pocketclod-07", + "template_id": "open-desert-pocketclod", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 106, + "y": 597 + }, + { + "x": 107, + "y": 597 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 3411514813, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pocketclod-zone-08": { + "id": "open-desert-pocketclod-zone-08", + "actor_id_prefix": "open-desert-pocketclod-08", + "template_id": "open-desert-pocketclod", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 302, + "y": 686 + }, + { + "x": 303, + "y": 686 + }, + { + "x": 302, + "y": 687 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 4264865727, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pocketclod-zone-09": { + "id": "open-desert-pocketclod-zone-09", + "actor_id_prefix": "open-desert-pocketclod-09", + "template_id": "open-desert-pocketclod", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 611, + "y": 774 + }, + { + "x": 612, + "y": 774 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 224638247, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pocketclod-zone-10": { + "id": "open-desert-pocketclod-zone-10", + "actor_id_prefix": "open-desert-pocketclod-10", + "template_id": "open-desert-pocketclod", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 387, + "y": 797 + }, + { + "x": 388, + "y": 797 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 2039068251, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pocketclod-zone-11": { + "id": "open-desert-pocketclod-zone-11", + "actor_id_prefix": "open-desert-pocketclod-11", + "template_id": "open-desert-pocketclod", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 707, + "y": 879 + }, + { + "x": 708, + "y": 879 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 2351781475, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-pocketclod-zone-12": { + "id": "open-desert-pocketclod-zone-12", + "actor_id_prefix": "open-desert-pocketclod-12", + "template_id": "open-desert-pocketclod", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 841, + "y": 924 + }, + { + "x": 842, + "y": 924 + }, + { + "x": 841, + "y": 925 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 1105591916, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-rogue-zone-001": { + "id": "open-desert-rogue-zone-001", + "actor_id_prefix": "open-desert-rogue-001", + "template_id": "open-desert-rogue-trooper", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 172, + "y": 162 + }, + { + "x": 173, + "y": 162 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 4500, + "batch_min": 1, + "batch_max": 1, + "seed": 3167651517, + "total_spawned": 0, + "next_spawn_tick": 4500, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-rogue-zone-002": { + "id": "open-desert-rogue-zone-002", + "actor_id_prefix": "open-desert-rogue-002", + "template_id": "open-desert-rogue-trooper", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 558, + "y": 171 + }, + { + "x": 559, + "y": 171 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 4500, + "batch_min": 1, + "batch_max": 1, + "seed": 573232659, + "total_spawned": 0, + "next_spawn_tick": 4500, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-rogue-zone-003": { + "id": "open-desert-rogue-zone-003", + "actor_id_prefix": "open-desert-rogue-003", + "template_id": "open-desert-rogue-trooper", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 737, + "y": 237 + }, + { + "x": 738, + "y": 237 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 4500, + "batch_min": 1, + "batch_max": 1, + "seed": 1406304541, + "total_spawned": 0, + "next_spawn_tick": 4500, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-rogue-zone-004": { + "id": "open-desert-rogue-zone-004", + "actor_id_prefix": "open-desert-rogue-004", + "template_id": "open-desert-rogue-trooper", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 832, + "y": 255 + }, + { + "x": 833, + "y": 255 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 4500, + "batch_min": 1, + "batch_max": 1, + "seed": 4158119059, + "total_spawned": 0, + "next_spawn_tick": 4500, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-rogue-zone-005": { + "id": "open-desert-rogue-zone-005", + "actor_id_prefix": "open-desert-rogue-005", + "template_id": "open-desert-rogue-trooper", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 688, + "y": 323 + }, + { + "x": 689, + "y": 323 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 4500, + "batch_min": 1, + "batch_max": 1, + "seed": 2503778947, + "total_spawned": 0, + "next_spawn_tick": 4500, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-rogue-zone-006": { + "id": "open-desert-rogue-zone-006", + "actor_id_prefix": "open-desert-rogue-006", + "template_id": "open-desert-rogue-trooper", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 357, + "y": 341 + }, + { + "x": 358, + "y": 341 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 4500, + "batch_min": 1, + "batch_max": 1, + "seed": 2792834875, + "total_spawned": 0, + "next_spawn_tick": 4500, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-rogue-zone-007": { + "id": "open-desert-rogue-zone-007", + "actor_id_prefix": "open-desert-rogue-007", + "template_id": "open-desert-rogue-trooper", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 912, + "y": 355 + }, + { + "x": 913, + "y": 355 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 4500, + "batch_min": 1, + "batch_max": 1, + "seed": 1186001170, + "total_spawned": 0, + "next_spawn_tick": 4500, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-rogue-zone-008": { + "id": "open-desert-rogue-zone-008", + "actor_id_prefix": "open-desert-rogue-008", + "template_id": "open-desert-rogue-trooper", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 541, + "y": 359 + }, + { + "x": 542, + "y": 359 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 4500, + "batch_min": 1, + "batch_max": 1, + "seed": 162558694, + "total_spawned": 0, + "next_spawn_tick": 4500, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-rogue-zone-009": { + "id": "open-desert-rogue-zone-009", + "actor_id_prefix": "open-desert-rogue-009", + "template_id": "open-desert-rogue-trooper", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 240, + "y": 384 + }, + { + "x": 241, + "y": 384 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 4500, + "batch_min": 1, + "batch_max": 1, + "seed": 710540690, + "total_spawned": 0, + "next_spawn_tick": 4500, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-rogue-zone-010": { + "id": "open-desert-rogue-zone-010", + "actor_id_prefix": "open-desert-rogue-010", + "template_id": "open-desert-rogue-trooper", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 218, + "y": 462 + }, + { + "x": 219, + "y": 462 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 4500, + "batch_min": 1, + "batch_max": 1, + "seed": 2507489417, + "total_spawned": 0, + "next_spawn_tick": 4500, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-rogue-zone-011": { + "id": "open-desert-rogue-zone-011", + "actor_id_prefix": "open-desert-rogue-011", + "template_id": "open-desert-rogue-trooper", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 611, + "y": 570 + }, + { + "x": 612, + "y": 570 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 4500, + "batch_min": 1, + "batch_max": 1, + "seed": 3349184142, + "total_spawned": 2, + "next_spawn_tick": 4500, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": true, + "last_player_within_leash_tick": 79031, + "last_player_within_deactivation_tick": 79651, + "next_activation_check_tick": 79661 + }, + "open-desert-rogue-zone-012": { + "id": "open-desert-rogue-zone-012", + "actor_id_prefix": "open-desert-rogue-012", + "template_id": "open-desert-rogue-trooper", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 512, + "y": 659 + }, + { + "x": 513, + "y": 659 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 4500, + "batch_min": 1, + "batch_max": 1, + "seed": 2229072897, + "total_spawned": 0, + "next_spawn_tick": 4500, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-rogue-zone-013": { + "id": "open-desert-rogue-zone-013", + "actor_id_prefix": "open-desert-rogue-013", + "template_id": "open-desert-rogue-trooper", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 773, + "y": 696 + }, + { + "x": 774, + "y": 696 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 4500, + "batch_min": 1, + "batch_max": 1, + "seed": 675404209, + "total_spawned": 0, + "next_spawn_tick": 4500, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-rogue-zone-014": { + "id": "open-desert-rogue-zone-014", + "actor_id_prefix": "open-desert-rogue-014", + "template_id": "open-desert-rogue-trooper", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 387, + "y": 705 + }, + { + "x": 388, + "y": 705 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 4500, + "batch_min": 1, + "batch_max": 1, + "seed": 2255380479, + "total_spawned": 0, + "next_spawn_tick": 4500, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-rogue-zone-015": { + "id": "open-desert-rogue-zone-015", + "actor_id_prefix": "open-desert-rogue-015", + "template_id": "open-desert-rogue-trooper", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 752, + "y": 780 + }, + { + "x": 753, + "y": 780 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 4500, + "batch_min": 1, + "batch_max": 1, + "seed": 3428926304, + "total_spawned": 0, + "next_spawn_tick": 4500, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-rogue-zone-016": { + "id": "open-desert-rogue-zone-016", + "actor_id_prefix": "open-desert-rogue-016", + "template_id": "open-desert-rogue-trooper", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 188, + "y": 827 + }, + { + "x": 189, + "y": 827 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 4500, + "batch_min": 1, + "batch_max": 1, + "seed": 971672543, + "total_spawned": 0, + "next_spawn_tick": 4500, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-rogue-zone-017": { + "id": "open-desert-rogue-zone-017", + "actor_id_prefix": "open-desert-rogue-017", + "template_id": "open-desert-rogue-trooper", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 552, + "y": 907 + }, + { + "x": 553, + "y": 907 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 4500, + "batch_min": 1, + "batch_max": 1, + "seed": 3872293346, + "total_spawned": 0, + "next_spawn_tick": 4500, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-rogue-zone-018": { + "id": "open-desert-rogue-zone-018", + "actor_id_prefix": "open-desert-rogue-018", + "template_id": "open-desert-rogue-trooper", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 154, + "y": 908 + }, + { + "x": 155, + "y": 908 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 4500, + "batch_min": 1, + "batch_max": 1, + "seed": 1720482208, + "total_spawned": 0, + "next_spawn_tick": 4500, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-snufflefin-zone-01": { + "id": "open-desert-snufflefin-zone-01", + "actor_id_prefix": "open-desert-snufflefin-01", + "template_id": "open-desert-snufflefin", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 377, + "y": 142 + }, + { + "x": 378, + "y": 142 + }, + { + "x": 377, + "y": 143 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 2412219389, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-snufflefin-zone-02": { + "id": "open-desert-snufflefin-zone-02", + "actor_id_prefix": "open-desert-snufflefin-02", + "template_id": "open-desert-snufflefin", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 860, + "y": 185 + }, + { + "x": 861, + "y": 185 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 456846815, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-snufflefin-zone-03": { + "id": "open-desert-snufflefin-zone-03", + "actor_id_prefix": "open-desert-snufflefin-03", + "template_id": "open-desert-snufflefin", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 170, + "y": 269 + }, + { + "x": 171, + "y": 269 + }, + { + "x": 170, + "y": 270 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 1176551325, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-snufflefin-zone-04": { + "id": "open-desert-snufflefin-zone-04", + "actor_id_prefix": "open-desert-snufflefin-04", + "template_id": "open-desert-snufflefin", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 475, + "y": 338 + }, + { + "x": 476, + "y": 338 + }, + { + "x": 475, + "y": 339 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 1094350358, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-snufflefin-zone-05": { + "id": "open-desert-snufflefin-zone-05", + "actor_id_prefix": "open-desert-snufflefin-05", + "template_id": "open-desert-snufflefin", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 701, + "y": 446 + }, + { + "x": 702, + "y": 446 + }, + { + "x": 701, + "y": 447 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 4172979021, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-snufflefin-zone-06": { + "id": "open-desert-snufflefin-zone-06", + "actor_id_prefix": "open-desert-snufflefin-06", + "template_id": "open-desert-snufflefin", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 656, + "y": 522 + }, + { + "x": 657, + "y": 522 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 254825040, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-snufflefin-zone-07": { + "id": "open-desert-snufflefin-zone-07", + "actor_id_prefix": "open-desert-snufflefin-07", + "template_id": "open-desert-snufflefin", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 436, + "y": 595 + }, + { + "x": 437, + "y": 595 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 4192970122, + "total_spawned": 13, + "next_spawn_tick": 81843, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": true, + "last_player_within_leash_tick": 25261, + "last_player_within_deactivation_tick": 79651, + "next_activation_check_tick": 79661 + }, + "open-desert-snufflefin-zone-08": { + "id": "open-desert-snufflefin-zone-08", + "actor_id_prefix": "open-desert-snufflefin-08", + "template_id": "open-desert-snufflefin", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 435, + "y": 649 + }, + { + "x": 436, + "y": 649 + }, + { + "x": 435, + "y": 650 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 2032560455, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-snufflefin-zone-09": { + "id": "open-desert-snufflefin-zone-09", + "actor_id_prefix": "open-desert-snufflefin-09", + "template_id": "open-desert-snufflefin", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 307, + "y": 742 + }, + { + "x": 308, + "y": 742 + }, + { + "x": 307, + "y": 743 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 47838241, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-snufflefin-zone-10": { + "id": "open-desert-snufflefin-zone-10", + "actor_id_prefix": "open-desert-snufflefin-10", + "template_id": "open-desert-snufflefin", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 522, + "y": 782 + }, + { + "x": 523, + "y": 782 + }, + { + "x": 522, + "y": 783 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 2681290586, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-snufflefin-zone-11": { + "id": "open-desert-snufflefin-zone-11", + "actor_id_prefix": "open-desert-snufflefin-11", + "template_id": "open-desert-snufflefin", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 583, + "y": 862 + }, + { + "x": 584, + "y": 862 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 3628402659, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-snufflefin-zone-12": { + "id": "open-desert-snufflefin-zone-12", + "actor_id_prefix": "open-desert-snufflefin-12", + "template_id": "open-desert-snufflefin", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 370, + "y": 924 + }, + { + "x": 371, + "y": 924 + }, + { + "x": 370, + "y": 925 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 3417752747, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "open-desert-sparring-zone": { + "id": "open-desert-sparring-zone", + "actor_id_prefix": "open-desert-sparring", + "template_id": "open-desert-rogue-trooper", + "area_id": "open-desert-overworld", + "candidate_cells": [ + { + "x": 592, + "y": 512 + }, + { + "x": 593, + "y": 512 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 450, + "batch_min": 1, + "batch_max": 1, + "seed": 4076911106, + "total_spawned": 0, + "next_spawn_tick": 450, + "activation_radius_milli": 16000, + "leash_radius_milli": 16000, + "leash_release_ticks": 120, + "deactivation_radius_milli": 40000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-dapplepod-zone-01": { + "id": "verdance-dapplepod-zone-01", + "actor_id_prefix": "verdance-dapplepod-01", + "template_id": "verdance-dapplepod", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 450, + "y": 107 + }, + { + "x": 451, + "y": 107 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 305181563, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-dapplepod-zone-02": { + "id": "verdance-dapplepod-zone-02", + "actor_id_prefix": "verdance-dapplepod-02", + "template_id": "verdance-dapplepod", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 259, + "y": 179 + }, + { + "x": 260, + "y": 179 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 120396127, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-dapplepod-zone-03": { + "id": "verdance-dapplepod-zone-03", + "actor_id_prefix": "verdance-dapplepod-03", + "template_id": "verdance-dapplepod", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 622, + "y": 214 + }, + { + "x": 623, + "y": 214 + }, + { + "x": 622, + "y": 215 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 2725785007, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-dapplepod-zone-04": { + "id": "verdance-dapplepod-zone-04", + "actor_id_prefix": "verdance-dapplepod-04", + "template_id": "verdance-dapplepod", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 882, + "y": 232 + }, + { + "x": 883, + "y": 232 + }, + { + "x": 882, + "y": 233 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 3078956868, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-dapplepod-zone-05": { + "id": "verdance-dapplepod-zone-05", + "actor_id_prefix": "verdance-dapplepod-05", + "template_id": "verdance-dapplepod", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 255, + "y": 315 + }, + { + "x": 256, + "y": 315 + }, + { + "x": 255, + "y": 316 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 2410214966, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-dapplepod-zone-06": { + "id": "verdance-dapplepod-zone-06", + "actor_id_prefix": "verdance-dapplepod-06", + "template_id": "verdance-dapplepod", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 299, + "y": 440 + }, + { + "x": 300, + "y": 440 + }, + { + "x": 299, + "y": 441 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 1783891519, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-dapplepod-zone-07": { + "id": "verdance-dapplepod-zone-07", + "actor_id_prefix": "verdance-dapplepod-07", + "template_id": "verdance-dapplepod", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 745, + "y": 466 + }, + { + "x": 746, + "y": 466 + }, + { + "x": 745, + "y": 467 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 1661614358, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-dapplepod-zone-08": { + "id": "verdance-dapplepod-zone-08", + "actor_id_prefix": "verdance-dapplepod-08", + "template_id": "verdance-dapplepod", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 813, + "y": 554 + }, + { + "x": 814, + "y": 554 + }, + { + "x": 813, + "y": 555 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 1980465446, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-dapplepod-zone-09": { + "id": "verdance-dapplepod-zone-09", + "actor_id_prefix": "verdance-dapplepod-09", + "template_id": "verdance-dapplepod", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 334, + "y": 577 + }, + { + "x": 335, + "y": 577 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 1031632830, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-dapplepod-zone-10": { + "id": "verdance-dapplepod-zone-10", + "actor_id_prefix": "verdance-dapplepod-10", + "template_id": "verdance-dapplepod", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 879, + "y": 693 + }, + { + "x": 880, + "y": 693 + }, + { + "x": 879, + "y": 694 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 2806852108, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-dapplepod-zone-11": { + "id": "verdance-dapplepod-zone-11", + "actor_id_prefix": "verdance-dapplepod-11", + "template_id": "verdance-dapplepod", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 683, + "y": 711 + }, + { + "x": 684, + "y": 711 + }, + { + "x": 683, + "y": 712 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 857302998, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-dapplepod-zone-12": { + "id": "verdance-dapplepod-zone-12", + "actor_id_prefix": "verdance-dapplepod-12", + "template_id": "verdance-dapplepod", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 383, + "y": 812 + }, + { + "x": 384, + "y": 812 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 2489779247, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-mossmuff-zone-01": { + "id": "verdance-mossmuff-zone-01", + "actor_id_prefix": "verdance-mossmuff-01", + "template_id": "verdance-mossmuff", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 693, + "y": 102 + }, + { + "x": 694, + "y": 102 + }, + { + "x": 693, + "y": 103 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 843088770, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-mossmuff-zone-02": { + "id": "verdance-mossmuff-zone-02", + "actor_id_prefix": "verdance-mossmuff-02", + "template_id": "verdance-mossmuff", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 817, + "y": 142 + }, + { + "x": 818, + "y": 142 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 2780244545, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-mossmuff-zone-03": { + "id": "verdance-mossmuff-zone-03", + "actor_id_prefix": "verdance-mossmuff-03", + "template_id": "verdance-mossmuff", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 727, + "y": 202 + }, + { + "x": 728, + "y": 202 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 404069888, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-mossmuff-zone-04": { + "id": "verdance-mossmuff-zone-04", + "actor_id_prefix": "verdance-mossmuff-04", + "template_id": "verdance-mossmuff", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 102, + "y": 223 + }, + { + "x": 103, + "y": 223 + }, + { + "x": 102, + "y": 224 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 1052425173, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-mossmuff-zone-05": { + "id": "verdance-mossmuff-zone-05", + "actor_id_prefix": "verdance-mossmuff-05", + "template_id": "verdance-mossmuff", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 543, + "y": 278 + }, + { + "x": 544, + "y": 278 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 2077052343, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-mossmuff-zone-06": { + "id": "verdance-mossmuff-zone-06", + "actor_id_prefix": "verdance-mossmuff-06", + "template_id": "verdance-mossmuff", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 524, + "y": 381 + }, + { + "x": 525, + "y": 381 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 3314575586, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-mossmuff-zone-07": { + "id": "verdance-mossmuff-zone-07", + "actor_id_prefix": "verdance-mossmuff-07", + "template_id": "verdance-mossmuff", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 225, + "y": 444 + }, + { + "x": 226, + "y": 444 + }, + { + "x": 225, + "y": 445 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 1303853385, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-mossmuff-zone-08": { + "id": "verdance-mossmuff-zone-08", + "actor_id_prefix": "verdance-mossmuff-08", + "template_id": "verdance-mossmuff", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 249, + "y": 552 + }, + { + "x": 250, + "y": 552 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 162385130, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-mossmuff-zone-09": { + "id": "verdance-mossmuff-zone-09", + "actor_id_prefix": "verdance-mossmuff-09", + "template_id": "verdance-mossmuff", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 923, + "y": 566 + }, + { + "x": 924, + "y": 566 + } + ], + "initial_count": 2, + "max_alive": 2, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 3839202301, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-mossmuff-zone-10": { + "id": "verdance-mossmuff-zone-10", + "actor_id_prefix": "verdance-mossmuff-10", + "template_id": "verdance-mossmuff", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 645, + "y": 591 + }, + { + "x": 646, + "y": 591 + }, + { + "x": 645, + "y": 592 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 1320913023, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-mossmuff-zone-11": { + "id": "verdance-mossmuff-zone-11", + "actor_id_prefix": "verdance-mossmuff-11", + "template_id": "verdance-mossmuff", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 487, + "y": 708 + }, + { + "x": 488, + "y": 708 + }, + { + "x": 487, + "y": 709 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 1416666607, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + }, + "verdance-mossmuff-zone-12": { + "id": "verdance-mossmuff-zone-12", + "actor_id_prefix": "verdance-mossmuff-12", + "template_id": "verdance-mossmuff", + "area_id": "verdance-forest-overworld", + "candidate_cells": [ + { + "x": 303, + "y": 726 + }, + { + "x": 304, + "y": 726 + }, + { + "x": 303, + "y": 727 + } + ], + "initial_count": 3, + "max_alive": 3, + "spawn_every_ticks": 5400, + "batch_min": 1, + "batch_max": 1, + "seed": 2709770860, + "total_spawned": 0, + "next_spawn_tick": 5400, + "activation_radius_milli": 112000, + "leash_radius_milli": 112000, + "leash_release_ticks": 240, + "deactivation_radius_milli": 168000, + "linger_ticks": 300, + "activation_check_every_ticks": 10, + "cleared": false, + "defeated_slots": [], + "active": false, + "last_player_within_leash_tick": 0, + "last_player_within_deactivation_tick": 0, + "next_activation_check_tick": 79661 + } + }, + "actor_sources": { + "open-desert-rogue-011-01": { + "template_id": "open-desert-rogue-trooper", + "spawn_zone_id": "open-desert-rogue-zone-011" + }, + "open-desert-rogue-011-02": { + "template_id": "open-desert-rogue-trooper", + "spawn_zone_id": "open-desert-rogue-zone-011" + }, + "open-desert-snufflefin-07-13": { + "template_id": "open-desert-snufflefin", + "spawn_zone_id": "open-desert-snufflefin-zone-07" + } + } + }, + "lootCaches": { + "open-desert-rogue-zone-001-footlocker": { + "prop_id": "open-desert-rogue-zone-001-footlocker", + "area_id": "open-desert-overworld", + "position": { + "x": 172500, + "y": 160500 + }, + "cell": { + "x": 172, + "y": 160 + }, + "container": "footlocker:open-desert-rogue-zone-001-footlocker", + "interaction_radius_milli": 1750, + "emptied": false + }, + "open-desert-rogue-zone-002-footlocker": { + "prop_id": "open-desert-rogue-zone-002-footlocker", + "area_id": "open-desert-overworld", + "position": { + "x": 558500, + "y": 169500 + }, + "cell": { + "x": 558, + "y": 169 + }, + "container": "footlocker:open-desert-rogue-zone-002-footlocker", + "interaction_radius_milli": 1750, + "emptied": false + }, + "open-desert-rogue-zone-003-footlocker": { + "prop_id": "open-desert-rogue-zone-003-footlocker", + "area_id": "open-desert-overworld", + "position": { + "x": 737500, + "y": 235500 + }, + "cell": { + "x": 737, + "y": 235 + }, + "container": "footlocker:open-desert-rogue-zone-003-footlocker", + "interaction_radius_milli": 1750, + "emptied": false + }, + "open-desert-rogue-zone-004-footlocker": { + "prop_id": "open-desert-rogue-zone-004-footlocker", + "area_id": "open-desert-overworld", + "position": { + "x": 832500, + "y": 257500 + }, + "cell": { + "x": 832, + "y": 257 + }, + "container": "footlocker:open-desert-rogue-zone-004-footlocker", + "interaction_radius_milli": 1750, + "emptied": false + }, + "open-desert-rogue-zone-005-footlocker": { + "prop_id": "open-desert-rogue-zone-005-footlocker", + "area_id": "open-desert-overworld", + "position": { + "x": 688500, + "y": 325500 + }, + "cell": { + "x": 688, + "y": 325 + }, + "container": "footlocker:open-desert-rogue-zone-005-footlocker", + "interaction_radius_milli": 1750, + "emptied": false + }, + "open-desert-rogue-zone-006-footlocker": { + "prop_id": "open-desert-rogue-zone-006-footlocker", + "area_id": "open-desert-overworld", + "position": { + "x": 357500, + "y": 343500 + }, + "cell": { + "x": 357, + "y": 343 + }, + "container": "footlocker:open-desert-rogue-zone-006-footlocker", + "interaction_radius_milli": 1750, + "emptied": false + }, + "open-desert-rogue-zone-007-footlocker": { + "prop_id": "open-desert-rogue-zone-007-footlocker", + "area_id": "open-desert-overworld", + "position": { + "x": 912500, + "y": 353500 + }, + "cell": { + "x": 912, + "y": 353 + }, + "container": "footlocker:open-desert-rogue-zone-007-footlocker", + "interaction_radius_milli": 1750, + "emptied": false + }, + "open-desert-rogue-zone-008-footlocker": { + "prop_id": "open-desert-rogue-zone-008-footlocker", + "area_id": "open-desert-overworld", + "position": { + "x": 539500, + "y": 359500 + }, + "cell": { + "x": 539, + "y": 359 + }, + "container": "footlocker:open-desert-rogue-zone-008-footlocker", + "interaction_radius_milli": 1750, + "emptied": false + }, + "open-desert-rogue-zone-009-footlocker": { + "prop_id": "open-desert-rogue-zone-009-footlocker", + "area_id": "open-desert-overworld", + "position": { + "x": 238500, + "y": 384500 + }, + "cell": { + "x": 238, + "y": 384 + }, + "container": "footlocker:open-desert-rogue-zone-009-footlocker", + "interaction_radius_milli": 1750, + "emptied": false + }, + "open-desert-rogue-zone-010-footlocker": { + "prop_id": "open-desert-rogue-zone-010-footlocker", + "area_id": "open-desert-overworld", + "position": { + "x": 220500, + "y": 462500 + }, + "cell": { + "x": 220, + "y": 462 + }, + "container": "footlocker:open-desert-rogue-zone-010-footlocker", + "interaction_radius_milli": 1750, + "emptied": false + }, + "open-desert-rogue-zone-011-footlocker": { + "prop_id": "open-desert-rogue-zone-011-footlocker", + "area_id": "open-desert-overworld", + "position": { + "x": 611500, + "y": 568500 + }, + "cell": { + "x": 611, + "y": 568 + }, + "container": "footlocker:open-desert-rogue-zone-011-footlocker", + "interaction_radius_milli": 1750, + "emptied": false + }, + "open-desert-rogue-zone-012-footlocker": { + "prop_id": "open-desert-rogue-zone-012-footlocker", + "area_id": "open-desert-overworld", + "position": { + "x": 514500, + "y": 659500 + }, + "cell": { + "x": 514, + "y": 659 + }, + "container": "footlocker:open-desert-rogue-zone-012-footlocker", + "interaction_radius_milli": 1750, + "emptied": false + }, + "open-desert-rogue-zone-013-footlocker": { + "prop_id": "open-desert-rogue-zone-013-footlocker", + "area_id": "open-desert-overworld", + "position": { + "x": 773500, + "y": 698500 + }, + "cell": { + "x": 773, + "y": 698 + }, + "container": "footlocker:open-desert-rogue-zone-013-footlocker", + "interaction_radius_milli": 1750, + "emptied": false + }, + "open-desert-rogue-zone-014-footlocker": { + "prop_id": "open-desert-rogue-zone-014-footlocker", + "area_id": "open-desert-overworld", + "position": { + "x": 387500, + "y": 707500 + }, + "cell": { + "x": 387, + "y": 707 + }, + "container": "footlocker:open-desert-rogue-zone-014-footlocker", + "interaction_radius_milli": 1750, + "emptied": false + }, + "open-desert-rogue-zone-015-footlocker": { + "prop_id": "open-desert-rogue-zone-015-footlocker", + "area_id": "open-desert-overworld", + "position": { + "x": 752500, + "y": 778500 + }, + "cell": { + "x": 752, + "y": 778 + }, + "container": "footlocker:open-desert-rogue-zone-015-footlocker", + "interaction_radius_milli": 1750, + "emptied": false + }, + "open-desert-rogue-zone-016-footlocker": { + "prop_id": "open-desert-rogue-zone-016-footlocker", + "area_id": "open-desert-overworld", + "position": { + "x": 186500, + "y": 827500 + }, + "cell": { + "x": 186, + "y": 827 + }, + "container": "footlocker:open-desert-rogue-zone-016-footlocker", + "interaction_radius_milli": 1750, + "emptied": false + }, + "open-desert-rogue-zone-017-footlocker": { + "prop_id": "open-desert-rogue-zone-017-footlocker", + "area_id": "open-desert-overworld", + "position": { + "x": 552500, + "y": 909500 + }, + "cell": { + "x": 552, + "y": 909 + }, + "container": "footlocker:open-desert-rogue-zone-017-footlocker", + "interaction_radius_milli": 1750, + "emptied": false + }, + "open-desert-rogue-zone-018-footlocker": { + "prop_id": "open-desert-rogue-zone-018-footlocker", + "area_id": "open-desert-overworld", + "position": { + "x": 156500, + "y": 908500 + }, + "cell": { + "x": 156, + "y": 908 + }, + "container": "footlocker:open-desert-rogue-zone-018-footlocker", + "interaction_radius_milli": 1750, + "emptied": false + }, + "open-desert-sparring-zone-footlocker": { + "prop_id": "open-desert-sparring-zone-footlocker", + "area_id": "open-desert-overworld", + "position": { + "x": 590500, + "y": 512500 + }, + "cell": { + "x": 590, + "y": 512 + }, + "container": "footlocker:open-desert-sparring-zone-footlocker", + "interaction_radius_milli": 1750, + "emptied": false + } + }, + "openDoorPropIds": [], + "seenCommands": [ + [ + 1, + 1, + 1620000 + ], + [ + 1, + 1, + 1620001 + ], + [ + 1, + 1, + 1620002 + ], + [ + 1, + 1, + 1620003 + ], + [ + 1, + 1, + 1620004 + ], + [ + 1, + 1, + 1620005 + ], + [ + 1, + 1, + 1620006 + ], + [ + 1, + 1, + 1620007 + ], + [ + 1, + 1, + 1620008 + ], + [ + 1, + 1, + 1620009 + ], + [ + 1, + 1, + 1620010 + ], + [ + 1, + 1, + 1620011 + ], + [ + 1, + 1, + 1620012 + ], + [ + 1, + 1, + 1620013 + ], + [ + 1, + 1, + 1620014 + ], + [ + 1, + 1, + 1620015 + ], + [ + 1, + 1, + 1620016 + ], + [ + 1, + 1, + 1620017 + ], + [ + 1, + 1, + 1620018 + ], + [ + 1, + 1, + 1620019 + ], + [ + 1, + 1, + 1620021 + ], + [ + 1, + 1, + 1620023 + ], + [ + 1, + 1, + 1620025 + ], + [ + 1, + 1, + 1620027 + ], + [ + 1, + 1, + 1620029 + ], + [ + 1, + 1, + 1620031 + ], + [ + 1, + 1, + 1620033 + ], + [ + 1, + 1, + 1620035 + ], + [ + 1, + 1, + 1620037 + ], + [ + 1, + 1, + 1620039 + ], + [ + 1, + 1, + 1620041 + ], + [ + 1, + 1, + 1620043 + ], + [ + 1, + 1, + 1620045 + ], + [ + 1, + 1, + 1620047 + ], + [ + 1, + 1, + 1620049 + ], + [ + 1, + 1, + 1620051 + ], + [ + 1, + 1, + 1620053 + ], + [ + 1, + 1, + 1620055 + ], + [ + 1, + 1, + 1620057 + ], + [ + 1, + 1, + 1620059 + ], + [ + 1, + 1, + 1620061 + ], + [ + 1, + 1, + 1620063 + ], + [ + 1, + 1, + 1620065 + ], + [ + 1, + 1, + 1620067 + ], + [ + 1, + 1, + 1620069 + ], + [ + 1, + 1, + 1620071 + ], + [ + 1, + 1, + 1620073 + ], + [ + 1, + 1, + 1620075 + ], + [ + 1, + 1, + 1620077 + ], + [ + 1, + 1, + 1620079 + ], + [ + 1, + 1, + 1620081 + ], + [ + 1, + 1, + 1620083 + ], + [ + 1, + 1, + 1620085 + ], + [ + 1, + 1, + 1620087 + ], + [ + 1, + 1, + 1620089 + ], + [ + 1, + 1, + 1620091 + ], + [ + 1, + 1, + 1620093 + ], + [ + 1, + 1, + 1620095 + ], + [ + 1, + 1, + 1620097 + ], + [ + 1, + 1, + 1620099 + ], + [ + 1, + 1, + 1620101 + ], + [ + 1, + 1, + 1620103 + ], + [ + 1, + 1, + 1620105 + ], + [ + 1, + 1, + 1620107 + ], + [ + 1, + 1, + 1620109 + ], + [ + 1, + 1, + 1620111 + ], + [ + 1, + 1, + 1620113 + ], + [ + 1, + 1, + 1620115 + ], + [ + 1, + 1, + 1620117 + ], + [ + 1, + 1, + 1620119 + ], + [ + 1, + 1, + 1620121 + ], + [ + 1, + 1, + 1620123 + ], + [ + 1, + 1, + 1620125 + ], + [ + 1, + 1, + 1620127 + ], + [ + 1, + 1, + 1620129 + ], + [ + 1, + 1, + 1620131 + ], + [ + 1, + 1, + 1620133 + ], + [ + 1, + 1, + 1620135 + ], + [ + 1, + 1, + 1620137 + ], + [ + 1, + 1, + 1620139 + ], + [ + 1, + 1, + 1620141 + ], + [ + 1, + 1, + 1620143 + ], + [ + 1, + 1, + 1620145 + ], + [ + 1, + 1, + 1620148 + ], + [ + 1, + 1, + 1620150 + ], + [ + 1, + 1, + 1633000 + ], + [ + 1, + 1, + 1633001 + ], + [ + 1, + 1, + 1633002 + ], + [ + 1, + 1, + 1633003 + ], + [ + 1, + 1, + 1633004 + ], + [ + 1, + 1, + 1633005 + ], + [ + 1, + 1, + 1633006 + ], + [ + 1, + 1, + 1633007 + ], + [ + 1, + 1, + 1633008 + ], + [ + 1, + 1, + 1633009 + ], + [ + 1, + 1, + 1633010 + ], + [ + 1, + 1, + 1633011 + ], + [ + 1, + 1, + 1633012 + ], + [ + 1, + 1, + 1633013 + ], + [ + 1, + 1, + 1633014 + ], + [ + 1, + 1, + 1633015 + ], + [ + 1, + 1, + 1633016 + ], + [ + 1, + 1, + 1633018 + ], + [ + 1, + 1, + 1633020 + ], + [ + 1, + 1, + 1633022 + ], + [ + 1, + 1, + 1633025 + ], + [ + 1, + 1, + 1633027 + ], + [ + 1, + 1, + 1633029 + ], + [ + 1, + 1, + 1633031 + ], + [ + 1, + 1, + 1633033 + ], + [ + 1, + 1, + 1633036 + ], + [ + 1, + 1, + 1633038 + ], + [ + 1, + 1, + 1633040 + ], + [ + 1, + 1, + 1633042 + ], + [ + 1, + 1, + 1633044 + ], + [ + 1, + 1, + 1633046 + ], + [ + 1, + 1, + 1633048 + ], + [ + 1, + 1, + 1633051 + ], + [ + 1, + 1, + 1633053 + ], + [ + 1, + 1, + 1633055 + ], + [ + 1, + 1, + 1633057 + ], + [ + 1, + 1, + 1633060 + ], + [ + 1, + 1, + 1633062 + ], + [ + 1, + 1, + 1633064 + ], + [ + 1, + 1, + 1633066 + ], + [ + 1, + 1, + 1633067 + ], + [ + 1, + 1, + 1633068 + ], + [ + 1, + 1, + 1633069 + ], + [ + 1, + 1, + 1633070 + ], + [ + 1, + 1, + 1633072 + ], + [ + 1, + 1, + 1633074 + ], + [ + 1, + 1, + 1633076 + ], + [ + 1, + 1, + 1633078 + ], + [ + 1, + 1, + 1633080 + ], + [ + 1, + 1, + 1633082 + ], + [ + 1, + 1, + 1633084 + ], + [ + 1, + 1, + 1633086 + ], + [ + 1, + 1, + 1633088 + ], + [ + 1, + 1, + 1633090 + ], + [ + 1, + 1, + 1633092 + ], + [ + 1, + 1, + 1633094 + ], + [ + 1, + 1, + 1633096 + ], + [ + 1, + 1, + 1633098 + ], + [ + 1, + 1, + 1633100 + ], + [ + 1, + 1, + 1633102 + ], + [ + 1, + 1, + 1633104 + ], + [ + 1, + 1, + 1633106 + ], + [ + 1, + 1, + 1633108 + ], + [ + 1, + 1, + 1633110 + ], + [ + 1, + 1, + 1633113 + ], + [ + 1, + 1, + 1633115 + ], + [ + 1, + 1, + 1633117 + ], + [ + 1, + 1, + 1633119 + ], + [ + 1, + 1, + 1633121 + ], + [ + 1, + 1, + 1633123 + ], + [ + 1, + 1, + 1633125 + ], + [ + 1, + 1, + 1633127 + ], + [ + 1, + 1, + 1633129 + ], + [ + 1, + 1, + 1633131 + ], + [ + 1, + 1, + 1633133 + ], + [ + 1, + 1, + 1633134 + ], + [ + 1, + 1, + 1633135 + ], + [ + 1, + 1, + 1633136 + ], + [ + 1, + 1, + 1633137 + ], + [ + 1, + 1, + 1633138 + ], + [ + 1, + 1, + 1633139 + ], + [ + 1, + 1, + 1633140 + ], + [ + 1, + 1, + 1633141 + ], + [ + 1, + 1, + 1633142 + ], + [ + 1, + 1, + 1633143 + ], + [ + 1, + 1, + 1633144 + ], + [ + 1, + 1, + 1633145 + ], + [ + 1, + 1, + 1633146 + ], + [ + 1, + 1, + 1633147 + ], + [ + 1, + 1, + 1633148 + ], + [ + 1, + 1, + 1633150 + ], + [ + 2, + 1, + 1589000 + ], + [ + 2, + 1, + 1589001 + ], + [ + 2, + 1, + 1589002 + ], + [ + 2, + 1, + 1589003 + ], + [ + 2, + 1, + 1589004 + ], + [ + 2, + 1, + 1589005 + ], + [ + 2, + 1, + 1589006 + ], + [ + 2, + 1, + 1589007 + ], + [ + 2, + 1, + 1589008 + ], + [ + 2, + 1, + 1589009 + ], + [ + 2, + 1, + 1589010 + ], + [ + 2, + 1, + 1589011 + ], + [ + 2, + 1, + 1589012 + ], + [ + 2, + 1, + 1589013 + ], + [ + 2, + 1, + 1589014 + ], + [ + 2, + 1, + 1589015 + ], + [ + 2, + 1, + 1589016 + ], + [ + 2, + 1, + 1589017 + ], + [ + 2, + 1, + 1589018 + ], + [ + 2, + 1, + 1589019 + ], + [ + 2, + 1, + 1589021 + ], + [ + 2, + 1, + 1589023 + ], + [ + 2, + 1, + 1589025 + ], + [ + 2, + 1, + 1589027 + ], + [ + 2, + 1, + 1589029 + ], + [ + 2, + 1, + 1589031 + ], + [ + 2, + 1, + 1589033 + ], + [ + 2, + 1, + 1589035 + ], + [ + 2, + 1, + 1589037 + ], + [ + 2, + 1, + 1589039 + ], + [ + 2, + 1, + 1589041 + ], + [ + 2, + 1, + 1589043 + ], + [ + 2, + 1, + 1589045 + ], + [ + 2, + 1, + 1589047 + ], + [ + 2, + 1, + 1589049 + ], + [ + 2, + 1, + 1589051 + ], + [ + 2, + 1, + 1589053 + ], + [ + 2, + 1, + 1589055 + ], + [ + 2, + 1, + 1589057 + ], + [ + 2, + 1, + 1589059 + ], + [ + 2, + 1, + 1589061 + ], + [ + 2, + 1, + 1589063 + ], + [ + 2, + 1, + 1589065 + ], + [ + 2, + 1, + 1589067 + ], + [ + 2, + 1, + 1589069 + ], + [ + 2, + 1, + 1589071 + ], + [ + 2, + 1, + 1589073 + ], + [ + 2, + 1, + 1589075 + ], + [ + 2, + 1, + 1589077 + ], + [ + 2, + 1, + 1589079 + ], + [ + 2, + 1, + 1589081 + ], + [ + 2, + 1, + 1589083 + ], + [ + 2, + 1, + 1589085 + ], + [ + 2, + 1, + 1589087 + ], + [ + 2, + 1, + 1589089 + ], + [ + 2, + 1, + 1589091 + ], + [ + 2, + 1, + 1589093 + ], + [ + 2, + 1, + 1589095 + ], + [ + 2, + 1, + 1589097 + ], + [ + 2, + 1, + 1589099 + ], + [ + 3, + 1, + 1598000 + ], + [ + 3, + 1, + 1598001 + ], + [ + 3, + 1, + 1598002 + ], + [ + 3, + 1, + 1598003 + ], + [ + 3, + 1, + 1598004 + ], + [ + 3, + 1, + 1598005 + ], + [ + 3, + 1, + 1598006 + ], + [ + 3, + 1, + 1598007 + ], + [ + 3, + 1, + 1598008 + ], + [ + 3, + 1, + 1598009 + ], + [ + 3, + 1, + 1598010 + ], + [ + 3, + 1, + 1598011 + ], + [ + 3, + 1, + 1598012 + ], + [ + 3, + 1, + 1598013 + ], + [ + 3, + 1, + 1598014 + ], + [ + 3, + 1, + 1598015 + ], + [ + 3, + 1, + 1598016 + ], + [ + 3, + 1, + 1598017 + ], + [ + 3, + 1, + 1598018 + ], + [ + 3, + 1, + 1598019 + ], + [ + 3, + 1, + 1598021 + ], + [ + 3, + 1, + 1598023 + ], + [ + 3, + 1, + 1598025 + ], + [ + 3, + 1, + 1598027 + ], + [ + 3, + 1, + 1598029 + ], + [ + 3, + 1, + 1598031 + ], + [ + 3, + 1, + 1598033 + ], + [ + 3, + 1, + 1598035 + ], + [ + 3, + 1, + 1598037 + ], + [ + 3, + 1, + 1598039 + ], + [ + 3, + 1, + 1598041 + ], + [ + 3, + 1, + 1598043 + ], + [ + 3, + 1, + 1598045 + ], + [ + 3, + 1, + 1598047 + ], + [ + 3, + 1, + 1598049 + ], + [ + 3, + 1, + 1598051 + ], + [ + 3, + 1, + 1598053 + ], + [ + 3, + 1, + 1598055 + ], + [ + 3, + 1, + 1598057 + ], + [ + 3, + 1, + 1598059 + ], + [ + 3, + 1, + 1598061 + ], + [ + 3, + 1, + 1598063 + ], + [ + 3, + 1, + 1598065 + ], + [ + 3, + 1, + 1598067 + ], + [ + 3, + 1, + 1598069 + ], + [ + 3, + 1, + 1598071 + ], + [ + 3, + 1, + 1598073 + ], + [ + 3, + 1, + 1598075 + ], + [ + 3, + 1, + 1598077 + ], + [ + 3, + 1, + 1598079 + ], + [ + 3, + 1, + 1598081 + ], + [ + 3, + 1, + 1598083 + ], + [ + 3, + 1, + 1598085 + ], + [ + 3, + 1, + 1598087 + ], + [ + 3, + 1, + 1598089 + ], + [ + 3, + 1, + 1598091 + ], + [ + 3, + 1, + 1598093 + ], + [ + 3, + 1, + 1598095 + ], + [ + 3, + 1, + 1598097 + ], + [ + 3, + 1, + 1598099 + ], + [ + 3, + 1, + 1598101 + ], + [ + 3, + 1, + 1598103 + ], + [ + 3, + 1, + 1598105 + ], + [ + 3, + 1, + 1598107 + ], + [ + 3, + 1, + 1598109 + ], + [ + 3, + 1, + 1598111 + ], + [ + 3, + 1, + 1598113 + ], + [ + 3, + 1, + 1598115 + ], + [ + 3, + 1, + 1598117 + ], + [ + 3, + 1, + 1598119 + ], + [ + 3, + 1, + 1598121 + ], + [ + 3, + 1, + 1598123 + ], + [ + 3, + 1, + 1598125 + ], + [ + 3, + 1, + 1598127 + ], + [ + 3, + 1, + 1598129 + ], + [ + 3, + 1, + 1598131 + ], + [ + 3, + 1, + 1598133 + ], + [ + 3, + 1, + 1598135 + ], + [ + 3, + 1, + 1598137 + ], + [ + 3, + 1, + 1598139 + ], + [ + 3, + 1, + 1598141 + ], + [ + 3, + 1, + 1598143 + ], + [ + 3, + 1, + 1598145 + ], + [ + 3, + 1, + 1598147 + ], + [ + 3, + 1, + 1598149 + ], + [ + 3, + 1, + 1598151 + ], + [ + 3, + 1, + 1598153 + ], + [ + 3, + 1, + 1598155 + ], + [ + 3, + 1, + 1598157 + ], + [ + 3, + 1, + 1598160 + ], + [ + 3, + 1, + 1598162 + ], + [ + 3, + 1, + 1598164 + ], + [ + 3, + 1, + 1598166 + ], + [ + 3, + 1, + 1598168 + ], + [ + 3, + 1, + 1598170 + ], + [ + 3, + 1, + 1598172 + ], + [ + 3, + 1, + 1598174 + ], + [ + 3, + 1, + 1598176 + ], + [ + 3, + 1, + 1598178 + ], + [ + 3, + 1, + 1598180 + ], + [ + 3, + 1, + 1598182 + ], + [ + 3, + 1, + 1598184 + ], + [ + 3, + 1, + 1598186 + ], + [ + 3, + 1, + 1598188 + ], + [ + 3, + 1, + 1598190 + ], + [ + 3, + 1, + 1598192 + ], + [ + 3, + 1, + 1598194 + ], + [ + 3, + 1, + 1598196 + ], + [ + 3, + 1, + 1598198 + ], + [ + 3, + 1, + 1598200 + ], + [ + 3, + 1, + 1598202 + ], + [ + 3, + 1, + 1598204 + ], + [ + 3, + 1, + 1598206 + ], + [ + 3, + 1, + 1598208 + ], + [ + 3, + 1, + 1598210 + ], + [ + 3, + 1, + 1598212 + ], + [ + 3, + 1, + 1598214 + ], + [ + 3, + 1, + 1598216 + ], + [ + 3, + 1, + 1598218 + ], + [ + 3, + 1, + 1598220 + ], + [ + 3, + 1, + 1598222 + ], + [ + 3, + 1, + 1598224 + ], + [ + 3, + 1, + 1598226 + ], + [ + 3, + 1, + 1598228 + ], + [ + 3, + 1, + 1598230 + ], + [ + 3, + 1, + 1598232 + ], + [ + 3, + 1, + 1598234 + ], + [ + 3, + 1, + 1598236 + ], + [ + 3, + 1, + 1598238 + ], + [ + 3, + 1, + 1598240 + ], + [ + 3, + 1, + 1598242 + ], + [ + 3, + 1, + 1598244 + ], + [ + 3, + 1, + 1598246 + ], + [ + 3, + 1, + 1598248 + ], + [ + 3, + 1, + 1598250 + ], + [ + 3, + 1, + 1598252 + ], + [ + 3, + 1, + 1598254 + ], + [ + 3, + 1, + 1598256 + ], + [ + 3, + 1, + 1598258 + ], + [ + 3, + 1, + 1598260 + ], + [ + 3, + 1, + 1598262 + ], + [ + 3, + 1, + 1598263 + ], + [ + 3, + 1, + 1598265 + ], + [ + 3, + 1, + 1598267 + ], + [ + 3, + 1, + 1598269 + ], + [ + 3, + 1, + 1598271 + ], + [ + 3, + 1, + 1598273 + ], + [ + 3, + 1, + 1598275 + ], + [ + 3, + 1, + 1598277 + ], + [ + 3, + 1, + 1598279 + ], + [ + 3, + 1, + 1598281 + ], + [ + 3, + 1, + 1598283 + ], + [ + 3, + 1, + 1598285 + ], + [ + 3, + 1, + 1598287 + ], + [ + 3, + 1, + 1598289 + ], + [ + 3, + 1, + 1598291 + ], + [ + 3, + 1, + 1598293 + ], + [ + 3, + 1, + 1598295 + ], + [ + 3, + 1, + 1598297 + ], + [ + 3, + 1, + 1598299 + ], + [ + 3, + 1, + 1598301 + ], + [ + 3, + 1, + 1598303 + ], + [ + 3, + 1, + 1598305 + ], + [ + 3, + 1, + 1598307 + ], + [ + 3, + 1, + 1598309 + ], + [ + 3, + 1, + 1598311 + ], + [ + 3, + 1, + 1598313 + ], + [ + 3, + 1, + 1598315 + ], + [ + 3, + 1, + 1598317 + ], + [ + 3, + 1, + 1598319 + ], + [ + 3, + 1, + 1598321 + ], + [ + 3, + 1, + 1598323 + ], + [ + 3, + 1, + 1598324 + ], + [ + 3, + 1, + 1598327 + ], + [ + 3, + 1, + 1598329 + ], + [ + 3, + 1, + 1598331 + ], + [ + 3, + 1, + 1598333 + ], + [ + 3, + 1, + 1598334 + ], + [ + 3, + 1, + 1598336 + ], + [ + 3, + 1, + 1598338 + ], + [ + 3, + 1, + 1598340 + ], + [ + 3, + 1, + 1598342 + ], + [ + 3, + 1, + 1598344 + ], + [ + 3, + 1, + 1598346 + ], + [ + 3, + 1, + 1598348 + ], + [ + 3, + 1, + 1598350 + ], + [ + 3, + 1, + 1598352 + ], + [ + 3, + 1, + 1598354 + ], + [ + 3, + 1, + 1598356 + ], + [ + 3, + 1, + 1598358 + ], + [ + 3, + 1, + 1598360 + ], + [ + 3, + 1, + 1598362 + ], + [ + 3, + 1, + 1598364 + ], + [ + 3, + 1, + 1598366 + ], + [ + 3, + 1, + 1598368 + ], + [ + 3, + 1, + 1598370 + ], + [ + 3, + 1, + 1598372 + ], + [ + 3, + 1, + 1598374 + ], + [ + 3, + 1, + 1598376 + ], + [ + 3, + 1, + 1598378 + ], + [ + 3, + 1, + 1598380 + ], + [ + 3, + 1, + 1598382 + ], + [ + 3, + 1, + 1598384 + ], + [ + 3, + 1, + 1598386 + ], + [ + 3, + 1, + 1598388 + ], + [ + 3, + 1, + 1598390 + ], + [ + 3, + 1, + 1598392 + ], + [ + 3, + 1, + 1598394 + ], + [ + 3, + 1, + 1598396 + ], + [ + 3, + 1, + 1598398 + ], + [ + 3, + 1, + 1598400 + ], + [ + 3, + 1, + 1598402 + ], + [ + 3, + 1, + 1598404 + ], + [ + 3, + 1, + 1598406 + ], + [ + 3, + 1, + 1598408 + ], + [ + 3, + 1, + 1598410 + ], + [ + 3, + 1, + 1598412 + ], + [ + 3, + 1, + 1598413 + ], + [ + 3, + 1, + 1598415 + ], + [ + 3, + 1, + 1598417 + ], + [ + 3, + 1, + 1598419 + ], + [ + 3, + 1, + 1598421 + ], + [ + 3, + 1, + 1598423 + ], + [ + 3, + 1, + 1598425 + ], + [ + 3, + 1, + 1598427 + ], + [ + 3, + 1, + 1598429 + ], + [ + 3, + 1, + 1598431 + ], + [ + 3, + 1, + 1598433 + ], + [ + 3, + 1, + 1598435 + ], + [ + 3, + 1, + 1598437 + ], + [ + 3, + 1, + 1598439 + ], + [ + 3, + 1, + 1598441 + ], + [ + 3, + 1, + 1598443 + ], + [ + 3, + 1, + 1598445 + ], + [ + 3, + 1, + 1598447 + ], + [ + 3, + 1, + 1598449 + ], + [ + 3, + 1, + 1640000 + ], + [ + 3, + 1, + 1640001 + ], + [ + 4, + 1, + 1615000 + ], + [ + 4, + 1, + 1615001 + ], + [ + 4, + 1, + 1615002 + ], + [ + 4, + 1, + 1615003 + ], + [ + 4, + 1, + 1615004 + ], + [ + 4, + 1, + 1615005 + ], + [ + 4, + 1, + 1615006 + ], + [ + 4, + 1, + 1615007 + ], + [ + 4, + 1, + 1615008 + ], + [ + 4, + 1, + 1615009 + ], + [ + 4, + 1, + 1615010 + ], + [ + 4, + 1, + 1615011 + ], + [ + 4, + 1, + 1615012 + ], + [ + 4, + 1, + 1615013 + ], + [ + 4, + 1, + 1615014 + ], + [ + 4, + 1, + 1615015 + ], + [ + 4, + 1, + 1615016 + ], + [ + 4, + 1, + 1615017 + ], + [ + 4, + 1, + 1615018 + ], + [ + 4, + 1, + 1615019 + ], + [ + 4, + 1, + 1615021 + ], + [ + 4, + 1, + 1615023 + ], + [ + 4, + 1, + 1615025 + ], + [ + 4, + 1, + 1615027 + ], + [ + 4, + 1, + 1615029 + ], + [ + 4, + 1, + 1615031 + ], + [ + 4, + 1, + 1615033 + ], + [ + 4, + 1, + 1615035 + ], + [ + 4, + 1, + 1615037 + ], + [ + 4, + 1, + 1615039 + ], + [ + 4, + 1, + 1615041 + ], + [ + 4, + 1, + 1615043 + ], + [ + 4, + 1, + 1615045 + ], + [ + 4, + 1, + 1615047 + ], + [ + 4, + 1, + 1615049 + ], + [ + 4, + 1, + 1615051 + ], + [ + 4, + 1, + 1615053 + ], + [ + 4, + 1, + 1615055 + ], + [ + 4, + 1, + 1615057 + ], + [ + 4, + 1, + 1615059 + ], + [ + 4, + 1, + 1615061 + ], + [ + 4, + 1, + 1615063 + ], + [ + 4, + 1, + 1615065 + ], + [ + 4, + 1, + 1615067 + ], + [ + 4, + 1, + 1615069 + ], + [ + 4, + 1, + 1615071 + ], + [ + 4, + 1, + 1615073 + ], + [ + 4, + 1, + 1615075 + ], + [ + 4, + 1, + 1615077 + ], + [ + 4, + 1, + 1615079 + ], + [ + 4, + 1, + 1615081 + ], + [ + 4, + 1, + 1615083 + ], + [ + 4, + 1, + 1615085 + ], + [ + 4, + 1, + 1615087 + ], + [ + 4, + 1, + 1615089 + ], + [ + 4, + 1, + 1615091 + ], + [ + 4, + 1, + 1615093 + ], + [ + 4, + 1, + 1615095 + ], + [ + 4, + 1, + 1615097 + ], + [ + 4, + 1, + 1615099 + ], + [ + 4, + 1, + 1615101 + ], + [ + 4, + 1, + 1615103 + ], + [ + 4, + 1, + 1615105 + ], + [ + 4, + 1, + 1615107 + ], + [ + 4, + 1, + 1615109 + ], + [ + 4, + 1, + 1615111 + ], + [ + 4, + 1, + 1615113 + ], + [ + 4, + 1, + 1615115 + ], + [ + 4, + 1, + 1615117 + ], + [ + 4, + 1, + 1615119 + ], + [ + 4, + 1, + 1615121 + ], + [ + 4, + 1, + 1615123 + ], + [ + 4, + 1, + 1615125 + ], + [ + 4, + 1, + 1615127 + ], + [ + 4, + 1, + 1615129 + ], + [ + 4, + 1, + 1615131 + ], + [ + 4, + 1, + 1615133 + ], + [ + 4, + 1, + 1615135 + ], + [ + 4, + 1, + 1615137 + ], + [ + 4, + 1, + 1615139 + ], + [ + 4, + 1, + 1615141 + ], + [ + 4, + 1, + 1615143 + ], + [ + 4, + 1, + 1615145 + ], + [ + 4, + 1, + 1615147 + ], + [ + 4, + 1, + 1615149 + ], + [ + 4, + 1, + 1615151 + ], + [ + 4, + 1, + 1615153 + ], + [ + 4, + 1, + 1615155 + ], + [ + 4, + 1, + 1615157 + ], + [ + 4, + 1, + 1615159 + ], + [ + 4, + 1, + 1615162 + ], + [ + 4, + 1, + 1615164 + ], + [ + 4, + 1, + 1615166 + ], + [ + 4, + 1, + 1615168 + ], + [ + 4, + 1, + 1615170 + ], + [ + 4, + 1, + 1615172 + ], + [ + 4, + 1, + 1615174 + ], + [ + 4, + 1, + 1615176 + ], + [ + 4, + 1, + 1615178 + ], + [ + 4, + 1, + 1615180 + ], + [ + 4, + 1, + 1615182 + ], + [ + 4, + 1, + 1615184 + ], + [ + 4, + 1, + 1615187 + ], + [ + 4, + 1, + 1615189 + ], + [ + 4, + 1, + 1615191 + ], + [ + 4, + 1, + 1615193 + ], + [ + 4, + 1, + 1615195 + ], + [ + 4, + 1, + 1615197 + ], + [ + 4, + 1, + 1615199 + ], + [ + 4, + 1, + 1615201 + ], + [ + 4, + 1, + 1615203 + ], + [ + 4, + 1, + 1615205 + ], + [ + 4, + 1, + 1615207 + ], + [ + 4, + 1, + 1615209 + ], + [ + 4, + 1, + 1615211 + ], + [ + 4, + 1, + 1615213 + ], + [ + 4, + 1, + 1615216 + ], + [ + 4, + 1, + 1615218 + ], + [ + 4, + 1, + 1615220 + ], + [ + 4, + 1, + 1615222 + ], + [ + 4, + 1, + 1615224 + ], + [ + 4, + 1, + 1615226 + ], + [ + 4, + 1, + 1615228 + ], + [ + 4, + 1, + 1615230 + ], + [ + 4, + 1, + 1615233 + ], + [ + 4, + 1, + 1615235 + ], + [ + 4, + 1, + 1615237 + ], + [ + 4, + 1, + 1615239 + ], + [ + 4, + 1, + 1615241 + ], + [ + 4, + 1, + 1615243 + ], + [ + 4, + 1, + 1615245 + ], + [ + 4, + 1, + 1615247 + ], + [ + 4, + 1, + 1615249 + ], + [ + 4, + 1, + 1615251 + ], + [ + 4, + 1, + 1615253 + ], + [ + 4, + 1, + 1615255 + ], + [ + 4, + 1, + 1615257 + ], + [ + 4, + 1, + 1615259 + ], + [ + 4, + 1, + 1615261 + ], + [ + 4, + 1, + 1615263 + ], + [ + 4, + 1, + 1615265 + ], + [ + 4, + 1, + 1615267 + ], + [ + 4, + 1, + 1615269 + ], + [ + 4, + 1, + 1615271 + ], + [ + 4, + 1, + 1615273 + ], + [ + 4, + 1, + 1615275 + ], + [ + 4, + 1, + 1615277 + ], + [ + 4, + 1, + 1615279 + ], + [ + 4, + 1, + 1615281 + ], + [ + 4, + 1, + 1615283 + ], + [ + 4, + 1, + 1615285 + ], + [ + 4, + 1, + 1615287 + ], + [ + 4, + 1, + 1615289 + ], + [ + 4, + 1, + 1615291 + ], + [ + 4, + 1, + 1615293 + ], + [ + 4, + 1, + 1615295 + ], + [ + 4, + 1, + 1615297 + ], + [ + 4, + 1, + 1615299 + ], + [ + 4, + 1, + 1615301 + ], + [ + 4, + 1, + 1615303 + ], + [ + 4, + 1, + 1615305 + ], + [ + 4, + 1, + 1615307 + ], + [ + 4, + 1, + 1615309 + ], + [ + 4, + 1, + 1615311 + ], + [ + 4, + 1, + 1615313 + ], + [ + 4, + 1, + 1615315 + ], + [ + 4, + 1, + 1615317 + ], + [ + 4, + 1, + 1615319 + ], + [ + 4, + 1, + 1615321 + ], + [ + 4, + 1, + 1615323 + ], + [ + 4, + 1, + 1615325 + ], + [ + 4, + 1, + 1615327 + ], + [ + 4, + 1, + 1615329 + ], + [ + 4, + 1, + 1615331 + ], + [ + 4, + 1, + 1615333 + ], + [ + 4, + 1, + 1615335 + ], + [ + 4, + 1, + 1615337 + ], + [ + 4, + 1, + 1615339 + ], + [ + 4, + 1, + 1615341 + ], + [ + 4, + 1, + 1615343 + ], + [ + 4, + 1, + 1615345 + ], + [ + 4, + 1, + 1615347 + ], + [ + 4, + 1, + 1615349 + ], + [ + 4, + 1, + 1615351 + ], + [ + 4, + 1, + 1615353 + ], + [ + 4, + 1, + 1615355 + ], + [ + 4, + 1, + 1615357 + ], + [ + 4, + 1, + 1615359 + ], + [ + 4, + 1, + 1615361 + ], + [ + 4, + 1, + 1615363 + ], + [ + 4, + 1, + 1615365 + ], + [ + 4, + 1, + 1615367 + ], + [ + 4, + 1, + 1615369 + ], + [ + 4, + 1, + 1615371 + ], + [ + 4, + 1, + 1615373 + ], + [ + 4, + 1, + 1615375 + ], + [ + 4, + 1, + 1615377 + ], + [ + 4, + 1, + 1615379 + ], + [ + 4, + 1, + 1615381 + ], + [ + 4, + 1, + 1615383 + ], + [ + 4, + 1, + 1615385 + ], + [ + 4, + 1, + 1615387 + ], + [ + 4, + 1, + 1615390 + ], + [ + 4, + 1, + 1615392 + ], + [ + 4, + 1, + 1615394 + ], + [ + 4, + 1, + 1615396 + ], + [ + 4, + 1, + 1615398 + ], + [ + 4, + 1, + 1615400 + ], + [ + 4, + 1, + 1615402 + ], + [ + 4, + 1, + 1615404 + ], + [ + 4, + 1, + 1615406 + ], + [ + 4, + 1, + 1615408 + ], + [ + 4, + 1, + 1615410 + ], + [ + 4, + 1, + 1615412 + ], + [ + 4, + 1, + 1615414 + ], + [ + 4, + 1, + 1615416 + ], + [ + 4, + 1, + 1615418 + ], + [ + 4, + 1, + 1615420 + ], + [ + 4, + 1, + 1615422 + ], + [ + 4, + 1, + 1615424 + ], + [ + 4, + 1, + 1615426 + ], + [ + 4, + 1, + 1615428 + ], + [ + 4, + 1, + 1615430 + ], + [ + 4, + 1, + 1615432 + ], + [ + 4, + 1, + 1615434 + ], + [ + 4, + 1, + 1615436 + ], + [ + 4, + 1, + 1615438 + ], + [ + 4, + 1, + 1615440 + ], + [ + 4, + 1, + 1615442 + ], + [ + 4, + 1, + 1615444 + ], + [ + 4, + 1, + 1615446 + ], + [ + 4, + 1, + 1615448 + ], + [ + 4, + 1, + 1615450 + ], + [ + 4, + 1, + 1615452 + ], + [ + 4, + 1, + 1615454 + ], + [ + 4, + 1, + 1615456 + ], + [ + 4, + 1, + 1615458 + ], + [ + 4, + 1, + 1615460 + ], + [ + 4, + 1, + 1615462 + ], + [ + 4, + 1, + 1615464 + ], + [ + 4, + 1, + 1615466 + ], + [ + 4, + 1, + 1615468 + ], + [ + 4, + 1, + 1615470 + ], + [ + 4, + 1, + 1615472 + ], + [ + 4, + 1, + 1615474 + ], + [ + 4, + 1, + 1615476 + ], + [ + 4, + 1, + 1615478 + ], + [ + 4, + 1, + 1615480 + ], + [ + 4, + 1, + 1615482 + ], + [ + 4, + 1, + 1615484 + ], + [ + 4, + 1, + 1615486 + ], + [ + 4, + 1, + 1615488 + ], + [ + 4, + 1, + 1615490 + ], + [ + 4, + 1, + 1615492 + ], + [ + 4, + 1, + 1615494 + ], + [ + 4, + 1, + 1615496 + ], + [ + 4, + 1, + 1615498 + ], + [ + 4, + 1, + 1615500 + ], + [ + 4, + 1, + 1615502 + ], + [ + 4, + 1, + 1615504 + ], + [ + 4, + 1, + 1615506 + ], + [ + 4, + 1, + 1615508 + ], + [ + 4, + 1, + 1615510 + ], + [ + 4, + 1, + 1615512 + ], + [ + 4, + 1, + 1615514 + ], + [ + 4, + 1, + 1615516 + ], + [ + 4, + 1, + 1615518 + ], + [ + 4, + 1, + 1615520 + ], + [ + 4, + 1, + 1615522 + ], + [ + 4, + 1, + 1615524 + ], + [ + 4, + 1, + 1615526 + ], + [ + 4, + 1, + 1615528 + ], + [ + 4, + 1, + 1615530 + ], + [ + 4, + 1, + 1615532 + ], + [ + 4, + 1, + 1615534 + ], + [ + 4, + 1, + 1615536 + ], + [ + 4, + 1, + 1615538 + ], + [ + 4, + 1, + 1615540 + ], + [ + 4, + 1, + 1615542 + ], + [ + 4, + 1, + 1615544 + ], + [ + 4, + 1, + 1615546 + ], + [ + 4, + 1, + 1615548 + ], + [ + 4, + 1, + 1615550 + ], + [ + 4, + 1, + 1615552 + ], + [ + 4, + 1, + 1615554 + ], + [ + 4, + 1, + 1615556 + ], + [ + 4, + 1, + 1615558 + ], + [ + 4, + 1, + 1615560 + ], + [ + 4, + 1, + 1615562 + ], + [ + 4, + 1, + 1615564 + ], + [ + 4, + 1, + 1615566 + ], + [ + 4, + 1, + 1615568 + ], + [ + 4, + 1, + 1615570 + ], + [ + 4, + 1, + 1615572 + ], + [ + 4, + 1, + 1615574 + ], + [ + 4, + 1, + 1615576 + ], + [ + 4, + 1, + 1615578 + ], + [ + 4, + 1, + 1615580 + ], + [ + 4, + 1, + 1615582 + ], + [ + 4, + 1, + 1615584 + ], + [ + 4, + 1, + 1615586 + ], + [ + 4, + 1, + 1615588 + ], + [ + 4, + 1, + 1615590 + ], + [ + 4, + 1, + 1615592 + ], + [ + 4, + 1, + 1615594 + ], + [ + 4, + 1, + 1615596 + ], + [ + 4, + 1, + 1615598 + ], + [ + 4, + 1, + 1615600 + ], + [ + 4, + 1, + 1624000 + ], + [ + 4, + 1, + 1624001 + ], + [ + 4, + 1, + 1624002 + ], + [ + 4, + 1, + 1624003 + ], + [ + 4, + 1, + 1624004 + ], + [ + 4, + 1, + 1624005 + ], + [ + 4, + 1, + 1624006 + ], + [ + 4, + 1, + 1624007 + ], + [ + 4, + 1, + 1624008 + ], + [ + 4, + 1, + 1624009 + ], + [ + 4, + 1, + 1624010 + ], + [ + 4, + 1, + 1624011 + ], + [ + 4, + 1, + 1624012 + ], + [ + 4, + 1, + 1624013 + ], + [ + 4, + 1, + 1624014 + ], + [ + 4, + 1, + 1624015 + ], + [ + 4, + 1, + 1624016 + ], + [ + 4, + 1, + 1624017 + ], + [ + 4, + 1, + 1624018 + ], + [ + 4, + 1, + 1624019 + ], + [ + 4, + 1, + 1624020 + ], + [ + 4, + 1, + 1624021 + ], + [ + 4, + 1, + 1624022 + ], + [ + 4, + 1, + 1624023 + ], + [ + 4, + 1, + 1624024 + ], + [ + 4, + 1, + 1624025 + ], + [ + 4, + 1, + 1624026 + ], + [ + 4, + 1, + 1624027 + ], + [ + 4, + 1, + 1624028 + ], + [ + 4, + 1, + 1624029 + ], + [ + 4, + 1, + 1624030 + ], + [ + 4, + 1, + 1624031 + ], + [ + 4, + 1, + 1624032 + ], + [ + 4, + 1, + 1624033 + ], + [ + 4, + 1, + 1624034 + ], + [ + 4, + 1, + 1624035 + ], + [ + 4, + 1, + 1624036 + ], + [ + 4, + 1, + 1624037 + ], + [ + 4, + 1, + 1624038 + ], + [ + 4, + 1, + 1624039 + ], + [ + 4, + 1, + 1624040 + ], + [ + 4, + 1, + 1624041 + ], + [ + 4, + 1, + 1624042 + ], + [ + 4, + 1, + 1624043 + ], + [ + 4, + 1, + 1624044 + ], + [ + 4, + 1, + 1624045 + ], + [ + 4, + 1, + 1624046 + ], + [ + 4, + 1, + 1624047 + ], + [ + 4, + 1, + 1624048 + ], + [ + 4, + 1, + 1624049 + ], + [ + 4, + 1, + 1624050 + ], + [ + 4, + 1, + 1624051 + ], + [ + 4, + 1, + 1624052 + ], + [ + 4, + 1, + 1624053 + ], + [ + 4, + 1, + 1624054 + ], + [ + 4, + 1, + 1624055 + ], + [ + 4, + 1, + 1624057 + ], + [ + 4, + 1, + 1624059 + ], + [ + 4, + 1, + 1624061 + ], + [ + 4, + 1, + 1624063 + ], + [ + 4, + 1, + 1624065 + ], + [ + 4, + 1, + 1624067 + ], + [ + 4, + 1, + 1624069 + ], + [ + 4, + 1, + 1624070 + ], + [ + 4, + 1, + 1624071 + ], + [ + 4, + 1, + 1624072 + ], + [ + 4, + 1, + 1624074 + ], + [ + 4, + 1, + 1624076 + ], + [ + 4, + 1, + 1624078 + ], + [ + 4, + 1, + 1624080 + ], + [ + 4, + 1, + 1624082 + ], + [ + 4, + 1, + 1624084 + ], + [ + 4, + 1, + 1624086 + ], + [ + 4, + 1, + 1624087 + ], + [ + 4, + 1, + 1624088 + ], + [ + 4, + 1, + 1624089 + ], + [ + 4, + 1, + 1624091 + ], + [ + 4, + 1, + 1624093 + ], + [ + 4, + 1, + 1624095 + ], + [ + 4, + 1, + 1624097 + ], + [ + 4, + 1, + 1624099 + ], + [ + 4, + 1, + 1624101 + ], + [ + 4, + 1, + 1624103 + ], + [ + 4, + 1, + 1624104 + ], + [ + 4, + 1, + 1624105 + ], + [ + 4, + 1, + 1624106 + ], + [ + 4, + 1, + 1624107 + ], + [ + 4, + 1, + 1624108 + ], + [ + 4, + 1, + 1624109 + ], + [ + 4, + 1, + 1624110 + ], + [ + 4, + 1, + 1624111 + ], + [ + 4, + 1, + 1624112 + ], + [ + 4, + 1, + 1624113 + ], + [ + 4, + 1, + 1624114 + ], + [ + 4, + 1, + 1624115 + ], + [ + 4, + 1, + 1624116 + ], + [ + 4, + 1, + 1624117 + ], + [ + 4, + 1, + 1624118 + ], + [ + 4, + 1, + 1624119 + ], + [ + 4, + 1, + 1624120 + ], + [ + 4, + 1, + 1624121 + ], + [ + 4, + 1, + 1624122 + ], + [ + 4, + 1, + 1624123 + ], + [ + 4, + 1, + 1624124 + ], + [ + 4, + 1, + 1624125 + ], + [ + 4, + 1, + 1624126 + ], + [ + 4, + 1, + 1624127 + ], + [ + 4, + 1, + 1624128 + ], + [ + 4, + 1, + 1624129 + ], + [ + 4, + 1, + 1624130 + ], + [ + 4, + 1, + 1624131 + ], + [ + 4, + 1, + 1624132 + ], + [ + 4, + 1, + 1624133 + ], + [ + 4, + 1, + 1624134 + ], + [ + 4, + 1, + 1624135 + ], + [ + 5, + 1, + 1592000 + ], + [ + 5, + 1, + 1592001 + ], + [ + 5, + 1, + 1592002 + ], + [ + 5, + 1, + 1592003 + ], + [ + 5, + 1, + 1592004 + ], + [ + 5, + 1, + 1592005 + ], + [ + 5, + 1, + 1592006 + ], + [ + 5, + 1, + 1592007 + ], + [ + 5, + 1, + 1592008 + ], + [ + 5, + 1, + 1592009 + ], + [ + 5, + 1, + 1592010 + ], + [ + 5, + 1, + 1592011 + ], + [ + 5, + 1, + 1592012 + ], + [ + 5, + 1, + 1592013 + ], + [ + 5, + 1, + 1592014 + ], + [ + 5, + 1, + 1592015 + ], + [ + 5, + 1, + 1592016 + ], + [ + 5, + 1, + 1592017 + ], + [ + 5, + 1, + 1592018 + ], + [ + 5, + 1, + 1592020 + ], + [ + 5, + 1, + 1592022 + ], + [ + 5, + 1, + 1592024 + ], + [ + 5, + 1, + 1592025 + ], + [ + 5, + 1, + 1592028 + ], + [ + 5, + 1, + 1592030 + ], + [ + 5, + 1, + 1592032 + ], + [ + 5, + 1, + 1592034 + ], + [ + 5, + 1, + 1592036 + ], + [ + 5, + 1, + 1592038 + ], + [ + 5, + 1, + 1592040 + ], + [ + 5, + 1, + 1592042 + ], + [ + 5, + 1, + 1592044 + ], + [ + 5, + 1, + 1592046 + ], + [ + 5, + 1, + 1592048 + ], + [ + 5, + 1, + 1592050 + ], + [ + 5, + 1, + 1592052 + ], + [ + 5, + 1, + 1592054 + ], + [ + 5, + 1, + 1592056 + ], + [ + 5, + 1, + 1592058 + ], + [ + 5, + 1, + 1592060 + ], + [ + 5, + 1, + 1592062 + ], + [ + 5, + 1, + 1592065 + ], + [ + 5, + 1, + 1592067 + ], + [ + 5, + 1, + 1592069 + ], + [ + 5, + 1, + 1592071 + ], + [ + 5, + 1, + 1592073 + ], + [ + 5, + 1, + 1592075 + ], + [ + 5, + 1, + 1592077 + ], + [ + 5, + 1, + 1592079 + ], + [ + 5, + 1, + 1592081 + ], + [ + 5, + 1, + 1592083 + ], + [ + 5, + 1, + 1592085 + ], + [ + 5, + 1, + 1592088 + ], + [ + 5, + 1, + 1592090 + ], + [ + 5, + 1, + 1592092 + ], + [ + 5, + 1, + 1592094 + ], + [ + 5, + 1, + 1592096 + ], + [ + 5, + 1, + 1592098 + ], + [ + 5, + 1, + 1592100 + ], + [ + 5, + 1, + 1592103 + ], + [ + 5, + 1, + 1592105 + ], + [ + 5, + 1, + 1592107 + ], + [ + 5, + 1, + 1592109 + ], + [ + 5, + 1, + 1592111 + ], + [ + 5, + 1, + 1592113 + ], + [ + 5, + 1, + 1592115 + ], + [ + 5, + 1, + 1592117 + ], + [ + 5, + 1, + 1592119 + ], + [ + 5, + 1, + 1592121 + ], + [ + 5, + 1, + 1592123 + ], + [ + 5, + 1, + 1592125 + ], + [ + 5, + 1, + 1592127 + ], + [ + 5, + 1, + 1592129 + ], + [ + 5, + 1, + 1592131 + ], + [ + 5, + 1, + 1592133 + ], + [ + 5, + 1, + 1592135 + ], + [ + 5, + 1, + 1592137 + ], + [ + 5, + 1, + 1592139 + ], + [ + 5, + 1, + 1592141 + ], + [ + 5, + 1, + 1592143 + ], + [ + 5, + 1, + 1592145 + ], + [ + 5, + 1, + 1592147 + ], + [ + 5, + 1, + 1592149 + ], + [ + 5, + 1, + 1592151 + ], + [ + 5, + 1, + 1592153 + ], + [ + 5, + 1, + 1592155 + ], + [ + 5, + 1, + 1592157 + ], + [ + 5, + 1, + 1592159 + ], + [ + 5, + 1, + 1592161 + ], + [ + 5, + 1, + 1592163 + ], + [ + 5, + 1, + 1592165 + ], + [ + 5, + 1, + 1592167 + ], + [ + 5, + 1, + 1592169 + ], + [ + 5, + 1, + 1592171 + ], + [ + 5, + 1, + 1592173 + ], + [ + 5, + 1, + 1592175 + ], + [ + 5, + 1, + 1592177 + ], + [ + 5, + 1, + 1592179 + ], + [ + 5, + 1, + 1592181 + ], + [ + 5, + 1, + 1592183 + ], + [ + 5, + 1, + 1592185 + ], + [ + 5, + 1, + 1592187 + ], + [ + 5, + 1, + 1592189 + ], + [ + 5, + 1, + 1592191 + ], + [ + 5, + 1, + 1592193 + ], + [ + 5, + 1, + 1592195 + ], + [ + 5, + 1, + 1592197 + ], + [ + 5, + 1, + 1592199 + ], + [ + 5, + 1, + 1592201 + ], + [ + 5, + 1, + 1592203 + ], + [ + 5, + 1, + 1592205 + ], + [ + 5, + 1, + 1592208 + ], + [ + 5, + 1, + 1592210 + ], + [ + 5, + 1, + 1592212 + ], + [ + 5, + 1, + 1592214 + ], + [ + 5, + 1, + 1592216 + ], + [ + 5, + 1, + 1592218 + ], + [ + 5, + 1, + 1592221 + ], + [ + 5, + 1, + 1592223 + ], + [ + 5, + 1, + 1592225 + ], + [ + 5, + 1, + 1592227 + ], + [ + 5, + 1, + 1592229 + ], + [ + 5, + 1, + 1592231 + ], + [ + 5, + 1, + 1592233 + ], + [ + 5, + 1, + 1592235 + ], + [ + 5, + 1, + 1592237 + ], + [ + 5, + 1, + 1592239 + ], + [ + 5, + 1, + 1592241 + ], + [ + 5, + 1, + 1592243 + ], + [ + 5, + 1, + 1592245 + ], + [ + 5, + 1, + 1592247 + ], + [ + 5, + 1, + 1592249 + ], + [ + 5, + 1, + 1592251 + ], + [ + 5, + 1, + 1592253 + ], + [ + 5, + 1, + 1592255 + ], + [ + 5, + 1, + 1592257 + ], + [ + 5, + 1, + 1592259 + ], + [ + 5, + 1, + 1592261 + ], + [ + 5, + 1, + 1592263 + ], + [ + 5, + 1, + 1592265 + ], + [ + 5, + 1, + 1592267 + ], + [ + 5, + 1, + 1592269 + ], + [ + 5, + 1, + 1592271 + ], + [ + 5, + 1, + 1592273 + ], + [ + 5, + 1, + 1592275 + ], + [ + 5, + 1, + 1592277 + ], + [ + 5, + 1, + 1592279 + ], + [ + 5, + 1, + 1592281 + ], + [ + 5, + 1, + 1592283 + ], + [ + 5, + 1, + 1592285 + ], + [ + 5, + 1, + 1592287 + ], + [ + 5, + 1, + 1592289 + ], + [ + 5, + 1, + 1592291 + ], + [ + 5, + 1, + 1592293 + ], + [ + 5, + 1, + 1592295 + ], + [ + 5, + 1, + 1592297 + ], + [ + 5, + 1, + 1592299 + ], + [ + 5, + 1, + 1592301 + ], + [ + 5, + 1, + 1592303 + ], + [ + 5, + 1, + 1592305 + ], + [ + 5, + 1, + 1592307 + ], + [ + 5, + 1, + 1592309 + ], + [ + 5, + 1, + 1592311 + ], + [ + 5, + 1, + 1592313 + ], + [ + 5, + 1, + 1592315 + ], + [ + 5, + 1, + 1592317 + ], + [ + 5, + 1, + 1592319 + ], + [ + 5, + 1, + 1592321 + ], + [ + 5, + 1, + 1592323 + ], + [ + 5, + 1, + 1592325 + ], + [ + 5, + 1, + 1592327 + ], + [ + 5, + 1, + 1592329 + ], + [ + 5, + 1, + 1592331 + ], + [ + 5, + 1, + 1592333 + ], + [ + 5, + 1, + 1592335 + ], + [ + 5, + 1, + 1592337 + ], + [ + 5, + 1, + 1592339 + ], + [ + 5, + 1, + 1592341 + ], + [ + 5, + 1, + 1592343 + ], + [ + 5, + 1, + 1592345 + ], + [ + 5, + 1, + 1592347 + ], + [ + 5, + 1, + 1592349 + ], + [ + 5, + 1, + 1592351 + ], + [ + 5, + 1, + 1592353 + ], + [ + 5, + 1, + 1592355 + ], + [ + 5, + 1, + 1592357 + ], + [ + 5, + 1, + 1592359 + ], + [ + 5, + 1, + 1592361 + ], + [ + 5, + 1, + 1592363 + ], + [ + 5, + 1, + 1592365 + ], + [ + 5, + 1, + 1592367 + ], + [ + 5, + 1, + 1592369 + ], + [ + 5, + 1, + 1592371 + ], + [ + 5, + 1, + 1592373 + ], + [ + 5, + 1, + 1592375 + ], + [ + 5, + 1, + 1592377 + ], + [ + 5, + 1, + 1592379 + ], + [ + 5, + 1, + 1592381 + ], + [ + 5, + 1, + 1592383 + ], + [ + 5, + 1, + 1592385 + ], + [ + 5, + 1, + 1592387 + ], + [ + 5, + 1, + 1592389 + ], + [ + 5, + 1, + 1592391 + ], + [ + 5, + 1, + 1592393 + ], + [ + 5, + 1, + 1592395 + ], + [ + 5, + 1, + 1592397 + ], + [ + 5, + 1, + 1592399 + ], + [ + 5, + 1, + 1592401 + ], + [ + 5, + 1, + 1592403 + ], + [ + 5, + 1, + 1592405 + ], + [ + 5, + 1, + 1592407 + ], + [ + 5, + 1, + 1592409 + ], + [ + 5, + 1, + 1592411 + ], + [ + 5, + 1, + 1592413 + ], + [ + 5, + 1, + 1592415 + ], + [ + 5, + 1, + 1592417 + ], + [ + 5, + 1, + 1592419 + ], + [ + 5, + 1, + 1592421 + ], + [ + 5, + 1, + 1592423 + ], + [ + 5, + 1, + 1592425 + ], + [ + 5, + 1, + 1592427 + ], + [ + 5, + 1, + 1592429 + ], + [ + 5, + 1, + 1592431 + ], + [ + 5, + 1, + 1592433 + ], + [ + 5, + 1, + 1592435 + ], + [ + 5, + 1, + 1592437 + ], + [ + 5, + 1, + 1592439 + ], + [ + 5, + 1, + 1592441 + ], + [ + 5, + 1, + 1592443 + ], + [ + 5, + 1, + 1592445 + ], + [ + 5, + 1, + 1592447 + ], + [ + 5, + 1, + 1592449 + ], + [ + 5, + 1, + 1592451 + ], + [ + 5, + 1, + 1592453 + ], + [ + 5, + 1, + 1592455 + ], + [ + 5, + 1, + 1592457 + ], + [ + 5, + 1, + 1592459 + ], + [ + 5, + 1, + 1592461 + ], + [ + 5, + 1, + 1592463 + ], + [ + 5, + 1, + 1592465 + ], + [ + 5, + 1, + 1592467 + ], + [ + 5, + 1, + 1592469 + ], + [ + 5, + 1, + 1592471 + ], + [ + 5, + 1, + 1592473 + ], + [ + 5, + 1, + 1592475 + ], + [ + 5, + 1, + 1592477 + ], + [ + 5, + 1, + 1592479 + ], + [ + 5, + 1, + 1592481 + ], + [ + 5, + 1, + 1592483 + ], + [ + 5, + 1, + 1592485 + ], + [ + 5, + 1, + 1592487 + ], + [ + 5, + 1, + 1592489 + ], + [ + 5, + 1, + 1592491 + ], + [ + 5, + 1, + 1592493 + ], + [ + 5, + 1, + 1592495 + ], + [ + 5, + 1, + 1592497 + ], + [ + 5, + 1, + 1592499 + ], + [ + 5, + 1, + 1592501 + ], + [ + 5, + 1, + 1592503 + ], + [ + 5, + 1, + 1592505 + ], + [ + 5, + 1, + 1592507 + ], + [ + 5, + 1, + 1592509 + ], + [ + 5, + 1, + 1592511 + ], + [ + 5, + 1, + 1592513 + ], + [ + 5, + 1, + 1592515 + ], + [ + 5, + 1, + 1592517 + ], + [ + 5, + 1, + 1592519 + ], + [ + 5, + 1, + 1592521 + ], + [ + 5, + 1, + 1592523 + ], + [ + 5, + 1, + 1592525 + ], + [ + 5, + 1, + 1592527 + ], + [ + 5, + 1, + 1592529 + ], + [ + 5, + 1, + 1592531 + ], + [ + 5, + 1, + 1592533 + ], + [ + 5, + 1, + 1592535 + ], + [ + 5, + 1, + 1592537 + ], + [ + 5, + 1, + 1592539 + ], + [ + 5, + 1, + 1592541 + ], + [ + 5, + 1, + 1592543 + ], + [ + 5, + 1, + 1592545 + ], + [ + 5, + 1, + 1592547 + ], + [ + 5, + 1, + 1592549 + ], + [ + 5, + 1, + 1592551 + ], + [ + 5, + 1, + 1592553 + ], + [ + 5, + 1, + 1592555 + ], + [ + 5, + 1, + 1592557 + ], + [ + 5, + 1, + 1592559 + ], + [ + 5, + 1, + 1592561 + ], + [ + 5, + 1, + 1592563 + ], + [ + 5, + 1, + 1592565 + ], + [ + 5, + 1, + 1592567 + ], + [ + 5, + 1, + 1592569 + ], + [ + 5, + 1, + 1592571 + ], + [ + 5, + 1, + 1592573 + ], + [ + 5, + 1, + 1592575 + ], + [ + 5, + 1, + 1592577 + ], + [ + 5, + 1, + 1592579 + ], + [ + 5, + 1, + 1592581 + ], + [ + 5, + 1, + 1592583 + ], + [ + 5, + 1, + 1592585 + ], + [ + 5, + 1, + 1592587 + ], + [ + 5, + 1, + 1592589 + ], + [ + 5, + 1, + 1592591 + ], + [ + 5, + 1, + 1592593 + ], + [ + 5, + 1, + 1592595 + ], + [ + 5, + 1, + 1592597 + ], + [ + 5, + 1, + 1592599 + ], + [ + 5, + 1, + 1592601 + ], + [ + 5, + 1, + 1592603 + ], + [ + 5, + 1, + 1592605 + ], + [ + 5, + 1, + 1592607 + ], + [ + 5, + 1, + 1592609 + ], + [ + 5, + 1, + 1592612 + ], + [ + 5, + 1, + 1592614 + ], + [ + 5, + 1, + 1592616 + ], + [ + 5, + 1, + 1592618 + ], + [ + 5, + 1, + 1592620 + ], + [ + 5, + 1, + 1592622 + ], + [ + 5, + 1, + 1592624 + ], + [ + 5, + 1, + 1592626 + ], + [ + 5, + 1, + 1592628 + ], + [ + 5, + 1, + 1592630 + ], + [ + 5, + 1, + 1592632 + ], + [ + 5, + 1, + 1592634 + ], + [ + 5, + 1, + 1592636 + ], + [ + 5, + 1, + 1592638 + ], + [ + 5, + 1, + 1592640 + ], + [ + 5, + 1, + 1592642 + ], + [ + 5, + 1, + 1592644 + ], + [ + 5, + 1, + 1592646 + ], + [ + 5, + 1, + 1592648 + ], + [ + 5, + 1, + 1592650 + ], + [ + 5, + 1, + 1592652 + ], + [ + 5, + 1, + 1592654 + ], + [ + 5, + 1, + 1592655 + ], + [ + 5, + 1, + 1592658 + ], + [ + 5, + 1, + 1592659 + ], + [ + 5, + 1, + 1592662 + ], + [ + 5, + 1, + 1592664 + ], + [ + 5, + 1, + 1592666 + ], + [ + 5, + 1, + 1592668 + ], + [ + 5, + 1, + 1592670 + ], + [ + 5, + 1, + 1592671 + ], + [ + 5, + 1, + 1592673 + ], + [ + 5, + 1, + 1592676 + ], + [ + 5, + 1, + 1592677 + ], + [ + 5, + 1, + 1592680 + ], + [ + 5, + 1, + 1592682 + ], + [ + 5, + 1, + 1592684 + ], + [ + 5, + 1, + 1592686 + ], + [ + 5, + 1, + 1592688 + ], + [ + 5, + 1, + 1592690 + ], + [ + 5, + 1, + 1592692 + ], + [ + 5, + 1, + 1592694 + ], + [ + 5, + 1, + 1592696 + ], + [ + 5, + 1, + 1592698 + ], + [ + 5, + 1, + 1592700 + ], + [ + 5, + 1, + 1592703 + ], + [ + 5, + 1, + 1592705 + ], + [ + 5, + 1, + 1592707 + ], + [ + 5, + 1, + 1592709 + ], + [ + 5, + 1, + 1592711 + ], + [ + 5, + 1, + 1592713 + ], + [ + 5, + 1, + 1592715 + ], + [ + 5, + 1, + 1592717 + ], + [ + 5, + 1, + 1592719 + ], + [ + 5, + 1, + 1592720 + ], + [ + 5, + 1, + 1592722 + ], + [ + 5, + 1, + 1592724 + ], + [ + 5, + 1, + 1592726 + ], + [ + 5, + 1, + 1592728 + ], + [ + 5, + 1, + 1592730 + ], + [ + 5, + 1, + 1592732 + ], + [ + 5, + 1, + 1592734 + ], + [ + 5, + 1, + 1592736 + ], + [ + 5, + 1, + 1592738 + ], + [ + 5, + 1, + 1592740 + ], + [ + 5, + 1, + 1592742 + ], + [ + 5, + 1, + 1592744 + ], + [ + 5, + 1, + 1592746 + ], + [ + 5, + 1, + 1592748 + ], + [ + 5, + 1, + 1592750 + ], + [ + 6, + 1, + 1599000 + ], + [ + 6, + 1, + 1599001 + ], + [ + 6, + 1, + 1599002 + ], + [ + 6, + 1, + 1599003 + ], + [ + 6, + 1, + 1599004 + ], + [ + 6, + 1, + 1599005 + ], + [ + 6, + 1, + 1599006 + ], + [ + 6, + 1, + 1599007 + ], + [ + 6, + 1, + 1599008 + ], + [ + 6, + 1, + 1599009 + ], + [ + 6, + 1, + 1599010 + ], + [ + 6, + 1, + 1599011 + ], + [ + 6, + 1, + 1599012 + ], + [ + 6, + 1, + 1599013 + ], + [ + 6, + 1, + 1599014 + ], + [ + 6, + 1, + 1599015 + ], + [ + 6, + 1, + 1599016 + ], + [ + 6, + 1, + 1599017 + ], + [ + 6, + 1, + 1599018 + ], + [ + 6, + 1, + 1599019 + ], + [ + 6, + 1, + 1599020 + ], + [ + 6, + 1, + 1599021 + ], + [ + 6, + 1, + 1599022 + ], + [ + 6, + 1, + 1599023 + ], + [ + 6, + 1, + 1599024 + ], + [ + 6, + 1, + 1599025 + ], + [ + 6, + 1, + 1599026 + ], + [ + 6, + 1, + 1599027 + ], + [ + 6, + 1, + 1599028 + ], + [ + 6, + 1, + 1599029 + ], + [ + 6, + 1, + 1599030 + ], + [ + 6, + 1, + 1599031 + ], + [ + 6, + 1, + 1599032 + ], + [ + 6, + 1, + 1599033 + ], + [ + 6, + 1, + 1599034 + ], + [ + 6, + 1, + 1599035 + ], + [ + 6, + 1, + 1599036 + ], + [ + 6, + 1, + 1599037 + ], + [ + 6, + 1, + 1599038 + ], + [ + 6, + 1, + 1599039 + ], + [ + 6, + 1, + 1599040 + ], + [ + 6, + 1, + 1599041 + ], + [ + 6, + 1, + 1599042 + ], + [ + 6, + 1, + 1599043 + ], + [ + 6, + 1, + 1599044 + ], + [ + 6, + 1, + 1599045 + ], + [ + 6, + 1, + 1599046 + ], + [ + 6, + 1, + 1599047 + ], + [ + 6, + 1, + 1599048 + ], + [ + 6, + 1, + 1599049 + ], + [ + 6, + 1, + 1599050 + ], + [ + 6, + 1, + 1599051 + ], + [ + 6, + 1, + 1599052 + ], + [ + 6, + 1, + 1599053 + ], + [ + 6, + 1, + 1599054 + ], + [ + 6, + 1, + 1599056 + ], + [ + 6, + 1, + 1599058 + ], + [ + 6, + 1, + 1599060 + ], + [ + 6, + 1, + 1599062 + ], + [ + 6, + 1, + 1599064 + ], + [ + 6, + 1, + 1599066 + ], + [ + 6, + 1, + 1599068 + ], + [ + 6, + 1, + 1599070 + ], + [ + 6, + 1, + 1599072 + ], + [ + 6, + 1, + 1599074 + ], + [ + 6, + 1, + 1599076 + ], + [ + 6, + 1, + 1599078 + ], + [ + 6, + 1, + 1599080 + ], + [ + 6, + 1, + 1599082 + ], + [ + 6, + 1, + 1599084 + ], + [ + 6, + 1, + 1599086 + ], + [ + 6, + 1, + 1599088 + ], + [ + 6, + 1, + 1599090 + ], + [ + 6, + 1, + 1599092 + ], + [ + 6, + 1, + 1599094 + ], + [ + 6, + 1, + 1599096 + ], + [ + 6, + 1, + 1599098 + ], + [ + 6, + 1, + 1599100 + ], + [ + 6, + 1, + 1599102 + ], + [ + 6, + 1, + 1599104 + ], + [ + 6, + 1, + 1599106 + ], + [ + 6, + 1, + 1599108 + ], + [ + 6, + 1, + 1599110 + ], + [ + 6, + 1, + 1599112 + ], + [ + 6, + 1, + 1599114 + ], + [ + 6, + 1, + 1599116 + ], + [ + 6, + 1, + 1599118 + ], + [ + 6, + 1, + 1599120 + ], + [ + 6, + 1, + 1599122 + ], + [ + 6, + 1, + 1599124 + ], + [ + 6, + 1, + 1599126 + ], + [ + 6, + 1, + 1599128 + ], + [ + 6, + 1, + 1599130 + ], + [ + 6, + 1, + 1599132 + ], + [ + 6, + 1, + 1599134 + ], + [ + 6, + 1, + 1599136 + ], + [ + 6, + 1, + 1599138 + ], + [ + 6, + 1, + 1599140 + ], + [ + 6, + 1, + 1599142 + ], + [ + 6, + 1, + 1599144 + ], + [ + 6, + 1, + 1599146 + ], + [ + 6, + 1, + 1599148 + ], + [ + 6, + 1, + 1599152 + ], + [ + 6, + 1, + 1599155 + ], + [ + 6, + 1, + 1599157 + ], + [ + 6, + 1, + 1599159 + ], + [ + 6, + 1, + 1599161 + ], + [ + 6, + 1, + 1599163 + ], + [ + 6, + 1, + 1599165 + ], + [ + 6, + 1, + 1599167 + ], + [ + 6, + 1, + 1599169 + ], + [ + 6, + 1, + 1599171 + ], + [ + 6, + 1, + 1599173 + ], + [ + 6, + 1, + 1599175 + ], + [ + 6, + 1, + 1599177 + ], + [ + 6, + 1, + 1599179 + ], + [ + 6, + 1, + 1599181 + ], + [ + 6, + 1, + 1599183 + ], + [ + 6, + 1, + 1599185 + ], + [ + 6, + 1, + 1599187 + ], + [ + 6, + 1, + 1599189 + ], + [ + 6, + 1, + 1599191 + ], + [ + 6, + 1, + 1599193 + ], + [ + 6, + 1, + 1599195 + ], + [ + 6, + 1, + 1599197 + ], + [ + 6, + 1, + 1599199 + ], + [ + 6, + 1, + 1599201 + ] + ], + "inventory": [ + { + "stackId": 1, + "container": "player:field-pack", + "item": "Stimpak A", + "itemId": 1001, + "variantId": 0, + "quantity": 16, + "reserved": 0, + "available": 16 + }, + { + "stackId": 2, + "container": "player:field-pack", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 16, + "reserved": 0, + "available": 16 + }, + { + "stackId": 3, + "container": "player:field-pack", + "item": "Iron Slug", + "itemId": 1101, + "variantId": 0, + "quantity": 240, + "reserved": 0, + "available": 240 + }, + { + "stackId": 4, + "container": "player:field-pack", + "item": "Slugthrower", + "itemId": 3101, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "stackId": 5, + "container": "player:field-pack", + "item": "Vibrosword", + "itemId": 3103, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "stackId": 6, + "container": "player:field-pack", + "item": "Plasma Sword", + "itemId": 3104, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "stackId": 7, + "container": "player:field-pack", + "item": "Scrapline Machete", + "itemId": 3105, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "stackId": 8, + "container": "player:field-pack", + "item": "Field Saber", + "itemId": 3106, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "stackId": 9, + "container": "player:field-pack", + "item": "Quarry Chopper", + "itemId": 3107, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "stackId": 10, + "container": "player:field-pack", + "item": "STEN Mk II", + "itemId": 3111, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "stackId": 11, + "container": "player:field-pack", + "item": "Kiln Energy Cell Carbine", + "itemId": 3112, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "stackId": 12, + "container": "player:field-pack", + "item": "Lightning Carbine", + "itemId": 3121, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "stackId": 13, + "container": "player:field-pack", + "item": "Badge Bolt Pistol", + "itemId": 3122, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "stackId": 14, + "container": "player:field-pack", + "item": "Slagrail Vanguard", + "itemId": 3123, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "stackId": 15, + "container": "player:field-pack", + "item": "Coilgate Scatter", + "itemId": 3124, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "stackId": 16, + "container": "player:field-pack", + "item": "Kiln Long Pattern", + "itemId": 3125, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "stackId": 17, + "container": "player:field-pack", + "item": "Bastion LMG", + "itemId": 3126, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "stackId": 18, + "container": "player:field-pack", + "item": "Flare Net Launcher", + "itemId": 3127, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "stackId": 19, + "container": "player:field-pack", + "item": "Personal Shield Generator", + "itemId": 1004, + "variantId": 0, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "stackId": 1, + "container": "footlocker:open-desert-rogue-zone-001-footlocker", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 3, + "reserved": 0, + "available": 3 + }, + { + "stackId": 1, + "container": "footlocker:open-desert-rogue-zone-002-footlocker", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 3, + "reserved": 0, + "available": 3 + }, + { + "stackId": 1, + "container": "footlocker:open-desert-rogue-zone-003-footlocker", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 3, + "reserved": 0, + "available": 3 + }, + { + "stackId": 1, + "container": "footlocker:open-desert-rogue-zone-004-footlocker", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 3, + "reserved": 0, + "available": 3 + }, + { + "stackId": 1, + "container": "footlocker:open-desert-rogue-zone-005-footlocker", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 3, + "reserved": 0, + "available": 3 + }, + { + "stackId": 1, + "container": "footlocker:open-desert-rogue-zone-006-footlocker", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 3, + "reserved": 0, + "available": 3 + }, + { + "stackId": 1, + "container": "footlocker:open-desert-rogue-zone-007-footlocker", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 3, + "reserved": 0, + "available": 3 + }, + { + "stackId": 1, + "container": "footlocker:open-desert-rogue-zone-008-footlocker", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 3, + "reserved": 0, + "available": 3 + }, + { + "stackId": 1, + "container": "footlocker:open-desert-rogue-zone-009-footlocker", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 3, + "reserved": 0, + "available": 3 + }, + { + "stackId": 1, + "container": "footlocker:open-desert-rogue-zone-010-footlocker", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 3, + "reserved": 0, + "available": 3 + }, + { + "stackId": 1, + "container": "footlocker:open-desert-rogue-zone-011-footlocker", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 3, + "reserved": 0, + "available": 3 + }, + { + "stackId": 1, + "container": "footlocker:open-desert-rogue-zone-012-footlocker", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 3, + "reserved": 0, + "available": 3 + }, + { + "stackId": 1, + "container": "footlocker:open-desert-rogue-zone-013-footlocker", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 3, + "reserved": 0, + "available": 3 + }, + { + "stackId": 1, + "container": "footlocker:open-desert-rogue-zone-014-footlocker", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 3, + "reserved": 0, + "available": 3 + }, + { + "stackId": 1, + "container": "footlocker:open-desert-rogue-zone-015-footlocker", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 3, + "reserved": 0, + "available": 3 + }, + { + "stackId": 1, + "container": "footlocker:open-desert-rogue-zone-016-footlocker", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 3, + "reserved": 0, + "available": 3 + }, + { + "stackId": 1, + "container": "footlocker:open-desert-rogue-zone-017-footlocker", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 3, + "reserved": 0, + "available": 3 + }, + { + "stackId": 1, + "container": "footlocker:open-desert-rogue-zone-018-footlocker", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 3, + "reserved": 0, + "available": 3 + }, + { + "stackId": 1, + "container": "footlocker:open-desert-sparring-zone-footlocker", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 3, + "reserved": 0, + "available": 3 + }, + { + "stackId": 1, + "container": "camp-trainer:field-pack", + "item": "Field Multitool", + "itemId": 3001, + "variantId": 500, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "stackId": 2, + "container": "camp-trainer:field-pack", + "item": "Mineral Survey Tool", + "itemId": 3008, + "variantId": 500, + "quantity": 1, + "reserved": 0, + "available": 1 + }, + { + "stackId": 1, + "container": "dev-1:field-pack", + "item": "Stimpak A", + "itemId": 1001, + "variantId": 0, + "quantity": 16, + "reserved": 0, + "available": 16 + }, + { + "stackId": 2, + "container": "dev-1:field-pack", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 16, + "reserved": 0, + "available": 16 + }, + { + "stackId": 3, + "container": "dev-1:field-pack", + "item": "Iron Slug", + "itemId": 1101, + "variantId": 0, + "quantity": 240, + "reserved": 0, + "available": 240 + }, + { + "stackId": 1, + "container": "dev-4:field-pack", + "item": "Stimpak A", + "itemId": 1001, + "variantId": 0, + "quantity": 16, + "reserved": 0, + "available": 16 + }, + { + "stackId": 2, + "container": "dev-4:field-pack", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 16, + "reserved": 0, + "available": 16 + }, + { + "stackId": 3, + "container": "dev-4:field-pack", + "item": "Iron Slug", + "itemId": 1101, + "variantId": 0, + "quantity": 240, + "reserved": 0, + "available": 240 + }, + { + "stackId": 1, + "container": "dev-5:field-pack", + "item": "Stimpak A", + "itemId": 1001, + "variantId": 0, + "quantity": 16, + "reserved": 0, + "available": 16 + }, + { + "stackId": 2, + "container": "dev-5:field-pack", + "item": "Field Bandage", + "itemId": 1002, + "variantId": 0, + "quantity": 16, + "reserved": 0, + "available": 16 + }, + { + "stackId": 3, + "container": "dev-5:field-pack", + "item": "Iron Slug", + "itemId": 1101, + "variantId": 0, + "quantity": 240, + "reserved": 0, + "available": 240 + } + ], + "bankAccounts": { + "dev-1": { + "bank_credits": 0, + "skill_backup": { + "learned": [ + "marksman" + ], + "xp": { + "marksman": 0 + }, + "track_xp": {}, + "skill_boxes": [ + "marksman-novice", + "marksman-rifle-i" + ], + "active_title_id": "marksman-novice", + "skill_point_cap": 250, + "saved_tick": 1039 + } + }, + "dev-4": { + "bank_credits": 0, + "skill_backup": { + "learned": [ + "marksman" + ], + "xp": { + "marksman": 0 + }, + "track_xp": {}, + "skill_boxes": [ + "marksman-novice", + "marksman-rifle-i" + ], + "active_title_id": "marksman-novice", + "skill_point_cap": 250, + "saved_tick": 4782 + } + }, + "dev-5": { + "bank_credits": 0, + "skill_backup": { + "learned": [ + "marksman" + ], + "xp": { + "marksman": 0 + }, + "track_xp": {}, + "skill_boxes": [ + "marksman-novice", + "marksman-rifle-i" + ], + "active_title_id": "marksman-novice", + "skill_point_cap": 250, + "saved_tick": 7111 + } + }, + "player": { + "bank_credits": 0, + "skill_backup": { + "learned": [ + "marksman" + ], + "xp": {}, + "track_xp": {}, + "skill_boxes": [ + "marksman-novice", + "marksman-rifle-i" + ], + "active_title_id": "marksman-novice", + "skill_point_cap": 250, + "saved_tick": 0 + } + } + }, + "playerCorpses": {}, + "nextPlayerCorpseId": 1, + "inventoryStackCounters": { + "camp-trainer:field-pack": 3, + "dev-1:field-pack": 4, + "dev-4:field-pack": 4, + "dev-5:field-pack": 4, + "footlocker:open-desert-rogue-zone-001-footlocker": 2, + "footlocker:open-desert-rogue-zone-002-footlocker": 2, + "footlocker:open-desert-rogue-zone-003-footlocker": 2, + "footlocker:open-desert-rogue-zone-004-footlocker": 2, + "footlocker:open-desert-rogue-zone-005-footlocker": 2, + "footlocker:open-desert-rogue-zone-006-footlocker": 2, + "footlocker:open-desert-rogue-zone-007-footlocker": 2, + "footlocker:open-desert-rogue-zone-008-footlocker": 2, + "footlocker:open-desert-rogue-zone-009-footlocker": 2, + "footlocker:open-desert-rogue-zone-010-footlocker": 2, + "footlocker:open-desert-rogue-zone-011-footlocker": 2, + "footlocker:open-desert-rogue-zone-012-footlocker": 2, + "footlocker:open-desert-rogue-zone-013-footlocker": 2, + "footlocker:open-desert-rogue-zone-014-footlocker": 2, + "footlocker:open-desert-rogue-zone-015-footlocker": 2, + "footlocker:open-desert-rogue-zone-016-footlocker": 2, + "footlocker:open-desert-rogue-zone-017-footlocker": 2, + "footlocker:open-desert-rogue-zone-018-footlocker": 2, + "footlocker:open-desert-sparring-zone-footlocker": 2, + "player:field-pack": 20 + }, + "reservations": [], + "npcJobs": [], + "timelineEvents": [], + "placedExtractors": {}, + "nextExtractorId": 1, + "placedCamps": {}, + "nextCampId": 1, + "cropGenomes": { + "genomes": {}, + "nextHandle": 1 + }, + "parcels": {}, + "nextParcelId": 1, + "nextBuildComponentId": 1, + "plotTending": {}, + "draftedSchematics": {}, + "nextDraftedSchematicId": 1, + "nextCombatEventId": 1, + "combatEventCount": 0, + "hits": 0, + "deaths": 12, + "tradeProposals": {}, + "nextTradeProposalId": 1, + "groups": {}, + "groupInvites": {}, + "nextGroupId": 1, + "duels": {}, + "duelChallenges": {}, + "nextDuelId": 1, + "guilds": {}, + "guildInvites": {}, + "nextGuildId": 1, + "nextGuildInviteId": 1 + } + } + } +} diff --git a/server/.local-state-archive-20260803/open-desert-persistent.checkpoint.json.craft-roll-key b/server/.local-state-archive-20260803/open-desert-persistent.checkpoint.json.craft-roll-key new file mode 100644 index 00000000..a963071f --- /dev/null +++ b/server/.local-state-archive-20260803/open-desert-persistent.checkpoint.json.craft-roll-key @@ -0,0 +1 @@ +1ed807365b9993c48a74c63fceb813dd51bcea880b6975185d1299978ba3fc03 \ No newline at end of file diff --git a/server/.local-state-archive-20260803/open-desert-persistent.journal.jsonl b/server/.local-state-archive-20260803/open-desert-persistent.journal.jsonl new file mode 100644 index 00000000..2d31d3c8 --- /dev/null +++ b/server/.local-state-archive-20260803/open-desert-persistent.journal.jsonl @@ -0,0 +1,2024 @@ +{"type":"checkpoint","at":"2026-08-04T00:44:05.351Z","tick":0,"reason":"close","actorCount":2,"stateHash":"b9f9424b36bc9670519d849ab5e93234027308c9b2d5c946d1eb41c5129c84d7"} +{"type":"session.connect","at":"2026-08-04T00:46:14.189Z","tick":0,"sessionId":"g_1","actorId":"dev-1","playerId":"dev-1","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T00:46:14.200Z","tick":0,"reason":"interval","actorCount":2,"stateHash":"04fd3bb9b121193d87c0a9152387bcfd2de23c6a524449297f5151740856b80b"} +{"type":"checkpoint","at":"2026-08-04T00:46:19.213Z","tick":150,"reason":"interval","actorCount":2,"stateHash":"7c96e51a83436324c8da2978ec167b30870198920856b760ad49c488627e19fa"} +{"type":"session.disconnect","at":"2026-08-04T00:46:22.218Z","tick":241,"sessionId":"g_1","actorId":"dev-1","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T00:46:24.232Z","tick":241,"reason":"interval","actorCount":2,"stateHash":"2476da7b57d3698aa73d28d422dc08dd46975435833cfd7f55d831decba65828"} +{"type":"session.connect","at":"2026-08-04T00:58:04.100Z","tick":241,"sessionId":"g_2","actorId":"dev-1","playerId":"dev-1","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T00:58:04.117Z","tick":241,"reason":"interval","actorCount":2,"stateHash":"b9f34377082d7bf6fd17086222aace0d611f0f1d9e42ec4fe3efaba8f8ebceac"} +{"type":"checkpoint","at":"2026-08-04T00:58:09.138Z","tick":392,"reason":"interval","actorCount":2,"stateHash":"2fdc4e47bdffdeafab9962e779e5a73fddade5c7d37cafc869b10f69cf31c75e"} +{"type":"session.disconnect","at":"2026-08-04T00:58:12.407Z","tick":490,"sessionId":"g_2","actorId":"dev-1","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T00:58:14.142Z","tick":490,"reason":"interval","actorCount":2,"stateHash":"33995a7137214a20650aafa210e62ad73183857678500da9f9f366a63750bedd"} +{"type":"session.connect","at":"2026-08-04T01:07:39.473Z","tick":490,"sessionId":"g_1","actorId":"dev-1","playerId":"dev-1","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T01:07:39.491Z","tick":490,"reason":"interval","actorCount":2,"stateHash":"339481e576efc88a948b8ce31bf15ac7a17eb50321387e0192afd029843f8359"} +{"type":"command.receipt","at":"2026-08-04T01:07:39.522Z","tick":491,"actorId":"dev-1","commandId":1620000,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620000,"issued_at_tick":490,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"1236dd944002f89090a532a64e177553186843ac386329d458e5514bd177dfa4"}} +{"type":"command.receipt","at":"2026-08-04T01:07:42.856Z","tick":592,"actorId":"dev-1","commandId":1620001,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620001,"issued_at_tick":576,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"c32fb7a3de9616a6b352fa7a5df85b972c7366e6a797b813af2a0e77315ef781"}} +{"type":"command.receipt","at":"2026-08-04T01:07:42.964Z","tick":595,"actorId":"dev-1","commandId":1620002,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620002,"issued_at_tick":595,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"7de0a2952e786ccfe7674a0405b3337a2a80cfa0347e193746c96c6bb6e3df34"}} +{"type":"command.receipt","at":"2026-08-04T01:07:43.063Z","tick":598,"actorId":"dev-1","commandId":1620003,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620003,"issued_at_tick":598,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"08a880148c1ded200ef9149792acddb3f2ba3a2b6ed754ebb30030608d3afb25"}} +{"type":"command.receipt","at":"2026-08-04T01:07:43.164Z","tick":601,"actorId":"dev-1","commandId":1620004,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620004,"issued_at_tick":601,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"ba716d1eceefef7557bdcde257e60da2297b182f00cf1150550874b987dcb068"}} +{"type":"command.receipt","at":"2026-08-04T01:07:43.262Z","tick":604,"actorId":"dev-1","commandId":1620005,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620005,"issued_at_tick":604,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"e900713dc7e0c0fbed6f7a1db53e85b28b22b8bbe4e915b10726e11a9fd009db"}} +{"type":"command.receipt","at":"2026-08-04T01:07:43.366Z","tick":607,"actorId":"dev-1","commandId":1620006,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620006,"issued_at_tick":607,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"ee413f2afff3d9233d9444528de1c03f93b3c365e06ddb4e09b017b347a70806"}} +{"type":"command.receipt","at":"2026-08-04T01:07:43.492Z","tick":611,"actorId":"dev-1","commandId":1620007,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620007,"issued_at_tick":611,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"3510a831bbebfdcf0c46dd7e963c29c3b5473d555dc5822b37a6da419c134593"}} +{"type":"command.receipt","at":"2026-08-04T01:07:43.589Z","tick":614,"actorId":"dev-1","commandId":1620008,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620008,"issued_at_tick":614,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"a1e5d6ef216e644bfc8b623a6f33bc140184e737305827e203cee8312f7e5e40"}} +{"type":"command.receipt","at":"2026-08-04T01:07:43.688Z","tick":617,"actorId":"dev-1","commandId":1620009,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620009,"issued_at_tick":617,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"12ca54cd8cc43ec35e0364c21146c4167cff905e6128e34ad3a86b82bebf8e46"}} +{"type":"command.receipt","at":"2026-08-04T01:07:43.787Z","tick":620,"actorId":"dev-1","commandId":1620010,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620010,"issued_at_tick":620,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"b00d59e0fe34da6486089f10e16214c8611c8f5921dbbab8dfc9043b215fbf8d"}} +{"type":"command.receipt","at":"2026-08-04T01:07:43.889Z","tick":623,"actorId":"dev-1","commandId":1620011,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620011,"issued_at_tick":623,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"a985a159c7491e3f952768918355244269613229cd68effaa7217af7a261715d"}} +{"type":"command.receipt","at":"2026-08-04T01:07:43.987Z","tick":626,"actorId":"dev-1","commandId":1620012,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620012,"issued_at_tick":626,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"3e127a9cc7878cedebf1ba9bed625a14ca1e01602d5ecc8d00539bcdc15c503a"}} +{"type":"command.receipt","at":"2026-08-04T01:07:44.090Z","tick":629,"actorId":"dev-1","commandId":1620013,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620013,"issued_at_tick":629,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"02631b76cc838b0842c7f29f2b018020026594401d6c42aace7b82ee8ea8ae1a"}} +{"type":"command.receipt","at":"2026-08-04T01:07:44.181Z","tick":632,"actorId":"dev-1","commandId":1620014,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620014,"issued_at_tick":632,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"dd5e7bc990865defb8d16903cee2e07e0901917600b4d168b060f78f5467dbbe"}} +{"type":"command.receipt","at":"2026-08-04T01:07:44.282Z","tick":635,"actorId":"dev-1","commandId":1620015,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620015,"issued_at_tick":635,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"11eea0db91036643725783a917e4581e8d9ca6685adb48c17535031b96eb7a82"}} +{"type":"command.receipt","at":"2026-08-04T01:07:44.381Z","tick":638,"actorId":"dev-1","commandId":1620016,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620016,"issued_at_tick":638,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"9d58be8618ca19abc9ac7ff268ccaf60df6af381cc00c185760fb68f9db0aae9"}} +{"type":"command.receipt","at":"2026-08-04T01:07:44.482Z","tick":641,"actorId":"dev-1","commandId":1620017,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620017,"issued_at_tick":640,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"7d29e7248dac1ace1a5cc6956caa3614f47c801a9cdf64b1e686642ea6780595"}} +{"type":"checkpoint","at":"2026-08-04T01:07:44.494Z","tick":641,"reason":"interval","actorCount":2,"stateHash":"7d29e7248dac1ace1a5cc6956caa3614f47c801a9cdf64b1e686642ea6780595"} +{"type":"command.receipt","at":"2026-08-04T01:07:44.582Z","tick":644,"actorId":"dev-1","commandId":1620018,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620018,"issued_at_tick":643,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"833e7e5eae919e4c24ce1b4e1051fa2b965716eb02fa484251d3a10b716ef11e"}} +{"type":"command.receipt","at":"2026-08-04T01:07:44.681Z","tick":647,"actorId":"dev-1","commandId":1620019,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620019,"issued_at_tick":647,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"2b599d54338eca93bf2c9528585355868498f854670776ded3b76b4dfdedfea3"}} +{"type":"command.receipt","at":"2026-08-04T01:07:44.883Z","tick":653,"actorId":"dev-1","commandId":1620021,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620021,"issued_at_tick":652,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"f7896ce7fdabff8d55e2316a4e57b8875910e4962138a3a4f7ab05c5b3c3ddcc"}} +{"type":"command.receipt","at":"2026-08-04T01:07:45.082Z","tick":659,"actorId":"dev-1","commandId":1620023,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620023,"issued_at_tick":658,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"b938b7eec426e21d3fae7a0dc2c58b449861bf1908c2e55a33e467c3e7f673f6"}} +{"type":"command.receipt","at":"2026-08-04T01:07:45.282Z","tick":665,"actorId":"dev-1","commandId":1620025,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620025,"issued_at_tick":665,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"e15c1cfc09a1249105c6a203cfaa3bbaaa5e5d634ca09ceb91c8b8f213770531"}} +{"type":"command.receipt","at":"2026-08-04T01:07:45.481Z","tick":671,"actorId":"dev-1","commandId":1620027,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620027,"issued_at_tick":671,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"1152aa589e2dd6d1c4ad31689a1bb0ace016194e6815067d0eb9d81a963f4bba"}} +{"type":"command.receipt","at":"2026-08-04T01:07:45.681Z","tick":677,"actorId":"dev-1","commandId":1620029,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620029,"issued_at_tick":677,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"a9466b26bf98ff27263d4bcfbb49d39f5dab785a2b7a7d6f0c4b75d75c8d4690"}} +{"type":"command.receipt","at":"2026-08-04T01:07:45.881Z","tick":683,"actorId":"dev-1","commandId":1620031,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620031,"issued_at_tick":683,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"e5294658302f462be931c4bf431f023b219979d0b1c57104c787c0596e0e930f"}} +{"type":"command.receipt","at":"2026-08-04T01:07:46.081Z","tick":689,"actorId":"dev-1","commandId":1620033,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620033,"issued_at_tick":689,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"09695e962e284b1dd32dc35a3df6d70f0d1bfd0d46ab1ec285a4e80a31bd64c4"}} +{"type":"command.receipt","at":"2026-08-04T01:07:46.279Z","tick":695,"actorId":"dev-1","commandId":1620035,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620035,"issued_at_tick":695,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"9675be68cf8e68acd080c4f3ea427505a51c38289df5bfc2d60c27f8af075a60"}} +{"type":"command.receipt","at":"2026-08-04T01:07:46.481Z","tick":701,"actorId":"dev-1","commandId":1620037,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620037,"issued_at_tick":701,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"5bb11b2cba2f29ae7b52e9cff00901a13c21380b0f455605478a47afda4075fa"}} +{"type":"command.receipt","at":"2026-08-04T01:07:46.680Z","tick":707,"actorId":"dev-1","commandId":1620039,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620039,"issued_at_tick":707,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"9de116280ee93b4826f9489429c4eefef0e9de6d5b0faffae7e395103d223973"}} +{"type":"command.receipt","at":"2026-08-04T01:07:46.881Z","tick":713,"actorId":"dev-1","commandId":1620041,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620041,"issued_at_tick":713,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"7ed248356c95f844a9e681120e31d291ecc5785c5152c358fd3d0f0fe9b62f55"}} +{"type":"command.receipt","at":"2026-08-04T01:07:47.079Z","tick":719,"actorId":"dev-1","commandId":1620043,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620043,"issued_at_tick":719,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"679bb3b9f9cadfd37180d7f3a7c8635cd9fe7d48c78dd4e095960138e2f0c621"}} +{"type":"command.receipt","at":"2026-08-04T01:07:47.280Z","tick":725,"actorId":"dev-1","commandId":1620045,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620045,"issued_at_tick":725,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"a07c762d9e66a09dfc4d035b8737befca4d318812914eb4bad27b77fd2e3daa2"}} +{"type":"command.receipt","at":"2026-08-04T01:07:47.480Z","tick":731,"actorId":"dev-1","commandId":1620047,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620047,"issued_at_tick":731,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"319dd111ae246fb6747ea2c1876d30e1fe436d009c423beacbabdd4beb9fe685"}} +{"type":"command.receipt","at":"2026-08-04T01:07:47.680Z","tick":737,"actorId":"dev-1","commandId":1620049,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620049,"issued_at_tick":737,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"d08f9bdb60b6bf505505974477617b19324e463b7c1aba5d37b2aad98ce41fed"}} +{"type":"command.receipt","at":"2026-08-04T01:07:47.880Z","tick":743,"actorId":"dev-1","commandId":1620051,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620051,"issued_at_tick":743,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"a7b6a66d0dddb418a5ae7d0f24dd56d73cab1dd8579ed1e0925b7c41c599f518"}} +{"type":"command.receipt","at":"2026-08-04T01:07:48.080Z","tick":749,"actorId":"dev-1","commandId":1620053,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620053,"issued_at_tick":749,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"a6ccb398e506894d22736bbff2ad88e53d41049ec05dc109b7d10e0ad5bc57e0"}} +{"type":"command.receipt","at":"2026-08-04T01:07:48.280Z","tick":755,"actorId":"dev-1","commandId":1620055,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620055,"issued_at_tick":755,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"b134fba7560adf1bb9a2637c37e963f134da4abec0625ecc5e8350f7552abd21"}} +{"type":"command.receipt","at":"2026-08-04T01:07:48.479Z","tick":761,"actorId":"dev-1","commandId":1620057,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620057,"issued_at_tick":761,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"b4ceb0ddb85ddebd02d15685d2f6e09a8dad5c0819f92e65affff7a3d5c3bc3d"}} +{"type":"command.receipt","at":"2026-08-04T01:07:48.680Z","tick":767,"actorId":"dev-1","commandId":1620059,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620059,"issued_at_tick":767,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"0842dc238017deae9af0f929614615d17ffd71263ea1c7005e12443ef434d3be"}} +{"type":"command.receipt","at":"2026-08-04T01:07:48.881Z","tick":773,"actorId":"dev-1","commandId":1620061,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620061,"issued_at_tick":773,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"b235de492fd30651f047a9753aadc98e24081fc2fd506945c1dc54697d12a747"}} +{"type":"command.receipt","at":"2026-08-04T01:07:49.079Z","tick":779,"actorId":"dev-1","commandId":1620063,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620063,"issued_at_tick":779,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"ea58292af89e46b91237b557beae8b72afcbcbb21338d764fbecede1ce3f1a19"}} +{"type":"command.receipt","at":"2026-08-04T01:07:49.280Z","tick":785,"actorId":"dev-1","commandId":1620065,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620065,"issued_at_tick":785,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"fbf8c59bc88b284a217ad73addfe3a1cc0194a6d865ce9ae4aabd2de3ce69ac8"}} +{"type":"command.receipt","at":"2026-08-04T01:07:49.479Z","tick":791,"actorId":"dev-1","commandId":1620067,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620067,"issued_at_tick":790,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"c8a554a7b5f81a33cb62704e53bc807d7e448eb165f4d1cc9b1d3733b729cb47"}} +{"type":"checkpoint","at":"2026-08-04T01:07:49.504Z","tick":791,"reason":"interval","actorCount":2,"stateHash":"c8a554a7b5f81a33cb62704e53bc807d7e448eb165f4d1cc9b1d3733b729cb47"} +{"type":"command.receipt","at":"2026-08-04T01:07:49.681Z","tick":797,"actorId":"dev-1","commandId":1620069,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620069,"issued_at_tick":797,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"48fef67e1497cc0dff68fa722de78bacde3321c76cbf54ce28533c38366b367c"}} +{"type":"command.receipt","at":"2026-08-04T01:07:49.872Z","tick":802,"actorId":"dev-1","commandId":1620071,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620071,"issued_at_tick":802,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"af0d44c10e8626be7fa608ea71137ddaa7d51af57870b0c61786ef78dc0d6fda"}} +{"type":"command.receipt","at":"2026-08-04T01:07:50.071Z","tick":808,"actorId":"dev-1","commandId":1620073,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620073,"issued_at_tick":808,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"4eccdf63054c1a55cb4af84e30d04a583d0a15c9f0ca03522a660d524f3a9e0b"}} +{"type":"command.receipt","at":"2026-08-04T01:07:50.273Z","tick":814,"actorId":"dev-1","commandId":1620075,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620075,"issued_at_tick":814,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"9a947d60357ffd3a8a60f35352ba1569418f42beb9f05d1a6beebb3ed204ac02"}} +{"type":"command.receipt","at":"2026-08-04T01:07:50.474Z","tick":820,"actorId":"dev-1","commandId":1620077,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620077,"issued_at_tick":820,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"8e375bb2e341e7b1c055ad4c365e56712452fa3f3e134ca6c61f3cd6c5bcdf82"}} +{"type":"command.receipt","at":"2026-08-04T01:07:50.673Z","tick":826,"actorId":"dev-1","commandId":1620079,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620079,"issued_at_tick":826,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"3dc1b8e7252c19fb9a90c492bf962ed653bf9f49ac9d5154440e6b90119e7a2f"}} +{"type":"command.receipt","at":"2026-08-04T01:07:50.872Z","tick":832,"actorId":"dev-1","commandId":1620081,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620081,"issued_at_tick":832,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"41448e554ed8f4ac576f36d48e9cc99bb8860887826dedfac3cb7d54bf3f6cf4"}} +{"type":"command.receipt","at":"2026-08-04T01:07:51.072Z","tick":838,"actorId":"dev-1","commandId":1620083,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620083,"issued_at_tick":838,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"b5867852a802851bbb55179c08f8e51069ae4bef494b0196da9039c72c4c136f"}} +{"type":"command.receipt","at":"2026-08-04T01:07:51.284Z","tick":845,"actorId":"dev-1","commandId":1620085,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620085,"issued_at_tick":845,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"9fe5ba872da001ab1f6e3a09dec489fb3eb444ebd9cb11e3ba4786e1f9c15153"}} +{"type":"command.receipt","at":"2026-08-04T01:07:51.481Z","tick":851,"actorId":"dev-1","commandId":1620087,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620087,"issued_at_tick":851,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"62f0115692e4695ddbe8a3e7e205f8d060aa5acd6114babec944abec96c9fd76"}} +{"type":"command.receipt","at":"2026-08-04T01:07:51.679Z","tick":857,"actorId":"dev-1","commandId":1620089,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620089,"issued_at_tick":857,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"c59791b9527c4775205eab41951b0958a12b77c936ddf5cdae603612f629f4e5"}} +{"type":"command.receipt","at":"2026-08-04T01:07:51.881Z","tick":863,"actorId":"dev-1","commandId":1620091,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620091,"issued_at_tick":863,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"6c9c862013eb2431ee550da33d1d70516e5aee0fd404f695cd54c5a1627058ea"}} +{"type":"command.receipt","at":"2026-08-04T01:07:52.080Z","tick":869,"actorId":"dev-1","commandId":1620093,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620093,"issued_at_tick":869,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"a8ed05a23f89211724e2c2ce19cf75f4f2c1387a17c6e4f96ca12c7d9328734b"}} +{"type":"command.receipt","at":"2026-08-04T01:07:52.280Z","tick":875,"actorId":"dev-1","commandId":1620095,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620095,"issued_at_tick":875,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"1ccca9445aeb5f20b943fcaa08c0ac7455f912acf5c569745f275761b7a1fa82"}} +{"type":"command.receipt","at":"2026-08-04T01:07:52.479Z","tick":881,"actorId":"dev-1","commandId":1620097,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620097,"issued_at_tick":880,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"7fb251fae946cd4fc12720b6e065ce812acbb5d1a9e959c9af8e50015232ca29"}} +{"type":"command.receipt","at":"2026-08-04T01:07:52.672Z","tick":886,"actorId":"dev-1","commandId":1620099,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620099,"issued_at_tick":886,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"ba14f2807e0699139a5efa88736c118fc20cd5fe1f2a133abf4bbdb9f2a3eb2d"}} +{"type":"command.receipt","at":"2026-08-04T01:07:52.872Z","tick":892,"actorId":"dev-1","commandId":1620101,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620101,"issued_at_tick":892,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"92a9b94d7d7040973fc3110a1bec69fb9cd5b57134719f84dcf45bcd79aabe77"}} +{"type":"command.receipt","at":"2026-08-04T01:07:53.072Z","tick":898,"actorId":"dev-1","commandId":1620103,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620103,"issued_at_tick":898,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"9ec40312db49c0990eba91258b9292e43f254479394f2327230ba2c4c5e88511"}} +{"type":"command.receipt","at":"2026-08-04T01:07:53.272Z","tick":904,"actorId":"dev-1","commandId":1620105,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620105,"issued_at_tick":904,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"5cf5d3a44a407e7a40dba66a54f6ebce8a766366df8894ca4d55bf2a76e3adb4"}} +{"type":"command.receipt","at":"2026-08-04T01:07:53.470Z","tick":910,"actorId":"dev-1","commandId":1620107,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620107,"issued_at_tick":910,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"f7641f6e64b902e7977376ec53c3329c4a80871ee3beede45d61501a4c045ab4"}} +{"type":"command.receipt","at":"2026-08-04T01:07:53.671Z","tick":916,"actorId":"dev-1","commandId":1620109,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620109,"issued_at_tick":916,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"d43789b36549bbe2044ca7904736f9e7f7a470cb808596ea560f9a4ffc77bd28"}} +{"type":"command.receipt","at":"2026-08-04T01:07:53.872Z","tick":922,"actorId":"dev-1","commandId":1620111,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620111,"issued_at_tick":922,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"9c907be964b3e66fcdc670924fa621d334b690666071039e780d2a1296a89287"}} +{"type":"command.receipt","at":"2026-08-04T01:07:54.070Z","tick":928,"actorId":"dev-1","commandId":1620113,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620113,"issued_at_tick":928,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"930c0e09ea29bfa40db3df2cdf1a64f811105df0b3df403be9c7ce9a45097d28"}} +{"type":"command.receipt","at":"2026-08-04T01:07:54.271Z","tick":934,"actorId":"dev-1","commandId":1620115,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620115,"issued_at_tick":934,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"7cc5de2a463ce0044270cab42de574a3f382b914ae94a23c2c29a6fd025b28b5"}} +{"type":"command.receipt","at":"2026-08-04T01:07:54.463Z","tick":940,"actorId":"dev-1","commandId":1620117,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620117,"issued_at_tick":940,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"d7964fecf534b9908b76372d49f5d7b6c0a57e2879aeb91a63c4415603900db7"}} +{"type":"checkpoint","at":"2026-08-04T01:07:54.511Z","tick":942,"reason":"interval","actorCount":2,"stateHash":"07cd25f89534f07413f12054156b145f6eb6eec4edb6bc9004eb4ed9f00a0288"} +{"type":"command.receipt","at":"2026-08-04T01:07:54.663Z","tick":946,"actorId":"dev-1","commandId":1620119,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620119,"issued_at_tick":946,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"217e3468eda8fd99da0fe25b47ceffcdde3e588e8007c7bc8d76abc555941d37"}} +{"type":"command.receipt","at":"2026-08-04T01:07:54.863Z","tick":952,"actorId":"dev-1","commandId":1620121,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620121,"issued_at_tick":952,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"3ac0471d7c89e25d3a64774d59d342f390b02ac5cec2a68f01336efb6f7cb535"}} +{"type":"command.receipt","at":"2026-08-04T01:07:55.063Z","tick":958,"actorId":"dev-1","commandId":1620123,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620123,"issued_at_tick":958,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"5b4b35ea0d0151c943532d649661b88905eec31c9a28453631fef414ae8043cb"}} +{"type":"command.receipt","at":"2026-08-04T01:07:55.263Z","tick":964,"actorId":"dev-1","commandId":1620125,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620125,"issued_at_tick":964,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"b4e78e991b8babc7b9b6322494d627ad73c5a4f6924d219b6be2710460dabc6b"}} +{"type":"command.receipt","at":"2026-08-04T01:07:55.463Z","tick":970,"actorId":"dev-1","commandId":1620127,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620127,"issued_at_tick":970,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"2bd14540541a1f3698caa316d158de95b3703954f629a810db431ca2a5d8f158"}} +{"type":"command.receipt","at":"2026-08-04T01:07:55.666Z","tick":976,"actorId":"dev-1","commandId":1620129,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620129,"issued_at_tick":976,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"3c031fe3d0440c6e1007f4b46f1a8e8cb23e5acea3a65a4766d020d2e167d254"}} +{"type":"command.receipt","at":"2026-08-04T01:07:55.864Z","tick":982,"actorId":"dev-1","commandId":1620131,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620131,"issued_at_tick":982,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"c44a78b0c6375e61819671037e25ae2aabb41e61239e909d0624ed78cc3a54e2"}} +{"type":"command.receipt","at":"2026-08-04T01:07:56.066Z","tick":988,"actorId":"dev-1","commandId":1620133,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620133,"issued_at_tick":988,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"e3f92513e48f7ff72892cd277f9dcdbe5cf6e1fefb2c1974e4abb9d67e982d7d"}} +{"type":"command.receipt","at":"2026-08-04T01:07:56.263Z","tick":994,"actorId":"dev-1","commandId":1620135,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620135,"issued_at_tick":994,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"86cc8ac7eb1a307fb742a7c5e837ed1fb4e3047d55c367a1c07fc7f80738de31"}} +{"type":"command.receipt","at":"2026-08-04T01:07:56.462Z","tick":1000,"actorId":"dev-1","commandId":1620137,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620137,"issued_at_tick":1000,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"299acc6d45f22247e7cf992f349690cd99035534e7d7f375fb4b10b703139619"}} +{"type":"command.receipt","at":"2026-08-04T01:07:56.664Z","tick":1006,"actorId":"dev-1","commandId":1620139,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620139,"issued_at_tick":1006,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"d027f2a46e4b0a25e76f19818757f8acc64b17832f33141ee8bb37b7888f34a4"}} +{"type":"command.receipt","at":"2026-08-04T01:07:56.865Z","tick":1012,"actorId":"dev-1","commandId":1620141,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620141,"issued_at_tick":1012,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"a2d5a0022ab5cc07fb693f8ea3cba467ada6e81f576ea2316dc17861252e528d"}} +{"type":"command.receipt","at":"2026-08-04T01:07:57.058Z","tick":1018,"actorId":"dev-1","commandId":1620143,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620143,"issued_at_tick":1018,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"8c63ca62fd88b3243c7f4795a72594f6dff06331e5bcbb4a05550df29606f54f"}} +{"type":"command.receipt","at":"2026-08-04T01:07:57.256Z","tick":1024,"actorId":"dev-1","commandId":1620145,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620145,"issued_at_tick":1024,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"6dc54ccb2cef49482e9ad37b80bf66b1247f0d889ab9f4299c146dc5a510d9bd"}} +{"type":"command.receipt","at":"2026-08-04T01:07:57.547Z","tick":1033,"actorId":"dev-1","commandId":1620148,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"player","envelope":{"session":1620,"player":404524662,"command_id":1620148,"issued_at_tick":1032,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":1,"player":1,"stateHash":"2b7f479e6b1e5094ed3a9f1486810b2614752e62743500e6dcce90ba6790b9eb"}} +{"type":"session.disconnect","at":"2026-08-04T01:07:57.754Z","tick":1039,"sessionId":"g_1","actorId":"dev-1","removedTransientActor":true} +{"type":"command.receipt","at":"2026-08-04T01:07:57.767Z","tick":1039,"actorId":"dev-1","commandId":1620150,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-1","rustActorId":"dev-1","envelope":{"session":1620,"player":404524662,"command_id":1620150,"issued_at_tick":1038,"command":{"SetMoveIntent":{"dx":0,"dy":0,"sprint":false}}},"session":1,"player":1,"stateHash":"9b9a7092c5156b406cfc0fa42d9877c6e45875f054fa96491d38641a9289a486"}} +{"type":"checkpoint","at":"2026-08-04T01:07:59.517Z","tick":1039,"reason":"interval","actorCount":2,"stateHash":"9b9a7092c5156b406cfc0fa42d9877c6e45875f054fa96491d38641a9289a486"} +{"type":"session.connect","at":"2026-08-04T01:09:36.772Z","tick":1039,"sessionId":"g_2","actorId":"dev-2","playerId":"dev-2","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T01:09:36.789Z","tick":1040,"reason":"interval","actorCount":2,"stateHash":"4c868727ceb2b95ed97ae8ca512ddcaba24b740f3b2d8fac1a7af7a64bcd2ab4"} +{"type":"command.receipt","at":"2026-08-04T01:09:36.810Z","tick":1040,"actorId":"dev-2","commandId":1589000,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589000,"issued_at_tick":1040,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"2e872594e839a2ec71aebc92331cd31ee38fd96f91b8cd94a265d9f22c072c20"}} +{"type":"command.receipt","at":"2026-08-04T01:09:40.244Z","tick":1143,"actorId":"dev-2","commandId":1589001,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589001,"issued_at_tick":1143,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"c29d69e91d01282e9b0c2aa3a226060b3141caa23ec254e93d9103dbe36b2120"}} +{"type":"command.receipt","at":"2026-08-04T01:09:40.347Z","tick":1147,"actorId":"dev-2","commandId":1589002,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589002,"issued_at_tick":1147,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"61b1e489ef0f2016ea735a32c288e6a23ca1c33ffece7a4bd2b14e8de453fa1c"}} +{"type":"command.receipt","at":"2026-08-04T01:09:40.456Z","tick":1150,"actorId":"dev-2","commandId":1589003,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589003,"issued_at_tick":1150,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"685131c893a00b37111d72c379b336b78ecd8e5705da0fd2db957ba1a25150dd"}} +{"type":"command.receipt","at":"2026-08-04T01:09:40.561Z","tick":1153,"actorId":"dev-2","commandId":1589004,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589004,"issued_at_tick":1153,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"d255d70f775cf739073e86506667ef1d0ce88be52cbfb5dd858a63970caf6100"}} +{"type":"command.receipt","at":"2026-08-04T01:09:40.660Z","tick":1156,"actorId":"dev-2","commandId":1589005,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589005,"issued_at_tick":1156,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"1c3b300b03d419a89906449a215ce274d66f809d2418243b9f3450ed543be4b3"}} +{"type":"command.receipt","at":"2026-08-04T01:09:40.760Z","tick":1159,"actorId":"dev-2","commandId":1589006,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589006,"issued_at_tick":1159,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"1f5a580fe5f1d79a2573fd48ddad28e702d76d31dd39380df23cd6ff62cf5392"}} +{"type":"command.receipt","at":"2026-08-04T01:09:40.860Z","tick":1162,"actorId":"dev-2","commandId":1589007,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589007,"issued_at_tick":1162,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"11f2a15d4d77b4dbb4965f800b976248a4d7c1ea311e5bb2485dda5dd50fadd1"}} +{"type":"command.receipt","at":"2026-08-04T01:09:40.960Z","tick":1165,"actorId":"dev-2","commandId":1589008,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589008,"issued_at_tick":1165,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"092805319299fabf82b7f6b53b110ef0ce19cf172e29aa3053dfc09e5ec2640c"}} +{"type":"command.receipt","at":"2026-08-04T01:09:41.059Z","tick":1168,"actorId":"dev-2","commandId":1589009,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589009,"issued_at_tick":1168,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"d00598548063c48e16ac99eae61be4c5184a62b24c18c468b4a6b78562ceabfd"}} +{"type":"command.receipt","at":"2026-08-04T01:09:41.159Z","tick":1171,"actorId":"dev-2","commandId":1589010,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589010,"issued_at_tick":1171,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"f5bcfcae038b800003c5e011a6cc3ca28939305574042f0dc3ea2bd2c78ff563"}} +{"type":"command.receipt","at":"2026-08-04T01:09:41.260Z","tick":1174,"actorId":"dev-2","commandId":1589011,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589011,"issued_at_tick":1174,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"a617622c1ff27a385fdd429f4d92dcfe66a4b55c5918ca57c89309fe61906afa"}} +{"type":"command.receipt","at":"2026-08-04T01:09:41.360Z","tick":1177,"actorId":"dev-2","commandId":1589012,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589012,"issued_at_tick":1177,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"96dd9dabf89cc0b883b8e7315dc06413713216ac4c34ef8da996ffcce24655ea"}} +{"type":"command.receipt","at":"2026-08-04T01:09:41.460Z","tick":1180,"actorId":"dev-2","commandId":1589013,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589013,"issued_at_tick":1180,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"a2e48c00ceb12a18d74c40efe7f57fb69da2089f7f0114cf588b181c55cb99ca"}} +{"type":"command.receipt","at":"2026-08-04T01:09:41.560Z","tick":1183,"actorId":"dev-2","commandId":1589014,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589014,"issued_at_tick":1183,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"e179f008a027e950dc31b9cb880b0f687acd4af8336cb2c4885b1eeec373ab32"}} +{"type":"command.receipt","at":"2026-08-04T01:09:41.660Z","tick":1186,"actorId":"dev-2","commandId":1589015,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589015,"issued_at_tick":1186,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"0a150d5036d137bb38251c0c8089a7883c9f297f487854ac8593833545efd55a"}} +{"type":"command.receipt","at":"2026-08-04T01:09:41.760Z","tick":1189,"actorId":"dev-2","commandId":1589016,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589016,"issued_at_tick":1189,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"fd87a5531164d8f0a00735a587b8d2c2250b5aa35f615e95f359285b1781a537"}} +{"type":"checkpoint","at":"2026-08-04T01:09:41.792Z","tick":1190,"reason":"interval","actorCount":2,"stateHash":"9132018b2f8d4b449117b0fdb712c8c0e4a04043228e007b44ff06b996284488"} +{"type":"command.receipt","at":"2026-08-04T01:09:41.860Z","tick":1192,"actorId":"dev-2","commandId":1589017,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589017,"issued_at_tick":1192,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"e96cf15cd62b4fabeefd30879c151a77e995053234a3bbce2ebbffac2088856a"}} +{"type":"command.receipt","at":"2026-08-04T01:09:41.960Z","tick":1195,"actorId":"dev-2","commandId":1589018,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589018,"issued_at_tick":1195,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"bd33074c7983e366b7868d38f1c07c631de316b611d45949d2681eb46fd4a968"}} +{"type":"command.receipt","at":"2026-08-04T01:09:42.060Z","tick":1198,"actorId":"dev-2","commandId":1589019,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589019,"issued_at_tick":1198,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"128edebb22d855efae58e20745f5c383cab2b4872a08eb851ac29e07180ad9c5"}} +{"type":"command.receipt","at":"2026-08-04T01:09:42.260Z","tick":1204,"actorId":"dev-2","commandId":1589021,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589021,"issued_at_tick":1204,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"4958f36b3f6713852491886aa242536db3d7f1ddd4f3ea453483af63768b98ff"}} +{"type":"command.receipt","at":"2026-08-04T01:09:42.459Z","tick":1210,"actorId":"dev-2","commandId":1589023,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589023,"issued_at_tick":1210,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"9bef0bba0ab6086c7637ea095ff8e34b8024f2e80c4eba16ea0c9783790aa8e6"}} +{"type":"command.receipt","at":"2026-08-04T01:09:42.660Z","tick":1216,"actorId":"dev-2","commandId":1589025,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589025,"issued_at_tick":1216,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"86d338e279884638dd703d3b8a15df545be976170a916f0cba2887fbe3fe570f"}} +{"type":"command.receipt","at":"2026-08-04T01:09:42.860Z","tick":1222,"actorId":"dev-2","commandId":1589027,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589027,"issued_at_tick":1222,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"46cfd25b14ad7a23463d5ec70f1d830c2e5b167c074ab10cb35ed0eb2490e922"}} +{"type":"command.receipt","at":"2026-08-04T01:09:43.061Z","tick":1228,"actorId":"dev-2","commandId":1589029,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589029,"issued_at_tick":1228,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"721329f0c004644c26df7b3d55ada714546c91caae080b550474a8430e105c3d"}} +{"type":"command.receipt","at":"2026-08-04T01:09:43.260Z","tick":1234,"actorId":"dev-2","commandId":1589031,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589031,"issued_at_tick":1234,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"49f407ad4f1502eb7d0042024afa92fdb4b9d1cf802ebdbaab12cb352719fd34"}} +{"type":"command.receipt","at":"2026-08-04T01:09:43.461Z","tick":1240,"actorId":"dev-2","commandId":1589033,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589033,"issued_at_tick":1240,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"1ba10f715e81ec15ba49e7964ca2c39637faa0dd111fc3a7b10097eeccb3c5e8"}} +{"type":"command.receipt","at":"2026-08-04T01:09:43.659Z","tick":1246,"actorId":"dev-2","commandId":1589035,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589035,"issued_at_tick":1246,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"d29272def32cf6c2181d81c7e1c1fbc38d467fb7b15d590adb67a1962bb5a255"}} +{"type":"command.receipt","at":"2026-08-04T01:09:43.860Z","tick":1252,"actorId":"dev-2","commandId":1589037,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589037,"issued_at_tick":1252,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"c47d31c389e3c516ab6c601b446c8e8cd9c853f734464a0eb01a066265ca9313"}} +{"type":"command.receipt","at":"2026-08-04T01:09:44.060Z","tick":1258,"actorId":"dev-2","commandId":1589039,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589039,"issued_at_tick":1258,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"94ffd93b7e3c328472783340fa29f99d1fa1beecea3d7191b24a03e826c8f642"}} +{"type":"command.receipt","at":"2026-08-04T01:09:44.259Z","tick":1264,"actorId":"dev-2","commandId":1589041,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589041,"issued_at_tick":1264,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"b4b82994ab64b14ec0622a05299ca5ccd5b01bf3e05245bb3d2daafd2eb00e42"}} +{"type":"command.receipt","at":"2026-08-04T01:09:44.460Z","tick":1270,"actorId":"dev-2","commandId":1589043,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589043,"issued_at_tick":1270,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"69d812d0b381197cb9c9b661ee14f82263040b030c04619a060e98122d61c347"}} +{"type":"command.receipt","at":"2026-08-04T01:09:44.660Z","tick":1276,"actorId":"dev-2","commandId":1589045,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589045,"issued_at_tick":1276,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"be64fbfbafcd5570b766c68521ad9fc6c9d8d089e2bdcd87b0ea831d22ef2e35"}} +{"type":"command.receipt","at":"2026-08-04T01:09:44.860Z","tick":1282,"actorId":"dev-2","commandId":1589047,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589047,"issued_at_tick":1282,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"183a4f5df8bc7e11c3ff0a878b566c76a62dbea8b16621acf9e82bdd2d6860b7"}} +{"type":"command.receipt","at":"2026-08-04T01:09:45.059Z","tick":1288,"actorId":"dev-2","commandId":1589049,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589049,"issued_at_tick":1288,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"ffba2637741a3c139be3478462ca238da3e864851f291dd8935e1e895db39355"}} +{"type":"command.receipt","at":"2026-08-04T01:09:45.260Z","tick":1294,"actorId":"dev-2","commandId":1589051,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589051,"issued_at_tick":1294,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"ab6889a8248ffbed7e4ca0280088292f636d4ff224659afd264b4cd3c138e53f"}} +{"type":"command.receipt","at":"2026-08-04T01:09:45.461Z","tick":1300,"actorId":"dev-2","commandId":1589053,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589053,"issued_at_tick":1300,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"6d5359e6906d0786776ba9c0bec65b2068faac6744692577751855b915ef4153"}} +{"type":"command.receipt","at":"2026-08-04T01:09:45.660Z","tick":1306,"actorId":"dev-2","commandId":1589055,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589055,"issued_at_tick":1306,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"2fb34426af78b5145d080f518990e0b5b3fc94b77f45019901884982af756f74"}} +{"type":"command.receipt","at":"2026-08-04T01:09:45.860Z","tick":1312,"actorId":"dev-2","commandId":1589057,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589057,"issued_at_tick":1312,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"048220702e17252d22eccd4020e0468e4ba011e1cff7c1902c75fd29c11e2c72"}} +{"type":"command.receipt","at":"2026-08-04T01:09:46.059Z","tick":1318,"actorId":"dev-2","commandId":1589059,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589059,"issued_at_tick":1318,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"c2e5a2ff4f5b835a4b54fff8fb930c430d6cf1ba328fc57fabca07109fdf7d9a"}} +{"type":"command.receipt","at":"2026-08-04T01:09:46.260Z","tick":1324,"actorId":"dev-2","commandId":1589061,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589061,"issued_at_tick":1324,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"f2ef65932a4dd057876a0ee0bd6555f75d9b123d15eb1be50f33475a013d2dbb"}} +{"type":"command.receipt","at":"2026-08-04T01:09:46.459Z","tick":1330,"actorId":"dev-2","commandId":1589063,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589063,"issued_at_tick":1330,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"58d6fbd68adf4d37d34c31129d3b34ec42085596bc9272e91e1d155877ca732e"}} +{"type":"command.receipt","at":"2026-08-04T01:09:46.659Z","tick":1336,"actorId":"dev-2","commandId":1589065,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589065,"issued_at_tick":1336,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"ec83e4d49df4f71a5d65ad9cd5ffb41844855eff3e1c6d5b82b3ef66387e93d3"}} +{"type":"checkpoint","at":"2026-08-04T01:09:46.796Z","tick":1340,"reason":"interval","actorCount":2,"stateHash":"87449f91e4695ab69d1df320b5ccfa82ea378937b5c957d554ab2e782ce3c9b5"} +{"type":"command.receipt","at":"2026-08-04T01:09:46.860Z","tick":1342,"actorId":"dev-2","commandId":1589067,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589067,"issued_at_tick":1342,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"6519d136fa0382a35bc9c5ebb1f689787bc1f7c2d6546acb757e8bbbe1a71fa3"}} +{"type":"command.receipt","at":"2026-08-04T01:09:47.061Z","tick":1348,"actorId":"dev-2","commandId":1589069,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589069,"issued_at_tick":1348,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"f7153f06c620cc41c08ffe35c7742062b87c50189dbc12ce7a1fda262bbd47c4"}} +{"type":"command.receipt","at":"2026-08-04T01:09:47.260Z","tick":1354,"actorId":"dev-2","commandId":1589071,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589071,"issued_at_tick":1354,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"52b79ec56fd19343b153751d10ecef6131539838b1cfbebabffaa9623d6dad11"}} +{"type":"command.receipt","at":"2026-08-04T01:09:47.460Z","tick":1360,"actorId":"dev-2","commandId":1589073,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589073,"issued_at_tick":1360,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"9cf5ea83013a2cff9d3c4fba12bbc46cdef199b4b58c9658779bf9624b4a92b8"}} +{"type":"command.receipt","at":"2026-08-04T01:09:47.659Z","tick":1366,"actorId":"dev-2","commandId":1589075,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589075,"issued_at_tick":1366,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"83bc8f72d894049e8997fc0f5904bb366999e6c38aab215b34f01a45d9986e32"}} +{"type":"command.receipt","at":"2026-08-04T01:09:47.861Z","tick":1372,"actorId":"dev-2","commandId":1589077,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589077,"issued_at_tick":1372,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"6da1ffa0ebadaf270fe7eec8a695c75bbb6d5f647e77ea902723532c687dfb8e"}} +{"type":"command.receipt","at":"2026-08-04T01:09:48.060Z","tick":1378,"actorId":"dev-2","commandId":1589079,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589079,"issued_at_tick":1378,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"872290d3d3d84cb78038bd5fea7b51dfb2bc96927e84ca8ce9bd20076184bce4"}} +{"type":"command.receipt","at":"2026-08-04T01:09:48.259Z","tick":1384,"actorId":"dev-2","commandId":1589081,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589081,"issued_at_tick":1384,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"516226b227077e38e4ae8d79e007d1eb4d6711095168b720b251d6ece93a9645"}} +{"type":"command.receipt","at":"2026-08-04T01:09:48.459Z","tick":1390,"actorId":"dev-2","commandId":1589083,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589083,"issued_at_tick":1390,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"40969a3b269be305f0cfbfb78b3819268bf475ef4ec2aa71f595bb997feddc83"}} +{"type":"command.receipt","at":"2026-08-04T01:09:48.661Z","tick":1396,"actorId":"dev-2","commandId":1589085,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589085,"issued_at_tick":1396,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"bb37c9caaf654a35303c7fe402b382835e841cbd026e04fe53a4f59ea59f972c"}} +{"type":"command.receipt","at":"2026-08-04T01:09:48.861Z","tick":1402,"actorId":"dev-2","commandId":1589087,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589087,"issued_at_tick":1402,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"8e015d9a9184d9b18fbcb549e0297f113608a706dd5571283023d56a5e16e887"}} +{"type":"command.receipt","at":"2026-08-04T01:09:49.062Z","tick":1408,"actorId":"dev-2","commandId":1589089,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589089,"issued_at_tick":1408,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"cbd52d627a96c39695a99f4a1c22340d010692d698110349980e7ca5457964da"}} +{"type":"command.receipt","at":"2026-08-04T01:09:49.259Z","tick":1414,"actorId":"dev-2","commandId":1589091,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589091,"issued_at_tick":1414,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"699925ad0597684e231be503b51d58c41bf00425d45d7ec1c9b9df050e1d1b88"}} +{"type":"command.receipt","at":"2026-08-04T01:09:49.461Z","tick":1420,"actorId":"dev-2","commandId":1589093,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589093,"issued_at_tick":1420,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"4713e69f0408a8b0419bdd446dbb41fb0cbef34361b70eff70905cc4cb2f37ba"}} +{"type":"command.receipt","at":"2026-08-04T01:09:49.660Z","tick":1426,"actorId":"dev-2","commandId":1589095,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589095,"issued_at_tick":1426,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"a5f19f6a89c8119679cad73cb06e51f98ca3cd245ffc96398735f7ba8665b8cf"}} +{"type":"command.receipt","at":"2026-08-04T01:09:49.860Z","tick":1432,"actorId":"dev-2","commandId":1589097,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589097,"issued_at_tick":1432,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"9f9ab4e621d81503627fc4e7e0c31ef86c67e95947b7368e3df6c42340f17282"}} +{"type":"command.receipt","at":"2026-08-04T01:09:50.059Z","tick":1438,"actorId":"dev-2","commandId":1589099,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-2","rustActorId":"player","envelope":{"session":1589,"player":387747043,"command_id":1589099,"issued_at_tick":1438,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":2,"player":1,"stateHash":"1becde050fb0ac91cd19210f8067a664b3a95f3cc47df3ee7758276fc6fb4f81"}} +{"type":"session.disconnect","at":"2026-08-04T01:09:50.166Z","tick":1441,"sessionId":"g_2","actorId":"dev-2","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T01:09:51.799Z","tick":1441,"reason":"interval","actorCount":2,"stateHash":"d25f19890792608f38d48b20c0082a179c5b26e26edbbad337a85724ae71c2d1"} +{"type":"session.connect","at":"2026-08-04T01:10:10.897Z","tick":1441,"sessionId":"g_3","actorId":"dev-3","playerId":"dev-3","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T01:10:10.915Z","tick":1441,"reason":"interval","actorCount":2,"stateHash":"2b337ab58c29a7f6352ad7583b11aea75023110c46874afcfdbf3871c3b5fb43"} +{"type":"command.receipt","at":"2026-08-04T01:10:10.945Z","tick":1442,"actorId":"dev-3","commandId":1598000,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598000,"issued_at_tick":1441,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"512a47df89f0f03d3f0b2132aa37938f4c75ece9e723d46cca6200c6757d8516"}} +{"type":"command.receipt","at":"2026-08-04T01:10:14.288Z","tick":1543,"actorId":"dev-3","commandId":1598001,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598001,"issued_at_tick":1543,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"fce250876342a1f077871ba6b75fd0afd776909a4cf0140b5ae18bd62fa651df"}} +{"type":"command.receipt","at":"2026-08-04T01:10:14.390Z","tick":1546,"actorId":"dev-3","commandId":1598002,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598002,"issued_at_tick":1546,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"6a57648e97edbfe00bbc436788aecf7b2799d37a2e110d2e511eca12a45c4748"}} +{"type":"command.receipt","at":"2026-08-04T01:10:14.494Z","tick":1549,"actorId":"dev-3","commandId":1598003,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598003,"issued_at_tick":1549,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"7e907690dd031a4b77867035ca756a0b2fbd8f0d8c10521386eb6cad21f3bfee"}} +{"type":"command.receipt","at":"2026-08-04T01:10:14.597Z","tick":1552,"actorId":"dev-3","commandId":1598004,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598004,"issued_at_tick":1552,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"b96da8cf75837c3db15ffb209c162d7f2dc98030aed4b557e6ff1e22022fe047"}} +{"type":"command.receipt","at":"2026-08-04T01:10:14.696Z","tick":1555,"actorId":"dev-3","commandId":1598005,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598005,"issued_at_tick":1555,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"f11b46bc0644965b2c1a56a96912bad80c697c48219eb4457e6db6a35d5c539b"}} +{"type":"command.receipt","at":"2026-08-04T01:10:14.795Z","tick":1558,"actorId":"dev-3","commandId":1598006,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598006,"issued_at_tick":1558,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"da84f92ce4d2263deb8cfd76abfee9900ebc44b59fe835adfa6c4328aef752ec"}} +{"type":"command.receipt","at":"2026-08-04T01:10:14.894Z","tick":1561,"actorId":"dev-3","commandId":1598007,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598007,"issued_at_tick":1561,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"95ecd5574cb4f21264434087075227a98ecaf7054bb2f368876bcd21b858c2a5"}} +{"type":"command.receipt","at":"2026-08-04T01:10:14.994Z","tick":1564,"actorId":"dev-3","commandId":1598008,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598008,"issued_at_tick":1564,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"94bc47b1a244f050f87a194723bcd52476cdd14ed509e007e03ee7d7cb676dde"}} +{"type":"command.receipt","at":"2026-08-04T01:10:15.096Z","tick":1567,"actorId":"dev-3","commandId":1598009,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598009,"issued_at_tick":1567,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"9c576bfda0cd9f26b11675587fe1202a7e2abafba931e5f4976063380ee0f043"}} +{"type":"command.receipt","at":"2026-08-04T01:10:15.193Z","tick":1570,"actorId":"dev-3","commandId":1598010,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598010,"issued_at_tick":1570,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"1357b4de68a0f05948be5e2b09e2efae7ff9f792fca0853b144aa5c3d9393711"}} +{"type":"command.receipt","at":"2026-08-04T01:10:15.294Z","tick":1573,"actorId":"dev-3","commandId":1598011,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598011,"issued_at_tick":1573,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"f11414f61fbcc7e0403f0fdb3f42c736f5e3e3c1ed4356c0eaa42c3c32e25682"}} +{"type":"command.receipt","at":"2026-08-04T01:10:15.393Z","tick":1576,"actorId":"dev-3","commandId":1598012,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598012,"issued_at_tick":1576,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"60a1cbb00faf4427de0a2e6891458389d156d9e819eda5c98678974bb4f8a1f2"}} +{"type":"command.receipt","at":"2026-08-04T01:10:15.493Z","tick":1579,"actorId":"dev-3","commandId":1598013,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598013,"issued_at_tick":1579,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"67eee9b9319656edfa5b6b20226d5a76eeeae6adfbc9c0eef9cdc1dbb7051937"}} +{"type":"command.receipt","at":"2026-08-04T01:10:15.594Z","tick":1582,"actorId":"dev-3","commandId":1598014,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598014,"issued_at_tick":1582,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"1a47975171ec88aa6b30de0a9e2f491206f3fdafd3e6e21a5bfc707f5d7549c7"}} +{"type":"command.receipt","at":"2026-08-04T01:10:15.693Z","tick":1585,"actorId":"dev-3","commandId":1598015,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598015,"issued_at_tick":1585,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"cadbea4210eddb40bac23e5ddcad143d37b889ee880dcf8ba9de1d7c37f74588"}} +{"type":"command.receipt","at":"2026-08-04T01:10:15.793Z","tick":1588,"actorId":"dev-3","commandId":1598016,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598016,"issued_at_tick":1588,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"767c1048a15953acd90cbbc559a558531aae4c5d3b9b17298cdc32bf7af90f05"}} +{"type":"command.receipt","at":"2026-08-04T01:10:15.893Z","tick":1591,"actorId":"dev-3","commandId":1598017,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598017,"issued_at_tick":1591,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"4ff2685998104cbe02cead77d61f901d9787d46d6f84c4b8c34b20b25d052e65"}} +{"type":"checkpoint","at":"2026-08-04T01:10:15.930Z","tick":1592,"reason":"interval","actorCount":2,"stateHash":"b4937128116256d2355ce27799dda52188fe980d913f0ffae163c745f9ac2225"} +{"type":"command.receipt","at":"2026-08-04T01:10:15.993Z","tick":1594,"actorId":"dev-3","commandId":1598018,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598018,"issued_at_tick":1594,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"71dd34ce2b4152137937cec20e571c25944ff0d0e90950a9a894a4121cded280"}} +{"type":"command.receipt","at":"2026-08-04T01:10:16.092Z","tick":1597,"actorId":"dev-3","commandId":1598019,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598019,"issued_at_tick":1597,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"bf73979b7229d70040dcefd7c464293fbd42420c312d4eb16425a8997fdb2085"}} +{"type":"command.receipt","at":"2026-08-04T01:10:16.293Z","tick":1603,"actorId":"dev-3","commandId":1598021,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598021,"issued_at_tick":1603,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"ee4f188b36a7fcbd7c57eb5946ee046bb95efa1ef48959fbc836b35ad0c924a1"}} +{"type":"command.receipt","at":"2026-08-04T01:10:16.494Z","tick":1609,"actorId":"dev-3","commandId":1598023,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598023,"issued_at_tick":1609,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"33af077513ef653772882b34a780a7f5ac797b12023afcffcbc9e05aca6baf56"}} +{"type":"command.receipt","at":"2026-08-04T01:10:16.693Z","tick":1615,"actorId":"dev-3","commandId":1598025,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598025,"issued_at_tick":1615,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"d45ce07f5514e8e0f5f0869136a94ffd0fb90ba27d509c238365e1d3d5286a9b"}} +{"type":"command.receipt","at":"2026-08-04T01:10:16.894Z","tick":1621,"actorId":"dev-3","commandId":1598027,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598027,"issued_at_tick":1621,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"c3e60bce0d188903fc1fc232ca401749c81b59f1e9562db752dd5f6a82c21633"}} +{"type":"command.receipt","at":"2026-08-04T01:10:17.096Z","tick":1627,"actorId":"dev-3","commandId":1598029,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598029,"issued_at_tick":1627,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"544cd4fbe5e07e782930bf5f49477d9167b144d85e95118abd7cef9a0b31cb8c"}} +{"type":"command.receipt","at":"2026-08-04T01:10:17.315Z","tick":1634,"actorId":"dev-3","commandId":1598031,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598031,"issued_at_tick":1634,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"346add7a9b7922581c9b4603f51979faaf05f159d0b68c008337d8f431d2e43b"}} +{"type":"command.receipt","at":"2026-08-04T01:10:17.518Z","tick":1640,"actorId":"dev-3","commandId":1598033,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598033,"issued_at_tick":1640,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"9fb1e9e6cd639b01624f8e64753b9f5b5a606fdeb9765e1eeac973bf8831b1bd"}} +{"type":"command.receipt","at":"2026-08-04T01:10:17.726Z","tick":1646,"actorId":"dev-3","commandId":1598035,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598035,"issued_at_tick":1646,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"2c9d48c4f755af034391ebf754100977ec86897986075350b4f19538527d6fc3"}} +{"type":"command.receipt","at":"2026-08-04T01:10:17.927Z","tick":1652,"actorId":"dev-3","commandId":1598037,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598037,"issued_at_tick":1652,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"980b612db60862ca177b566443c7291ae4bc509a6c5ad4204f6bca596464ff9d"}} +{"type":"command.receipt","at":"2026-08-04T01:10:18.126Z","tick":1658,"actorId":"dev-3","commandId":1598039,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598039,"issued_at_tick":1658,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"34b3e9ea1de60612fca249e499f5166310f820a893c0efaa3d971a05d01b0e74"}} +{"type":"command.receipt","at":"2026-08-04T01:10:18.326Z","tick":1664,"actorId":"dev-3","commandId":1598041,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598041,"issued_at_tick":1664,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"34e5d7bc6be8fde81323e5a715a2bf0937b3d91ce86f2b7dfdaf81e725049913"}} +{"type":"command.receipt","at":"2026-08-04T01:10:18.527Z","tick":1670,"actorId":"dev-3","commandId":1598043,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598043,"issued_at_tick":1670,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"feab350c9c7b71b2fc6a108f48e2a1a86c0ccb47889244ccbfca58e07cb6e6b3"}} +{"type":"command.receipt","at":"2026-08-04T01:10:18.727Z","tick":1676,"actorId":"dev-3","commandId":1598045,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598045,"issued_at_tick":1676,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"3ee4276cd4c5d9e62c441a6c9e685af38af1b67b88313e9f12a149a29251cdbd"}} +{"type":"command.receipt","at":"2026-08-04T01:10:18.927Z","tick":1682,"actorId":"dev-3","commandId":1598047,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598047,"issued_at_tick":1682,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"382ca31b0a003bc5c03f9f1f8bc30fe7e9261c78d3a212796e62fbcc946faaba"}} +{"type":"command.receipt","at":"2026-08-04T01:10:19.126Z","tick":1688,"actorId":"dev-3","commandId":1598049,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598049,"issued_at_tick":1688,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"065a4f1e6005fd1b030249b4b78099eb936ed36655c0c9b13aa078294a86243d"}} +{"type":"command.receipt","at":"2026-08-04T01:10:19.327Z","tick":1694,"actorId":"dev-3","commandId":1598051,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598051,"issued_at_tick":1694,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"01d3e2a842d3a8e3ce16cbce37f27088e6b86d46699fb704d5f4f2369e9755d1"}} +{"type":"command.receipt","at":"2026-08-04T01:10:19.528Z","tick":1700,"actorId":"dev-3","commandId":1598053,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598053,"issued_at_tick":1700,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"95d7e0506e8b3d487214e0f915ffac0dfab7ac5439db2b3f1028f214a6295912"}} +{"type":"command.receipt","at":"2026-08-04T01:10:19.727Z","tick":1706,"actorId":"dev-3","commandId":1598055,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598055,"issued_at_tick":1706,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"87b2f6cba106bc043e97b26a53632dd468a96d5f31b30fde3d574fc63b2649a3"}} +{"type":"command.receipt","at":"2026-08-04T01:10:19.927Z","tick":1712,"actorId":"dev-3","commandId":1598057,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598057,"issued_at_tick":1712,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"fc90aaa73f4582141d3f3c80b48d42b57ec9d4a32ea3b31a2aa3231f509991be"}} +{"type":"command.receipt","at":"2026-08-04T01:10:20.128Z","tick":1718,"actorId":"dev-3","commandId":1598059,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598059,"issued_at_tick":1718,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"ec37bfaa618e60325bcc1b393a6c46b589a62bcec86e0c5644af01c58d917cd8"}} +{"type":"command.receipt","at":"2026-08-04T01:10:20.326Z","tick":1724,"actorId":"dev-3","commandId":1598061,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598061,"issued_at_tick":1724,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"496da2e8a8984f179dca41502dc53b1ee7e99e7ac50115254e99a741272110a6"}} +{"type":"command.receipt","at":"2026-08-04T01:10:20.530Z","tick":1730,"actorId":"dev-3","commandId":1598063,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598063,"issued_at_tick":1730,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"3b5342c0f21c4d9e45533d93c940f2a94c2a546c4f9507b4612038dda33a6f6b"}} +{"type":"command.receipt","at":"2026-08-04T01:10:20.727Z","tick":1736,"actorId":"dev-3","commandId":1598065,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598065,"issued_at_tick":1736,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"7d874ae5bd4d675e596447b566ca46cc15ebbae1c890c1d03a6e3a090f00d159"}} +{"type":"command.receipt","at":"2026-08-04T01:10:20.926Z","tick":1742,"actorId":"dev-3","commandId":1598067,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598067,"issued_at_tick":1742,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"53a902ce260d0654fbf6363376ce0d87a47c2bab5677e94729be4cd59fe62fc9"}} +{"type":"checkpoint","at":"2026-08-04T01:10:20.938Z","tick":1742,"reason":"interval","actorCount":2,"stateHash":"53a902ce260d0654fbf6363376ce0d87a47c2bab5677e94729be4cd59fe62fc9"} +{"type":"command.receipt","at":"2026-08-04T01:10:21.128Z","tick":1748,"actorId":"dev-3","commandId":1598069,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598069,"issued_at_tick":1748,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"d115024efacd16d879150cb388033bca92fb0b8a3665394100a3266a89a436e2"}} +{"type":"command.receipt","at":"2026-08-04T01:10:21.328Z","tick":1754,"actorId":"dev-3","commandId":1598071,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598071,"issued_at_tick":1754,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"79162dad5b4781ae8c656f978bdb53ab8ac47b8a5351a9b85d506f5b7e199a99"}} +{"type":"command.receipt","at":"2026-08-04T01:10:21.526Z","tick":1760,"actorId":"dev-3","commandId":1598073,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598073,"issued_at_tick":1760,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"d492e74d22d012505be8a3b11b3ec9bd8c63510e6e73d4a9cff15d2f67bd5b47"}} +{"type":"command.receipt","at":"2026-08-04T01:10:21.729Z","tick":1766,"actorId":"dev-3","commandId":1598075,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598075,"issued_at_tick":1766,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"4518247b411bec35eea7b0de34a78092d35d0e7cefc93d4e65b3703335c8d6c0"}} +{"type":"command.receipt","at":"2026-08-04T01:10:21.927Z","tick":1772,"actorId":"dev-3","commandId":1598077,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598077,"issued_at_tick":1772,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"2d95e0c980fe34281671c513e1c82b50eee40446c5d949362f5b4d346c9fe843"}} +{"type":"command.receipt","at":"2026-08-04T01:10:22.127Z","tick":1778,"actorId":"dev-3","commandId":1598079,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598079,"issued_at_tick":1778,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"703d639cf8980632ffa56dfaf575098cd1969b6299239fe2366526471621b80d"}} +{"type":"command.receipt","at":"2026-08-04T01:10:22.327Z","tick":1784,"actorId":"dev-3","commandId":1598081,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598081,"issued_at_tick":1784,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"3d2c32fbb5a584abfb9a67052e507c53a321bcd25521a2bf79b4a78f563418e7"}} +{"type":"command.receipt","at":"2026-08-04T01:10:22.526Z","tick":1790,"actorId":"dev-3","commandId":1598083,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598083,"issued_at_tick":1790,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"bb1e7a38024a9a586d9a72696420353ef37b84d9ac65e1e21aaea9e06fe989da"}} +{"type":"command.receipt","at":"2026-08-04T01:10:22.728Z","tick":1796,"actorId":"dev-3","commandId":1598085,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598085,"issued_at_tick":1796,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"f05002a59f9ce67e3763d96257ad40a42d674ca1f4640f91bc8d38c343f6ac98"}} +{"type":"command.receipt","at":"2026-08-04T01:10:22.928Z","tick":1802,"actorId":"dev-3","commandId":1598087,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598087,"issued_at_tick":1802,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"6de6773e5a1ddb013480532cd6b11538f524b36c683214dd948f6d135c3694bd"}} +{"type":"command.receipt","at":"2026-08-04T01:10:23.126Z","tick":1808,"actorId":"dev-3","commandId":1598089,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598089,"issued_at_tick":1808,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"9e8c47fc2d5888c95ec1cc36ee2878011952dfdcaf988da4333f05a2e72b13e5"}} +{"type":"command.receipt","at":"2026-08-04T01:10:23.335Z","tick":1814,"actorId":"dev-3","commandId":1598091,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598091,"issued_at_tick":1814,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"a3c8874e7346744bdc99625c6988d9c2370593b71ea82781ee6800f91d8a0dc3"}} +{"type":"command.receipt","at":"2026-08-04T01:10:23.539Z","tick":1820,"actorId":"dev-3","commandId":1598093,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598093,"issued_at_tick":1820,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"ed2ed1a7029fce85ae1fdbc8afb5e03e736d331b597c8927d90eeb2c10406b67"}} +{"type":"command.receipt","at":"2026-08-04T01:10:23.735Z","tick":1826,"actorId":"dev-3","commandId":1598095,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598095,"issued_at_tick":1826,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"02e1deedf2b0dcc84cb307fcdbfe2581d13dd2b8911d5763c5e6200658b337f5"}} +{"type":"command.receipt","at":"2026-08-04T01:10:23.935Z","tick":1832,"actorId":"dev-3","commandId":1598097,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598097,"issued_at_tick":1832,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"082d28b1e1b448bbb2878451400bac340fbaca404a5421f18326c8938bf4fc88"}} +{"type":"command.receipt","at":"2026-08-04T01:10:24.135Z","tick":1838,"actorId":"dev-3","commandId":1598099,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598099,"issued_at_tick":1838,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"99531fa780b57345d5b9dc6e24a00abdbc1fa5a9eff2c0eadf9360814aed6269"}} +{"type":"command.receipt","at":"2026-08-04T01:10:24.335Z","tick":1844,"actorId":"dev-3","commandId":1598101,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598101,"issued_at_tick":1844,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"20bdf9ceed0c21d06bba27b478d5f8bda802b7b9434bf4f5c2c92ce729012489"}} +{"type":"command.receipt","at":"2026-08-04T01:10:24.535Z","tick":1850,"actorId":"dev-3","commandId":1598103,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598103,"issued_at_tick":1850,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"8b821280f304e46de426c898a096e8ac92e07b65b17cec62c4ba47d1a8440fe2"}} +{"type":"command.receipt","at":"2026-08-04T01:10:24.737Z","tick":1856,"actorId":"dev-3","commandId":1598105,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598105,"issued_at_tick":1856,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"9c2f95f0d3d3e9ccee59b782edc38f860c2a725652599be4d712df0db6ee6c05"}} +{"type":"command.receipt","at":"2026-08-04T01:10:24.936Z","tick":1862,"actorId":"dev-3","commandId":1598107,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598107,"issued_at_tick":1862,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"1ae6a5bd7ae0ac69bc706faa8bf8b117024cb925a0be995b79bb0c82080baa1d"}} +{"type":"command.receipt","at":"2026-08-04T01:10:25.135Z","tick":1868,"actorId":"dev-3","commandId":1598109,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598109,"issued_at_tick":1868,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"f783f25735c7d4b9d0d5e5fc2a384872561efe9b23c131a8a053f09f4142c0bf"}} +{"type":"command.receipt","at":"2026-08-04T01:10:25.335Z","tick":1874,"actorId":"dev-3","commandId":1598111,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598111,"issued_at_tick":1874,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"66133dcfc9329bb39573d84e49a7c7e9d00e002a0f029867d8ef8921278f5d8f"}} +{"type":"command.receipt","at":"2026-08-04T01:10:25.536Z","tick":1880,"actorId":"dev-3","commandId":1598113,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598113,"issued_at_tick":1880,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"ea81a92744d662eb0195f0f4d69af0e2a25a2f9620622f10b03d90dc3e2062e1"}} +{"type":"command.receipt","at":"2026-08-04T01:10:25.734Z","tick":1886,"actorId":"dev-3","commandId":1598115,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598115,"issued_at_tick":1886,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"565b5623cad65ca03c4a60faba9f06fac720b5497d9e55449aba6964a084c9ee"}} +{"type":"command.receipt","at":"2026-08-04T01:10:25.936Z","tick":1892,"actorId":"dev-3","commandId":1598117,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598117,"issued_at_tick":1892,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"8e6bdf9933b45fc77667a656672592d215f88ab40ea5fd337956d544221faaa6"}} +{"type":"checkpoint","at":"2026-08-04T01:10:25.950Z","tick":1893,"reason":"interval","actorCount":2,"stateHash":"f561c32ad499ffc45fb8e44b5993dc96ce553896886b84e2b64670e78a425243"} +{"type":"command.receipt","at":"2026-08-04T01:10:26.137Z","tick":1898,"actorId":"dev-3","commandId":1598119,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598119,"issued_at_tick":1898,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"3dd53e00af1d7fb173ddc363c07fc8d8be0cc3eceeec1de461dfe24d0bbbbadd"}} +{"type":"command.receipt","at":"2026-08-04T01:10:26.335Z","tick":1904,"actorId":"dev-3","commandId":1598121,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598121,"issued_at_tick":1904,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"dbbf027a7a2ed2e2ceb855613bd2a448e11b1e93efc721d56a54adf641b607e6"}} +{"type":"command.receipt","at":"2026-08-04T01:10:26.537Z","tick":1910,"actorId":"dev-3","commandId":1598123,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598123,"issued_at_tick":1910,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"eea1e95d7abd453881d895d5bcfc1791a1e88ca370df732e905f4de6b4b38572"}} +{"type":"command.receipt","at":"2026-08-04T01:10:26.735Z","tick":1916,"actorId":"dev-3","commandId":1598125,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598125,"issued_at_tick":1916,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"601f18454643a383a77da750b1eb4ab267c59ee17a0354168b7d0afbe3d4a8e5"}} +{"type":"command.receipt","at":"2026-08-04T01:10:26.935Z","tick":1922,"actorId":"dev-3","commandId":1598127,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598127,"issued_at_tick":1922,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"5f1079c63b09aeb9cc413655c11a1e9979a5582bae07b69b4c319ecbf566762b"}} +{"type":"command.receipt","at":"2026-08-04T01:10:27.135Z","tick":1928,"actorId":"dev-3","commandId":1598129,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598129,"issued_at_tick":1928,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"3f76df38224b4ef8f537d7f180d8b79c6d6cfacb6a8e16eec4772de84c143de3"}} +{"type":"command.receipt","at":"2026-08-04T01:10:27.336Z","tick":1934,"actorId":"dev-3","commandId":1598131,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598131,"issued_at_tick":1934,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"a2aa5219ee86411c72146bab8311eb7724dfee2b3c81b7340e9bda40089b73ea"}} +{"type":"command.receipt","at":"2026-08-04T01:10:27.538Z","tick":1940,"actorId":"dev-3","commandId":1598133,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598133,"issued_at_tick":1940,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"65d27b5271ead4d5f1254c424817a6e3d41d2a14b3a0660df45163e77097d9a7"}} +{"type":"command.receipt","at":"2026-08-04T01:10:27.736Z","tick":1946,"actorId":"dev-3","commandId":1598135,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598135,"issued_at_tick":1946,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"e21c77ff526256da22c1e9cd4615eadbe4a12e789c93bab68b7678a68fbefcb1"}} +{"type":"command.receipt","at":"2026-08-04T01:10:27.936Z","tick":1952,"actorId":"dev-3","commandId":1598137,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598137,"issued_at_tick":1952,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"1bc1b2bec0cce6ec1c4c28227f004bc077111e88bb7d2e8bba749f600e508e76"}} +{"type":"command.receipt","at":"2026-08-04T01:10:28.134Z","tick":1958,"actorId":"dev-3","commandId":1598139,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598139,"issued_at_tick":1958,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"a6fa9bbed7bf35402b5efef66cc5b18376b3db4b42ef3238cb49c6075f6313c2"}} +{"type":"command.receipt","at":"2026-08-04T01:10:28.336Z","tick":1964,"actorId":"dev-3","commandId":1598141,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598141,"issued_at_tick":1964,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"070749852567f49c26985e0c254702294d98553669a250130fa9f9898600c9dc"}} +{"type":"command.receipt","at":"2026-08-04T01:10:28.537Z","tick":1970,"actorId":"dev-3","commandId":1598143,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598143,"issued_at_tick":1970,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"7e597f2de877d93802581bb8c92d62a0a912009b88a2f25f64d54eda22a918f0"}} +{"type":"command.receipt","at":"2026-08-04T01:10:28.736Z","tick":1976,"actorId":"dev-3","commandId":1598145,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598145,"issued_at_tick":1976,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"b86daad59bc458ffa7b2a75f3d16ad629c90e46abf7174716a8ef10e6fc544f3"}} +{"type":"command.receipt","at":"2026-08-04T01:10:28.928Z","tick":1982,"actorId":"dev-3","commandId":1598147,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598147,"issued_at_tick":1982,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"d2aaeaca531734037667f8bebc1b9c7ac46a86c2bfc8efe44a74e0adad601c6c"}} +{"type":"command.receipt","at":"2026-08-04T01:10:29.127Z","tick":1988,"actorId":"dev-3","commandId":1598149,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598149,"issued_at_tick":1988,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"6f56c4320cf4dcdbcefbe0bb0c81a01092e84dedf2233f796efda1373df32f05"}} +{"type":"command.receipt","at":"2026-08-04T01:10:29.326Z","tick":1994,"actorId":"dev-3","commandId":1598151,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598151,"issued_at_tick":1994,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"8d99994917018c42d1350deeb8927f7a0106e73795bf5a3c195ba32d7b1898b4"}} +{"type":"command.receipt","at":"2026-08-04T01:10:29.523Z","tick":2000,"actorId":"dev-3","commandId":1598153,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598153,"issued_at_tick":2000,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"dc2344069f613f9e4fc5c96f17ef2188ba79168845c1485fb157c05e9a7a3581"}} +{"type":"command.receipt","at":"2026-08-04T01:10:29.707Z","tick":2005,"actorId":"dev-3","commandId":1598155,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598155,"issued_at_tick":2005,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"5b3010ed3905df0d771aaecbeea6633514f51019118d51fc5b24118e508e2de9"}} +{"type":"command.receipt","at":"2026-08-04T01:10:29.893Z","tick":2011,"actorId":"dev-3","commandId":1598157,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598157,"issued_at_tick":2011,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"a939c914a95731cf320a044af79619924c2870b35e88b995282879503553f869"}} +{"type":"command.receipt","at":"2026-08-04T01:10:30.187Z","tick":2020,"actorId":"dev-3","commandId":1598160,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598160,"issued_at_tick":2020,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"8a044e8a238f6854760f1ac8bbdad97dc33e2ed4f93b5a321b8409b8e66a3bdc"}} +{"type":"command.receipt","at":"2026-08-04T01:10:30.386Z","tick":2026,"actorId":"dev-3","commandId":1598162,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598162,"issued_at_tick":2026,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"6c73685e43babb711f5611363a27bf75d6ae52410588f27ff0b208e75939a2d0"}} +{"type":"command.receipt","at":"2026-08-04T01:10:30.586Z","tick":2032,"actorId":"dev-3","commandId":1598164,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598164,"issued_at_tick":2032,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"60385a0e0eb9cb80f26568d3711b58779675cbca63e08dd366ce16923a6fc5ef"}} +{"type":"command.receipt","at":"2026-08-04T01:10:30.786Z","tick":2038,"actorId":"dev-3","commandId":1598166,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598166,"issued_at_tick":2038,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"87acc93363b55c9c69ab0b5a178c9b45ca913088680a65705b1797154e939127"}} +{"type":"checkpoint","at":"2026-08-04T01:10:30.971Z","tick":2043,"reason":"interval","actorCount":2,"stateHash":"4329ac0d2419bd88df221165104a7a25e64c24f06e423cfc170154eb80e43f01"} +{"type":"command.receipt","at":"2026-08-04T01:10:30.995Z","tick":2044,"actorId":"dev-3","commandId":1598168,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598168,"issued_at_tick":2043,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"8c8dc04c3c454d253eb113e79bff67d28c47b504303e7c2eb535b68722e5d514"}} +{"type":"command.receipt","at":"2026-08-04T01:10:31.185Z","tick":2050,"actorId":"dev-3","commandId":1598170,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598170,"issued_at_tick":2050,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"c9d8ac0218c1f0cc791caf0ef0d6b3204740a50517b8e225627d2384321154d5"}} +{"type":"command.receipt","at":"2026-08-04T01:10:31.384Z","tick":2056,"actorId":"dev-3","commandId":1598172,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598172,"issued_at_tick":2056,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"4fce35f282478e1de8f2ec2c139d0ae3ca1229598611ff400a0625fca112ef92"}} +{"type":"command.receipt","at":"2026-08-04T01:10:31.585Z","tick":2062,"actorId":"dev-3","commandId":1598174,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598174,"issued_at_tick":2062,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"65286be310798a431d7c9d7592b0bcfc383a2d692a62dc40d325c423116fb1f5"}} +{"type":"command.receipt","at":"2026-08-04T01:10:31.784Z","tick":2068,"actorId":"dev-3","commandId":1598176,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598176,"issued_at_tick":2068,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"1e4bf69c09e90708c38d2b69355820ed3641b84856f10ba2373934ad2edf1ea6"}} +{"type":"command.receipt","at":"2026-08-04T01:10:31.985Z","tick":2074,"actorId":"dev-3","commandId":1598178,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598178,"issued_at_tick":2074,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"43cee9ad8ce00b870b3dfcc0222a187002978512d9b5ce2637e0f67d03cafd98"}} +{"type":"command.receipt","at":"2026-08-04T01:10:32.185Z","tick":2080,"actorId":"dev-3","commandId":1598180,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598180,"issued_at_tick":2080,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"f00a107b03e3838d0fcadcf58963851e44cb3c0e040362c47c6d9ef659e96b09"}} +{"type":"command.receipt","at":"2026-08-04T01:10:32.385Z","tick":2086,"actorId":"dev-3","commandId":1598182,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598182,"issued_at_tick":2086,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"32e2bff76f88a9bfae14861472e2e4d73222ab67e126bb043fa228fe8801a97f"}} +{"type":"command.receipt","at":"2026-08-04T01:10:32.586Z","tick":2092,"actorId":"dev-3","commandId":1598184,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598184,"issued_at_tick":2092,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"a2bfab9d71b17bcd92946e535b0a22e8d4b105686fc822bcb3ef320d15626ebf"}} +{"type":"command.receipt","at":"2026-08-04T01:10:32.785Z","tick":2098,"actorId":"dev-3","commandId":1598186,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598186,"issued_at_tick":2098,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"61e232d32f53e8f77f6b846a88118f4e33046b72b97c3f4f49a3ac9810085dc4"}} +{"type":"command.receipt","at":"2026-08-04T01:10:32.986Z","tick":2104,"actorId":"dev-3","commandId":1598188,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598188,"issued_at_tick":2104,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"702374a4fe14b3b1471d9268737b01a556e985afff9079321eb692e7174fce4f"}} +{"type":"command.receipt","at":"2026-08-04T01:10:33.185Z","tick":2110,"actorId":"dev-3","commandId":1598190,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598190,"issued_at_tick":2110,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"05673f1c1d9d03db1abeb614cac3427eebddd0399972cdb69aada8d2464a221a"}} +{"type":"command.receipt","at":"2026-08-04T01:10:33.385Z","tick":2116,"actorId":"dev-3","commandId":1598192,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598192,"issued_at_tick":2116,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"7c6259755a7b60e600ed77b75762e8f7ce736dc94b9b2fc30eca7c6184b98242"}} +{"type":"command.receipt","at":"2026-08-04T01:10:33.589Z","tick":2122,"actorId":"dev-3","commandId":1598194,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598194,"issued_at_tick":2122,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"639f5aaaadd3de5be3483f6d88a4dd1f50f63329af22f5ee8755f1cfaf688422"}} +{"type":"command.receipt","at":"2026-08-04T01:10:33.785Z","tick":2128,"actorId":"dev-3","commandId":1598196,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598196,"issued_at_tick":2128,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"123b9f0c0b3e1939784ad820d21b0dd0f7526584e54d1c90dcc6b5878931642a"}} +{"type":"command.receipt","at":"2026-08-04T01:10:33.985Z","tick":2134,"actorId":"dev-3","commandId":1598198,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598198,"issued_at_tick":2134,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"8d042008ceec3ea9c9a5b9b10bfa8b98343f30054e775bfa7694ac02ec012bf4"}} +{"type":"command.receipt","at":"2026-08-04T01:10:34.186Z","tick":2140,"actorId":"dev-3","commandId":1598200,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598200,"issued_at_tick":2140,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"4645567d4f96b31b98eec0933f80e7c3f872e33fc36c40d0db8bed28e4cbec98"}} +{"type":"command.receipt","at":"2026-08-04T01:10:34.386Z","tick":2146,"actorId":"dev-3","commandId":1598202,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598202,"issued_at_tick":2146,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"e479f1fd2765f50f4d432c0671f9e9ed476fc990a05f597a4519ea46e878cf49"}} +{"type":"command.receipt","at":"2026-08-04T01:10:34.586Z","tick":2152,"actorId":"dev-3","commandId":1598204,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598204,"issued_at_tick":2152,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"a2912113c28a07941deb5342c11f3b6fcdb71f1d1a479f16a17d20ae2ca1834c"}} +{"type":"command.receipt","at":"2026-08-04T01:10:34.786Z","tick":2158,"actorId":"dev-3","commandId":1598206,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598206,"issued_at_tick":2158,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"6a07019fe2f81f70065331f06fa1c76c8cbdec091e640da4c7e6866389df6984"}} +{"type":"command.receipt","at":"2026-08-04T01:10:34.985Z","tick":2164,"actorId":"dev-3","commandId":1598208,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598208,"issued_at_tick":2164,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"2fdaf1314a5c78ee09df0edf5a53004fc9ef0e604d63a7adacbbb2165a79f0b5"}} +{"type":"command.receipt","at":"2026-08-04T01:10:35.185Z","tick":2170,"actorId":"dev-3","commandId":1598210,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598210,"issued_at_tick":2170,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"58494ca0500cc176c5568736bf41266b98d2d20cc327fd8b8e2dbf9ea7c3a653"}} +{"type":"command.receipt","at":"2026-08-04T01:10:35.385Z","tick":2176,"actorId":"dev-3","commandId":1598212,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598212,"issued_at_tick":2176,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"7c5371f572f75a0aebac5adf4c90a3676fa999977b0d294b2d5753f4bc6feead"}} +{"type":"command.receipt","at":"2026-08-04T01:10:35.585Z","tick":2182,"actorId":"dev-3","commandId":1598214,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598214,"issued_at_tick":2182,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"d2a15c94721b65f324b32de634004a9283b2451d780b9e81546cbdb08cc6a18f"}} +{"type":"command.receipt","at":"2026-08-04T01:10:35.786Z","tick":2188,"actorId":"dev-3","commandId":1598216,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598216,"issued_at_tick":2188,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"3c97be582d6711951e83577a23c128f1652da665de38e9d995dad83e4bb95877"}} +{"type":"checkpoint","at":"2026-08-04T01:10:35.975Z","tick":2193,"reason":"interval","actorCount":2,"stateHash":"5f29dee13a3aaf243a35ef982e878a912fe95e76ea8683b7b432ec6cf81c5b17"} +{"type":"command.receipt","at":"2026-08-04T01:10:36.004Z","tick":2194,"actorId":"dev-3","commandId":1598218,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598218,"issued_at_tick":2193,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"8389a73e954644641111d20ed2e4fe8100e53996290526fef85e54c666a4e822"}} +{"type":"command.receipt","at":"2026-08-04T01:10:36.186Z","tick":2200,"actorId":"dev-3","commandId":1598220,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598220,"issued_at_tick":2200,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"1393119c01fe17c59ee0bd743254f0c54da6026e362904b880bb84baec1cf868"}} +{"type":"command.receipt","at":"2026-08-04T01:10:36.386Z","tick":2206,"actorId":"dev-3","commandId":1598222,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598222,"issued_at_tick":2206,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"a1647aee6c767bc3db574e68387fe39983e59028f0afd9154d04929126edee2e"}} +{"type":"command.receipt","at":"2026-08-04T01:10:36.584Z","tick":2212,"actorId":"dev-3","commandId":1598224,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598224,"issued_at_tick":2212,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"5698ee1d1c39ca229ccf670e9d5498a3230d54952fd176cf5c306d7f528a3051"}} +{"type":"command.receipt","at":"2026-08-04T01:10:36.786Z","tick":2218,"actorId":"dev-3","commandId":1598226,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598226,"issued_at_tick":2218,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"5a791d354ec781b93bfda52256a947ad6ca8dfa207220857d040c6a1278bed48"}} +{"type":"command.receipt","at":"2026-08-04T01:10:36.986Z","tick":2224,"actorId":"dev-3","commandId":1598228,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598228,"issued_at_tick":2224,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"57d0f11a8aa5c3927ed7062c96966b35353219cef7ea93985285d08a11db4495"}} +{"type":"command.receipt","at":"2026-08-04T01:10:37.184Z","tick":2230,"actorId":"dev-3","commandId":1598230,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598230,"issued_at_tick":2230,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"4414652e701dcdaa58913adee1c41548e0d895e5eb004bd1ebb0c88d06fb335a"}} +{"type":"command.receipt","at":"2026-08-04T01:10:37.384Z","tick":2236,"actorId":"dev-3","commandId":1598232,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598232,"issued_at_tick":2236,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"143dcc3de1ee8a636c609f3c2b60e8e0d1d2927fa69a00eb232ec7e6eac3ec47"}} +{"type":"command.receipt","at":"2026-08-04T01:10:37.585Z","tick":2242,"actorId":"dev-3","commandId":1598234,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598234,"issued_at_tick":2242,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"d9b416983feb1388ab47b04dda6614f4d2e8627bff6c0924c1f9cf3ad360b028"}} +{"type":"command.receipt","at":"2026-08-04T01:10:37.785Z","tick":2248,"actorId":"dev-3","commandId":1598236,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598236,"issued_at_tick":2248,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"3553da7f00fe686b90e031997079ceba8947990190ac6ca9e37a7d28f43d8a1b"}} +{"type":"command.receipt","at":"2026-08-04T01:10:37.985Z","tick":2254,"actorId":"dev-3","commandId":1598238,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598238,"issued_at_tick":2254,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"948fa5138c5b78c275a79d28d15c3df923e940da63f07ea8c2adf0524cd00b85"}} +{"type":"command.receipt","at":"2026-08-04T01:10:38.184Z","tick":2260,"actorId":"dev-3","commandId":1598240,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598240,"issued_at_tick":2260,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"9e908a67e959f25734f7de2df866a1e195d743e88bd32115daea824e3190944a"}} +{"type":"command.receipt","at":"2026-08-04T01:10:38.385Z","tick":2266,"actorId":"dev-3","commandId":1598242,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598242,"issued_at_tick":2266,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"eb942bd58f912c65b160b2836175d856ae46984e547aac4d5cf708279052c85e"}} +{"type":"command.receipt","at":"2026-08-04T01:10:38.585Z","tick":2272,"actorId":"dev-3","commandId":1598244,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598244,"issued_at_tick":2272,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"61fd8bbf1ceaffac632d75a21a9b047acf4b6638c55f014d4344dcfabe1783bf"}} +{"type":"command.receipt","at":"2026-08-04T01:10:38.785Z","tick":2278,"actorId":"dev-3","commandId":1598246,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598246,"issued_at_tick":2278,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"8bc63a985097a86c83f1623ccfb31aa201bedd8bc9d982e91e061cb35cbbe1cf"}} +{"type":"command.receipt","at":"2026-08-04T01:10:38.985Z","tick":2284,"actorId":"dev-3","commandId":1598248,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598248,"issued_at_tick":2284,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"c12d828aa2391dfea4309971a1fbfabe316d779fbeb532c9e6d139db9e7ed442"}} +{"type":"command.receipt","at":"2026-08-04T01:10:39.186Z","tick":2290,"actorId":"dev-3","commandId":1598250,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598250,"issued_at_tick":2290,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"40d0e782c69417375668e8cc7b0a1ba5ff41355d1951651a9b29388ee6b10b87"}} +{"type":"command.receipt","at":"2026-08-04T01:10:39.386Z","tick":2296,"actorId":"dev-3","commandId":1598252,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598252,"issued_at_tick":2296,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"3de79a847f4fc2674ec63e4459c1a7990f5323787bdb02d617d7696174995e5e"}} +{"type":"command.receipt","at":"2026-08-04T01:10:39.585Z","tick":2302,"actorId":"dev-3","commandId":1598254,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598254,"issued_at_tick":2302,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"7b1d5efcdc72f25cb5fc30444e14cec04825fb69ba9074f95c7bc2f68c479fba"}} +{"type":"command.receipt","at":"2026-08-04T01:10:39.785Z","tick":2308,"actorId":"dev-3","commandId":1598256,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598256,"issued_at_tick":2308,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"e9fcc052c115e371edd0139678b9e87ddbc44e6d044582d75e4bfe4529089f17"}} +{"type":"command.receipt","at":"2026-08-04T01:10:39.985Z","tick":2314,"actorId":"dev-3","commandId":1598258,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598258,"issued_at_tick":2314,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"ee8466c42a3a3f60c4197c338f8581c9f3b77e5204a1fb0a5a2011eb8b1e1562"}} +{"type":"command.receipt","at":"2026-08-04T01:10:40.185Z","tick":2320,"actorId":"dev-3","commandId":1598260,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598260,"issued_at_tick":2320,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"3021915f946ca4637455de12bc3331d2db86e0f6a6f38271b9303cec979b2834"}} +{"type":"command.receipt","at":"2026-08-04T01:10:40.401Z","tick":2326,"actorId":"dev-3","commandId":1598262,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598262,"issued_at_tick":2326,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"571c38e96d431b20bb4343e39df0acfd3c80b7a3207ddb21f43c54085d14ce46"}} +{"type":"command.receipt","at":"2026-08-04T01:10:40.502Z","tick":2329,"actorId":"dev-3","commandId":1598263,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598263,"issued_at_tick":2329,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"fe8e5f8759d53eb9a5d104da0ae283bcd9f9aae79a6d1622f5bb8cfa57694202"}} +{"type":"command.receipt","at":"2026-08-04T01:10:40.703Z","tick":2335,"actorId":"dev-3","commandId":1598265,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598265,"issued_at_tick":2335,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"48c2fa5f1c7915cfb392d0befc5f194451a53d3fb5d1a45a166f0d8f61546c47"}} +{"type":"command.receipt","at":"2026-08-04T01:10:40.902Z","tick":2341,"actorId":"dev-3","commandId":1598267,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598267,"issued_at_tick":2341,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"f9b41249b521ac85a7ca4989087703cb3e2f2dfee47effaffb2112c61a34c9e4"}} +{"type":"checkpoint","at":"2026-08-04T01:10:40.978Z","tick":2343,"reason":"interval","actorCount":2,"stateHash":"50bb4dba9940b338d52578e78ad43eeffcc983356d9d5cd8d3bcf0b50769d2bd"} +{"type":"command.receipt","at":"2026-08-04T01:10:41.104Z","tick":2347,"actorId":"dev-3","commandId":1598269,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598269,"issued_at_tick":2347,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"6d030925cea8604843d4bf08b02566a95761481aae255440fc00501bbd63ebc4"}} +{"type":"command.receipt","at":"2026-08-04T01:10:41.314Z","tick":2354,"actorId":"dev-3","commandId":1598271,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598271,"issued_at_tick":2353,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"917f2e8e5a2768dbc30ce6f6837d652e6b755befb9ec739b160900f6ca201417"}} +{"type":"command.receipt","at":"2026-08-04T01:10:41.509Z","tick":2359,"actorId":"dev-3","commandId":1598273,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598273,"issued_at_tick":2359,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"fd45a5e493764413bb8cc0522ea1d7982058743f3f3b02c211ee989c536615de"}} +{"type":"command.receipt","at":"2026-08-04T01:10:41.710Z","tick":2365,"actorId":"dev-3","commandId":1598275,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598275,"issued_at_tick":2365,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"348f4d1b3eb5dd4cbf9ddf1cda44b575b2fea65e9aab5d741242d7c6f283df21"}} +{"type":"command.receipt","at":"2026-08-04T01:10:41.911Z","tick":2371,"actorId":"dev-3","commandId":1598277,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598277,"issued_at_tick":2371,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"40f2b0d5ad73a8334395afc94d40bfaf015915a7b809d3aa0bd6af83f5ab4582"}} +{"type":"command.receipt","at":"2026-08-04T01:10:42.110Z","tick":2377,"actorId":"dev-3","commandId":1598279,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598279,"issued_at_tick":2377,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"aa14b7d344b312057a6b721d0c18fe41937738e6762f8ff1928851a1cd60bd29"}} +{"type":"command.receipt","at":"2026-08-04T01:10:42.313Z","tick":2384,"actorId":"dev-3","commandId":1598281,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598281,"issued_at_tick":2383,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"e34d424c60bdf147816303401578fa13f3fcbff4855cad68bdb9ea7400893787"}} +{"type":"command.receipt","at":"2026-08-04T01:10:42.514Z","tick":2390,"actorId":"dev-3","commandId":1598283,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598283,"issued_at_tick":2390,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"abd7cd993048a0dd301413f901ff4e9f3b4e13ccadf3e82eefe505d0c878e259"}} +{"type":"command.receipt","at":"2026-08-04T01:10:42.710Z","tick":2395,"actorId":"dev-3","commandId":1598285,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598285,"issued_at_tick":2395,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"484bc229736c57eceea91610f01d8f3f263aebf65df8ad8b2568a6093233085e"}} +{"type":"command.receipt","at":"2026-08-04T01:10:42.910Z","tick":2401,"actorId":"dev-3","commandId":1598287,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598287,"issued_at_tick":2401,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"7366798b9a150b6548ae97533100cd4897164d6d8919b6bb49f2c82896d98f0e"}} +{"type":"command.receipt","at":"2026-08-04T01:10:43.110Z","tick":2407,"actorId":"dev-3","commandId":1598289,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598289,"issued_at_tick":2407,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"396c9a243ecb0a8c1187f9b0cc0dce13860e45a7f9c75dad66533db2978a060d"}} +{"type":"command.receipt","at":"2026-08-04T01:10:43.313Z","tick":2414,"actorId":"dev-3","commandId":1598291,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598291,"issued_at_tick":2413,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"f10f46d97c59cbbdfdd536495ecb3b6125a8da46287caec663d7844666a7a139"}} +{"type":"command.receipt","at":"2026-08-04T01:10:43.510Z","tick":2419,"actorId":"dev-3","commandId":1598293,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598293,"issued_at_tick":2419,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"455125fe9b7a7dff4be91fa04f3f6a29db9d70fa17027abca113143304195ec5"}} +{"type":"command.receipt","at":"2026-08-04T01:10:43.711Z","tick":2425,"actorId":"dev-3","commandId":1598295,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598295,"issued_at_tick":2425,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"113859c2a412b867980f341b69c0bbd7876b662c8ccb8a2da97a959b7f95722b"}} +{"type":"command.receipt","at":"2026-08-04T01:10:43.909Z","tick":2431,"actorId":"dev-3","commandId":1598297,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598297,"issued_at_tick":2431,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"e3fce80875a86563068f5c8d9ce636c1e9b89d2541201d7a7ac9bf25e21c4f8a"}} +{"type":"command.receipt","at":"2026-08-04T01:10:44.114Z","tick":2438,"actorId":"dev-3","commandId":1598299,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598299,"issued_at_tick":2437,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"49985d97f5be31c1e888e22cd4aa42af507d1daaf10cdfb7a9fb3d7fac4c97cd"}} +{"type":"command.receipt","at":"2026-08-04T01:10:44.311Z","tick":2443,"actorId":"dev-3","commandId":1598301,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598301,"issued_at_tick":2443,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"4d70209132ad5cb715a72c5f71eb6bed024d51d350804a662989df20c711a965"}} +{"type":"command.receipt","at":"2026-08-04T01:10:44.509Z","tick":2449,"actorId":"dev-3","commandId":1598303,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598303,"issued_at_tick":2449,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"8b50890e9e628cc04061cd005e28fedc6c25ad49683032697311631c04baf6d1"}} +{"type":"command.receipt","at":"2026-08-04T01:10:44.710Z","tick":2455,"actorId":"dev-3","commandId":1598305,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598305,"issued_at_tick":2455,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"bcc69704caeb2dc79f3917370accd84b46dc8f103a488b626ac82c5c9a50c217"}} +{"type":"command.receipt","at":"2026-08-04T01:10:44.911Z","tick":2461,"actorId":"dev-3","commandId":1598307,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598307,"issued_at_tick":2461,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"676a2e035baa41dcef73511c67d05a6d8c576a543d0725028aee6f601d3ef59c"}} +{"type":"command.receipt","at":"2026-08-04T01:10:45.111Z","tick":2467,"actorId":"dev-3","commandId":1598309,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598309,"issued_at_tick":2467,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"aaa07a9444b2cb641357b136dea5bc08cbf38c324c2aadf3cb24121541aa0a56"}} +{"type":"command.receipt","at":"2026-08-04T01:10:45.310Z","tick":2473,"actorId":"dev-3","commandId":1598311,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598311,"issued_at_tick":2473,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"5313fe84ebacfbed50be06d19aa6f9bb97b8a88ad155bae40f0d93616ca6e791"}} +{"type":"command.receipt","at":"2026-08-04T01:10:45.513Z","tick":2480,"actorId":"dev-3","commandId":1598313,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598313,"issued_at_tick":2479,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"59c45d6041012a7f1d2d020760dfe62e218e401bfc3db767dc85436c1dc18597"}} +{"type":"command.receipt","at":"2026-08-04T01:10:45.715Z","tick":2486,"actorId":"dev-3","commandId":1598315,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598315,"issued_at_tick":2485,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"d11f1560a8a13537e7d98b099d0cc869c2625c78e6389b525ded4066209b379f"}} +{"type":"command.receipt","at":"2026-08-04T01:10:45.948Z","tick":2493,"actorId":"dev-3","commandId":1598317,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598317,"issued_at_tick":2493,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"98cead23b17e468a21f5641d7749610b4450a1918c4f2d20e1b7741e53f3ff5b"}} +{"type":"checkpoint","at":"2026-08-04T01:10:45.986Z","tick":2494,"reason":"interval","actorCount":2,"stateHash":"8884fd1450bd00127c117696c00bd9329f961d305283e93c7d2748cd3b9ae350"} +{"type":"command.receipt","at":"2026-08-04T01:10:46.163Z","tick":2499,"actorId":"dev-3","commandId":1598319,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598319,"issued_at_tick":2499,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"7b935672e37196b07ddb3d99c9e29aae77c286fa9b9603fd3d7e7d133b763b79"}} +{"type":"command.receipt","at":"2026-08-04T01:10:46.384Z","tick":2506,"actorId":"dev-3","commandId":1598321,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598321,"issued_at_tick":2506,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"893fd1d7f80b17ca968c65d06ae6f9d5944af712e77a9d4585bf580ab93f52f4"}} +{"type":"command.receipt","at":"2026-08-04T01:10:46.588Z","tick":2512,"actorId":"dev-3","commandId":1598323,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598323,"issued_at_tick":2512,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"657c30156dd5cb26509ba99d4c2db90f7f64c205e877ffcecf434154a2154c55"}} +{"type":"command.receipt","at":"2026-08-04T01:10:46.688Z","tick":2515,"actorId":"dev-3","commandId":1598324,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598324,"issued_at_tick":2515,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"68952a4c926b36094150dcc1abc50a214415ac12d1abf1127eb11f37fd5fea47"}} +{"type":"command.receipt","at":"2026-08-04T01:10:46.986Z","tick":2524,"actorId":"dev-3","commandId":1598327,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598327,"issued_at_tick":2524,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"08525c7053fdf2047d9cbea938cb4358fc2d9a7fdbc3d89a78712b18c1c61e25"}} +{"type":"command.receipt","at":"2026-08-04T01:10:47.185Z","tick":2530,"actorId":"dev-3","commandId":1598329,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598329,"issued_at_tick":2530,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"bdb35ebbae7385d5f368ebbeec4d9bfb2d5b8ba635b9e7b609010a0645d7d653"}} +{"type":"command.receipt","at":"2026-08-04T01:10:47.384Z","tick":2536,"actorId":"dev-3","commandId":1598331,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598331,"issued_at_tick":2536,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"fe978c21f16ded3d024311217dcf5142c565a4d2b470b86362e6d5fc99deae19"}} +{"type":"command.receipt","at":"2026-08-04T01:10:47.588Z","tick":2542,"actorId":"dev-3","commandId":1598333,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598333,"issued_at_tick":2542,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"836018aca21301a43c986c431eb6f7646432016ba11bc27fd19412415f0d209c"}} +{"type":"command.receipt","at":"2026-08-04T01:10:47.697Z","tick":2545,"actorId":"dev-3","commandId":1598334,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598334,"issued_at_tick":2545,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"0c2fc91441cc2b02596f21bef323a3d423a10e8902d59bcd7a9e09dd2b93ca2c"}} +{"type":"command.receipt","at":"2026-08-04T01:10:47.901Z","tick":2551,"actorId":"dev-3","commandId":1598336,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598336,"issued_at_tick":2551,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"01a16f2e41254d76d19279fe551228216136913580fc8c9327c3f43e7db00ed4"}} +{"type":"command.receipt","at":"2026-08-04T01:10:48.102Z","tick":2557,"actorId":"dev-3","commandId":1598338,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598338,"issued_at_tick":2557,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"8de55c58621770f9c290e22ed6c87144fc74f30a158e5fe72b3a2e41f8104968"}} +{"type":"command.receipt","at":"2026-08-04T01:10:48.302Z","tick":2563,"actorId":"dev-3","commandId":1598340,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598340,"issued_at_tick":2563,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"8a4c84f55179e679cbeab127d19455fee03e19acad95842fffc7a5bfcf717efe"}} +{"type":"command.receipt","at":"2026-08-04T01:10:48.502Z","tick":2569,"actorId":"dev-3","commandId":1598342,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598342,"issued_at_tick":2569,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"47c61bfae94e9555384ab8f1eac57c1d632bcf9fecccf0e97edd3f09f7d1695c"}} +{"type":"command.receipt","at":"2026-08-04T01:10:48.703Z","tick":2575,"actorId":"dev-3","commandId":1598344,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598344,"issued_at_tick":2575,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"0929797150226f86e40e658ff0d3c394a852a2bae6ae975ba964691d714bc40d"}} +{"type":"command.receipt","at":"2026-08-04T01:10:48.912Z","tick":2581,"actorId":"dev-3","commandId":1598346,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598346,"issued_at_tick":2581,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"9181fd10bf97ac8bcebfe87318124ee1fc301e52bb6a402c0bc2e7bdb8ceea0f"}} +{"type":"command.receipt","at":"2026-08-04T01:10:49.120Z","tick":2588,"actorId":"dev-3","commandId":1598348,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598348,"issued_at_tick":2588,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"910cec31c0f0e7369e2566b151b2631c2f118dc90cb54f0b20140edc4e4e0f07"}} +{"type":"command.receipt","at":"2026-08-04T01:10:49.320Z","tick":2594,"actorId":"dev-3","commandId":1598350,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598350,"issued_at_tick":2594,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"cac483fe63410418a32065dbc1a9140144ef02bf92b4a6605d96931a5f81b4c4"}} +{"type":"command.receipt","at":"2026-08-04T01:10:49.528Z","tick":2600,"actorId":"dev-3","commandId":1598352,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598352,"issued_at_tick":2600,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"e6e6bfeda6387b81be7f11d32c70583133554a1c7e4b35c864ea2db2ba67f278"}} +{"type":"command.receipt","at":"2026-08-04T01:10:49.738Z","tick":2606,"actorId":"dev-3","commandId":1598354,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598354,"issued_at_tick":2606,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"fd2d47efafa70db86b641a4f44404bef349ff4444246c9d1d35161bdd26fb7db"}} +{"type":"command.receipt","at":"2026-08-04T01:10:49.936Z","tick":2612,"actorId":"dev-3","commandId":1598356,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598356,"issued_at_tick":2612,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"4d78275c0f295bf5a5acd20991f731c12eea030b4d7637a96465606ca76aa2fb"}} +{"type":"command.receipt","at":"2026-08-04T01:10:50.138Z","tick":2618,"actorId":"dev-3","commandId":1598358,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598358,"issued_at_tick":2618,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"be5742419deb72d6e42833d8e914fd578a2989750b21fa5ef83b753ff6901d88"}} +{"type":"command.receipt","at":"2026-08-04T01:10:50.336Z","tick":2624,"actorId":"dev-3","commandId":1598360,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598360,"issued_at_tick":2624,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"f950d7b600a021ff3a01c64bc65278bb4ebf4d28a388b67fe821d9f63637aa79"}} +{"type":"command.receipt","at":"2026-08-04T01:10:50.537Z","tick":2630,"actorId":"dev-3","commandId":1598362,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598362,"issued_at_tick":2630,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"0bc6c0bdad5b02661d3b0c8d2dbc31007df6b6b10de5cef5ff2ce59d435ae22e"}} +{"type":"command.receipt","at":"2026-08-04T01:10:50.736Z","tick":2636,"actorId":"dev-3","commandId":1598364,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598364,"issued_at_tick":2636,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"648d4edbd276532a2dc5ab515e750b3ff306a4ac244ffd744451f8463eda6583"}} +{"type":"command.receipt","at":"2026-08-04T01:10:50.935Z","tick":2642,"actorId":"dev-3","commandId":1598366,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598366,"issued_at_tick":2642,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"fb17153dce562a8a2fca15b2494fa2b0d8dc217354f97a7ef86d758a46190221"}} +{"type":"checkpoint","at":"2026-08-04T01:10:50.998Z","tick":2644,"reason":"interval","actorCount":2,"stateHash":"8155b274873861654f786df92bb121c09c1f413728e472e7dec775d20938eee6"} +{"type":"command.receipt","at":"2026-08-04T01:10:51.136Z","tick":2648,"actorId":"dev-3","commandId":1598368,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598368,"issued_at_tick":2648,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"ed94ca664c35bb2092551047a09461ad97e8fb4a7c410e05382f2a6c043b35be"}} +{"type":"command.receipt","at":"2026-08-04T01:10:51.334Z","tick":2654,"actorId":"dev-3","commandId":1598370,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598370,"issued_at_tick":2654,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"405951da438e078e43c3ebb276e479347f9f0558145e2623b175255ae0be22cf"}} +{"type":"command.receipt","at":"2026-08-04T01:10:51.537Z","tick":2660,"actorId":"dev-3","commandId":1598372,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598372,"issued_at_tick":2660,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"b5f5b11ebd310e150248d160abfb5fe50c0e8c369ea5b392a751e8ac871b3318"}} +{"type":"command.receipt","at":"2026-08-04T01:10:51.734Z","tick":2666,"actorId":"dev-3","commandId":1598374,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598374,"issued_at_tick":2666,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"5bdd8057a214e1486e1e9e4e82acbc1d9abf6da3b3e7eab0a8120ed73e4f43de"}} +{"type":"command.receipt","at":"2026-08-04T01:10:51.934Z","tick":2672,"actorId":"dev-3","commandId":1598376,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598376,"issued_at_tick":2672,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"12b1485a40141a786451f7f2ca8af08ac7ef863723b71af94cbd3f960bf22e45"}} +{"type":"command.receipt","at":"2026-08-04T01:10:52.136Z","tick":2678,"actorId":"dev-3","commandId":1598378,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598378,"issued_at_tick":2678,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"1afe02ff7a5721b5c534543cd0f19b9ef4f5b3010af4fcd6d1de2eda831885bb"}} +{"type":"command.receipt","at":"2026-08-04T01:10:52.334Z","tick":2684,"actorId":"dev-3","commandId":1598380,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598380,"issued_at_tick":2684,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"4c331d44953600a4b650e674b92920933d1e302c62412d190543821fefd0ee0f"}} +{"type":"command.receipt","at":"2026-08-04T01:10:52.535Z","tick":2690,"actorId":"dev-3","commandId":1598382,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598382,"issued_at_tick":2690,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"fa43f563aa0aa4ea871ebef6f482501861a8ca72babe8123717dfebe5f88857d"}} +{"type":"command.receipt","at":"2026-08-04T01:10:52.736Z","tick":2696,"actorId":"dev-3","commandId":1598384,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598384,"issued_at_tick":2696,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"4d4e762d16571de5f67d3fcdce0f40f604fe0512f8e72f4760d8deba6bbcc93c"}} +{"type":"command.receipt","at":"2026-08-04T01:10:52.938Z","tick":2702,"actorId":"dev-3","commandId":1598386,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598386,"issued_at_tick":2702,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"3c884b75277008e173833121d8fedb51308980e1a72d2fefdccee14e29818d96"}} +{"type":"command.receipt","at":"2026-08-04T01:10:53.137Z","tick":2708,"actorId":"dev-3","commandId":1598388,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598388,"issued_at_tick":2708,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"44d6c1dbc370b73535c6735e00ce1bb898533ebeb7d282580dc2f4e9977988e0"}} +{"type":"command.receipt","at":"2026-08-04T01:10:53.336Z","tick":2714,"actorId":"dev-3","commandId":1598390,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598390,"issued_at_tick":2714,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"8a69f128dbac579a48f8cddc95572a29f6cbabeafb98f4845287dc62ffd216bc"}} +{"type":"command.receipt","at":"2026-08-04T01:10:53.537Z","tick":2720,"actorId":"dev-3","commandId":1598392,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598392,"issued_at_tick":2720,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"56d7ef4c66d5328bfd7be2c255234da9df2253af35c3e6743d5ffa398727309f"}} +{"type":"command.receipt","at":"2026-08-04T01:10:53.737Z","tick":2726,"actorId":"dev-3","commandId":1598394,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598394,"issued_at_tick":2726,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"120d0fa20f07f5abd27e751bde8a3e62c563ff9c72903b14e64a408c81633966"}} +{"type":"command.receipt","at":"2026-08-04T01:10:53.937Z","tick":2732,"actorId":"dev-3","commandId":1598396,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598396,"issued_at_tick":2732,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"ede7f40f5b502570ab52bbcbf8322a99def875b4aa46ffc992c66cf929b04ba7"}} +{"type":"command.receipt","at":"2026-08-04T01:10:54.135Z","tick":2738,"actorId":"dev-3","commandId":1598398,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598398,"issued_at_tick":2738,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"bc0be02fdf2925a97dbbc5208f4946236acf71f7fa8fcf2311706c7f5bb60b67"}} +{"type":"command.receipt","at":"2026-08-04T01:10:54.338Z","tick":2744,"actorId":"dev-3","commandId":1598400,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598400,"issued_at_tick":2744,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"7d8025e6d1950487cc6197f7eb63b00549e3fd7d279d6dc005c01b913be63421"}} +{"type":"command.receipt","at":"2026-08-04T01:10:54.536Z","tick":2750,"actorId":"dev-3","commandId":1598402,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598402,"issued_at_tick":2750,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"7a7908363f233a518842888fc79455f01487cb927d1b1dace417e71f6c6c1754"}} +{"type":"command.receipt","at":"2026-08-04T01:10:54.736Z","tick":2756,"actorId":"dev-3","commandId":1598404,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598404,"issued_at_tick":2756,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"f9ac659bb7b87eb275b8b49396682096397fe11c89f8dea1b59780a4bab46d7d"}} +{"type":"command.receipt","at":"2026-08-04T01:10:54.936Z","tick":2762,"actorId":"dev-3","commandId":1598406,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598406,"issued_at_tick":2762,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"cba3d06cd63b83e22ea514a50db23c1c7e342fc3e97b2547ab1ea14c0ebf1688"}} +{"type":"command.receipt","at":"2026-08-04T01:10:55.138Z","tick":2768,"actorId":"dev-3","commandId":1598408,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598408,"issued_at_tick":2768,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"06ca63697eebcadd2624efc5eb8535cfd4a2540c99486c374c20e4ca7939eb9e"}} +{"type":"command.receipt","at":"2026-08-04T01:10:55.347Z","tick":2775,"actorId":"dev-3","commandId":1598410,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598410,"issued_at_tick":2774,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"681da89c8b703d8487fc1607f5cc24cd862e4987fed7391c46fe32290f143990"}} +{"type":"command.receipt","at":"2026-08-04T01:10:55.577Z","tick":2781,"actorId":"dev-3","commandId":1598412,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598412,"issued_at_tick":2781,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"70531e22195cda1fa0d2c88bbbf270dc55394cce4e4954b731ef26895956120a"}} +{"type":"command.receipt","at":"2026-08-04T01:10:55.694Z","tick":2785,"actorId":"dev-3","commandId":1598413,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598413,"issued_at_tick":2785,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"895c97539faf2bf36f3bcbcde7ddeb39e1955972c364afc84aa1d3e32c5b2482"}} +{"type":"command.receipt","at":"2026-08-04T01:10:55.894Z","tick":2791,"actorId":"dev-3","commandId":1598415,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598415,"issued_at_tick":2791,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"f14cf39de4d549f6616c890e7abc11d49a603dcd73f7dde222841c8208a293a5"}} +{"type":"checkpoint","at":"2026-08-04T01:10:56.001Z","tick":2794,"reason":"interval","actorCount":2,"stateHash":"8bc81a75f4666aefffb242010ff6aa17eaa82c27ca50ef5664917eebded217da"} +{"type":"command.receipt","at":"2026-08-04T01:10:56.102Z","tick":2797,"actorId":"dev-3","commandId":1598417,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598417,"issued_at_tick":2797,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"12785a108f45b6747d7501ce8c67c4322316a39b92526376709ab929e0bc293f"}} +{"type":"command.receipt","at":"2026-08-04T01:10:56.301Z","tick":2803,"actorId":"dev-3","commandId":1598419,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598419,"issued_at_tick":2803,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"c7e6fc31d2e6145d5e16cd75b3ac010f4496f889528acefbb30ec3c04ae327b4"}} +{"type":"command.receipt","at":"2026-08-04T01:10:56.506Z","tick":2809,"actorId":"dev-3","commandId":1598421,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598421,"issued_at_tick":2809,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"d6170f42e50a0b2a96d42843f0abe742ef899a4550dbd6b150917b5cf53ea387"}} +{"type":"command.receipt","at":"2026-08-04T01:10:56.721Z","tick":2816,"actorId":"dev-3","commandId":1598423,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598423,"issued_at_tick":2816,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"ecb6188e236b0dc1536adccf1056135801d76db0a32db3466fe1cda1c72e2eab"}} +{"type":"command.receipt","at":"2026-08-04T01:10:56.921Z","tick":2822,"actorId":"dev-3","commandId":1598425,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598425,"issued_at_tick":2822,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"157e37ef5da894c614e68b289435cea0b53671090af9f7093c721c1f8ec55885"}} +{"type":"command.receipt","at":"2026-08-04T01:10:57.122Z","tick":2828,"actorId":"dev-3","commandId":1598427,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598427,"issued_at_tick":2828,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"773fd274680cafc19b60893fe8f85208b94e087b910a89676be24a05be251374"}} +{"type":"command.receipt","at":"2026-08-04T01:10:57.323Z","tick":2834,"actorId":"dev-3","commandId":1598429,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598429,"issued_at_tick":2834,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"04c531f8a1295a89a81e2d5a71506aad97363187bda1bab8e4d60d6fc9c75857"}} +{"type":"command.receipt","at":"2026-08-04T01:10:57.526Z","tick":2840,"actorId":"dev-3","commandId":1598431,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598431,"issued_at_tick":2840,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"7290dd91ad4cdbc75ae17c5bb7c96be9ffff8c1722d1e14c2188186719242efb"}} +{"type":"command.receipt","at":"2026-08-04T01:10:57.726Z","tick":2846,"actorId":"dev-3","commandId":1598433,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598433,"issued_at_tick":2846,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"280f78f4caaeedd0602dd9d6f094a91850195ec8b5508088ce40a4ae7ccbb28a"}} +{"type":"command.receipt","at":"2026-08-04T01:10:57.927Z","tick":2852,"actorId":"dev-3","commandId":1598435,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598435,"issued_at_tick":2852,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"dd9f7675cd0ee25df2eb8ff53008f31b7c458d0c8a3929d7f48119ed3cb4fb24"}} +{"type":"command.receipt","at":"2026-08-04T01:10:58.126Z","tick":2858,"actorId":"dev-3","commandId":1598437,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598437,"issued_at_tick":2858,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"3a19ab901c7a384d612be33aecbe6d7c47667b0400bd267e6d6b3684314f41e5"}} +{"type":"command.receipt","at":"2026-08-04T01:10:58.327Z","tick":2864,"actorId":"dev-3","commandId":1598439,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598439,"issued_at_tick":2864,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"1d494b414ec3e81c59f9cd41068ac8a86cb5de6ccfa143a66d7230e64e835155"}} +{"type":"command.receipt","at":"2026-08-04T01:10:58.527Z","tick":2870,"actorId":"dev-3","commandId":1598441,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598441,"issued_at_tick":2870,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"63404bf594dcdd77f52e031c09c0ac39a65afe3b7dbea5b4366067e62bdf1390"}} +{"type":"command.receipt","at":"2026-08-04T01:10:58.728Z","tick":2876,"actorId":"dev-3","commandId":1598443,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598443,"issued_at_tick":2876,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"30ff0678836800c6ad642eeba6d3b2391826f979df2ecd04695280f6120a8511"}} +{"type":"command.receipt","at":"2026-08-04T01:10:58.925Z","tick":2882,"actorId":"dev-3","commandId":1598445,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598445,"issued_at_tick":2882,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"f0263da9a00a31fb882e05115fd18e919cb03f0d57add88abdb9b6454ac5f0c3"}} +{"type":"command.receipt","at":"2026-08-04T01:10:59.127Z","tick":2888,"actorId":"dev-3","commandId":1598447,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598447,"issued_at_tick":2888,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"bbcddc60137861bf9f1dc93b386249b8b1e95f28953746f13ad92c41462e0abc"}} +{"type":"command.receipt","at":"2026-08-04T01:10:59.328Z","tick":2894,"actorId":"dev-3","commandId":1598449,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-3","rustActorId":"player","envelope":{"session":1598,"player":370969424,"command_id":1598449,"issued_at_tick":2894,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"249c7f24e5fa6cba68d750fef108108a1ab4bfd3729239acd4a3be3fbf5543bd"}} +{"type":"session.disconnect","at":"2026-08-04T01:10:59.437Z","tick":2897,"sessionId":"g_3","actorId":"dev-3","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T01:11:01.013Z","tick":2897,"reason":"interval","actorCount":2,"stateHash":"feeafece93eec9dc424b52ed3851cfd576ea0d4ef31e6b3e94cf8b60186cc90f"} +{"type":"session.connect","at":"2026-08-04T01:11:01.382Z","tick":2897,"sessionId":"g_4","actorId":"dev-4","playerId":"dev-4","zoneId":"open-desert"} +{"type":"command.receipt","at":"2026-08-04T01:11:01.399Z","tick":2897,"actorId":"dev-4","commandId":1615000,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615000,"issued_at_tick":2897,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"3a3dd869153e1aa401a2c59eec5e33af9bfe898fd0bead6193e62c352e56ddbb"}} +{"type":"command.receipt","at":"2026-08-04T01:11:04.797Z","tick":2999,"actorId":"dev-4","commandId":1615001,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615001,"issued_at_tick":2999,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"383ccdc237d1ed39bff801d25bee16b127afd2f52e34a4ce0df709cd1927dc9e"}} +{"type":"command.receipt","at":"2026-08-04T01:11:04.904Z","tick":3002,"actorId":"dev-4","commandId":1615002,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615002,"issued_at_tick":3002,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"78d237d3e614ae9479e3ec1dca44b812408d3f516b920d1845e4828bb85d483d"}} +{"type":"command.receipt","at":"2026-08-04T01:11:05.004Z","tick":3005,"actorId":"dev-4","commandId":1615003,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615003,"issued_at_tick":3005,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"d9516bf80539759dd94d7d738ff48098ec5b4019f4ca76186ace02f6c348f1d0"}} +{"type":"command.receipt","at":"2026-08-04T01:11:05.102Z","tick":3008,"actorId":"dev-4","commandId":1615004,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615004,"issued_at_tick":3008,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"050f4158c26605eb6972808f392c1369717836e9474b6306c15ccc4459988ef4"}} +{"type":"command.receipt","at":"2026-08-04T01:11:05.203Z","tick":3011,"actorId":"dev-4","commandId":1615005,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615005,"issued_at_tick":3011,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"a32de01d8146bdafdeecdcff9af13fbacb2231824a3a21a1ff54d4babf9f1fc7"}} +{"type":"command.receipt","at":"2026-08-04T01:11:05.304Z","tick":3014,"actorId":"dev-4","commandId":1615006,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615006,"issued_at_tick":3014,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"3b44a91c868b0efbf256f3504a31ba5217c453de394ed7858f56940d4b13f504"}} +{"type":"command.receipt","at":"2026-08-04T01:11:05.404Z","tick":3017,"actorId":"dev-4","commandId":1615007,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615007,"issued_at_tick":3017,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"c1b5510afe5d42db8497feade01449207db25d19df1d3c7a7e734ffdc424f0e6"}} +{"type":"command.receipt","at":"2026-08-04T01:11:05.502Z","tick":3020,"actorId":"dev-4","commandId":1615008,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615008,"issued_at_tick":3020,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"30e0fcff6397a076876a518ca6cecb530504a3519e719059f3f5148f3bc69742"}} +{"type":"command.receipt","at":"2026-08-04T01:11:05.602Z","tick":3023,"actorId":"dev-4","commandId":1615009,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615009,"issued_at_tick":3023,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"3fe80a66deddefb70bcafd383de749850d6285eb382a2cf29515be626342cb80"}} +{"type":"command.receipt","at":"2026-08-04T01:11:05.701Z","tick":3026,"actorId":"dev-4","commandId":1615010,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615010,"issued_at_tick":3026,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"1a77b126a485bf9206cd210d52be70dbdb0d38d540575ba271da3fee905497d8"}} +{"type":"command.receipt","at":"2026-08-04T01:11:05.802Z","tick":3029,"actorId":"dev-4","commandId":1615011,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615011,"issued_at_tick":3029,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"ea7a61cb42be2170e1410424f5078eadfb65c08c65bea4802e485d19feadb26c"}} +{"type":"command.receipt","at":"2026-08-04T01:11:05.902Z","tick":3032,"actorId":"dev-4","commandId":1615012,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615012,"issued_at_tick":3032,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"0dd77d0954a30d7f3a7f8f0c51c535ac467a39c50847d23c149e9fe9c91597b5"}} +{"type":"command.receipt","at":"2026-08-04T01:11:06.003Z","tick":3035,"actorId":"dev-4","commandId":1615013,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615013,"issued_at_tick":3035,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"efac2a71edfd689a989bd6e691c2958a63c84697575e47efe3aea0a25da52a7e"}} +{"type":"checkpoint","at":"2026-08-04T01:11:06.025Z","tick":3036,"reason":"interval","actorCount":2,"stateHash":"610706bed6ac4752e6351824f03539363f28425debc2855d591e75f896760f93"} +{"type":"command.receipt","at":"2026-08-04T01:11:06.103Z","tick":3038,"actorId":"dev-4","commandId":1615014,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615014,"issued_at_tick":3038,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"b6d93164c261a7ceecae978c4df813f1c8be4c884e96181302a28b1d4bc0db1d"}} +{"type":"command.receipt","at":"2026-08-04T01:11:06.201Z","tick":3041,"actorId":"dev-4","commandId":1615015,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615015,"issued_at_tick":3041,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"9e7111667c59efe2bfa01d69e9e00d861e5680d7b2225a8fbf8ea682ac7bf8bf"}} +{"type":"command.receipt","at":"2026-08-04T01:11:06.301Z","tick":3044,"actorId":"dev-4","commandId":1615016,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615016,"issued_at_tick":3044,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"d76c1473050c27839f94aec62989fc174ddc2af08a00ea8d6a754fe6f4d2a581"}} +{"type":"command.receipt","at":"2026-08-04T01:11:06.402Z","tick":3047,"actorId":"dev-4","commandId":1615017,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615017,"issued_at_tick":3047,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"e329ccb9bab480b6ea7c5ddcac6c99798cafb6dbe1ad0308f091919698957d60"}} +{"type":"command.receipt","at":"2026-08-04T01:11:06.502Z","tick":3050,"actorId":"dev-4","commandId":1615018,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615018,"issued_at_tick":3050,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"0b66539bbd2a20436aa7b9118fd5a87aaa8d2b00ab8f1f0fbbde22ec42760c11"}} +{"type":"command.receipt","at":"2026-08-04T01:11:06.603Z","tick":3053,"actorId":"dev-4","commandId":1615019,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615019,"issued_at_tick":3053,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"cbbb60709a3adf74e5146dfdc90f5a3acf4f06579247997b1ca10a02ddf3ebec"}} +{"type":"command.receipt","at":"2026-08-04T01:11:06.801Z","tick":3059,"actorId":"dev-4","commandId":1615021,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615021,"issued_at_tick":3059,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"d1148212acd585676d3903c5e6cbf7d5f4f4d1ba235905a5ae53f4758c2e6863"}} +{"type":"command.receipt","at":"2026-08-04T01:11:07.003Z","tick":3065,"actorId":"dev-4","commandId":1615023,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615023,"issued_at_tick":3065,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"2c46f2b598c74c94c920649e74b813d58dc26fc20462fc7bc5c5f8100b0f2d4c"}} +{"type":"command.receipt","at":"2026-08-04T01:11:07.203Z","tick":3071,"actorId":"dev-4","commandId":1615025,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615025,"issued_at_tick":3071,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"39dd2528595b586dfe1ab2d363cd38592f4d336a1ae92428f8e3a73dc8e76f90"}} +{"type":"command.receipt","at":"2026-08-04T01:11:07.402Z","tick":3077,"actorId":"dev-4","commandId":1615027,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615027,"issued_at_tick":3077,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"2e814070934f24004c7ea44cc2c0e083321a7b1ce8db64dceadac950557649ff"}} +{"type":"command.receipt","at":"2026-08-04T01:11:07.602Z","tick":3083,"actorId":"dev-4","commandId":1615029,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615029,"issued_at_tick":3083,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"5171ed0ee0be609e2b81e5066e0a8d4fde1a9fa079223a7077948efbd2712457"}} +{"type":"command.receipt","at":"2026-08-04T01:11:07.804Z","tick":3089,"actorId":"dev-4","commandId":1615031,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615031,"issued_at_tick":3089,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"32f2a0ea8068d8681435d1b7f2b7127e98eac100c1facddf4d26a94e25821fe4"}} +{"type":"command.receipt","at":"2026-08-04T01:11:08.004Z","tick":3095,"actorId":"dev-4","commandId":1615033,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615033,"issued_at_tick":3095,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"af21e14e463fc3ea6aa8e82aadc75bb9f667a8c8a547383be3d1d18a45dec4e7"}} +{"type":"command.receipt","at":"2026-08-04T01:11:08.202Z","tick":3101,"actorId":"dev-4","commandId":1615035,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615035,"issued_at_tick":3101,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"5521aed4ac1de42c803ac19fb46758caae1d2aae337195eb6693c000e56a2350"}} +{"type":"command.receipt","at":"2026-08-04T01:11:08.406Z","tick":3107,"actorId":"dev-4","commandId":1615037,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615037,"issued_at_tick":3107,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"7274bf1cedea4e4800d1ef48439829606626eb509d1b1a83f55b52c97395c8cd"}} +{"type":"command.receipt","at":"2026-08-04T01:11:08.601Z","tick":3113,"actorId":"dev-4","commandId":1615039,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615039,"issued_at_tick":3113,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"ba59554bd2278c58862519ebeefbdbf082b38fa116edc14eb33861f6fc5717e6"}} +{"type":"command.receipt","at":"2026-08-04T01:11:08.803Z","tick":3119,"actorId":"dev-4","commandId":1615041,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615041,"issued_at_tick":3119,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"2de961592b1592917d52bf168e89c01fe5cc5c77ff990c8bbb5c2d6931c69410"}} +{"type":"command.receipt","at":"2026-08-04T01:11:09.001Z","tick":3125,"actorId":"dev-4","commandId":1615043,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615043,"issued_at_tick":3125,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"d13f0d9513e3d5622aab939e3772a7f8eeb38164a3305eb5696a94de05ef4afa"}} +{"type":"command.receipt","at":"2026-08-04T01:11:09.202Z","tick":3131,"actorId":"dev-4","commandId":1615045,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615045,"issued_at_tick":3131,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"8f1cb5f86851fb7a1be723f9748ad3673152313c5c7637f071d048093fdb0e12"}} +{"type":"command.receipt","at":"2026-08-04T01:11:09.402Z","tick":3137,"actorId":"dev-4","commandId":1615047,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615047,"issued_at_tick":3137,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"d432b2b91e6375c66ae1a6575d1b6a1633e8c73d818f7877cbaa2797833286a9"}} +{"type":"command.receipt","at":"2026-08-04T01:11:09.602Z","tick":3143,"actorId":"dev-4","commandId":1615049,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615049,"issued_at_tick":3143,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"7d6bade3caa505f0578757e92a881e056f831a71e6fa406ac7151d8def263ed3"}} +{"type":"command.receipt","at":"2026-08-04T01:11:09.801Z","tick":3149,"actorId":"dev-4","commandId":1615051,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615051,"issued_at_tick":3149,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"8c6a412cc5bb503647c34b2fc075e1584edc5fd7475d497af5ee5f49099afbc7"}} +{"type":"command.receipt","at":"2026-08-04T01:11:10.003Z","tick":3155,"actorId":"dev-4","commandId":1615053,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615053,"issued_at_tick":3155,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"2be104db6ccc001126e93c16de75ee6964b138d0901a50e08d0a7bee856e2ad0"}} +{"type":"command.receipt","at":"2026-08-04T01:11:10.203Z","tick":3161,"actorId":"dev-4","commandId":1615055,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615055,"issued_at_tick":3161,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"1dbca463ec13628d983345c0b4ab12ea489f8caab85475cf4eba4d33fdafe44b"}} +{"type":"command.receipt","at":"2026-08-04T01:11:10.404Z","tick":3167,"actorId":"dev-4","commandId":1615057,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615057,"issued_at_tick":3167,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"a1fd4d89922cb414effb44b6d7dda87efa02a1d663dd24ebfd3f3464f052ec48"}} +{"type":"command.receipt","at":"2026-08-04T01:11:10.602Z","tick":3173,"actorId":"dev-4","commandId":1615059,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615059,"issued_at_tick":3173,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"9bad93ee3aab4ab5efab6db51e4394ca8fdec14f9f50c860913a65f31eb1a55c"}} +{"type":"command.receipt","at":"2026-08-04T01:11:10.804Z","tick":3179,"actorId":"dev-4","commandId":1615061,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615061,"issued_at_tick":3179,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"aba0f7fc0f5d7aa1369e7a2531e09109cfd126ccedcbb9683f03c3fcc1c8cadd"}} +{"type":"command.receipt","at":"2026-08-04T01:11:11.001Z","tick":3185,"actorId":"dev-4","commandId":1615063,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615063,"issued_at_tick":3185,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"a19ed080579bedb868118f137e54a32413020f0f03bc82360e218fd2e26cd821"}} +{"type":"checkpoint","at":"2026-08-04T01:11:11.035Z","tick":3186,"reason":"interval","actorCount":2,"stateHash":"9a19b616c133c537cb21a19b04deba5d90523fcba3c26ee2bdbecbe4d86c1803"} +{"type":"command.receipt","at":"2026-08-04T01:11:11.203Z","tick":3191,"actorId":"dev-4","commandId":1615065,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615065,"issued_at_tick":3191,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"e4574825154d967c0252a32b27f5249dcf9874a68330b93ae4bb899e9366886a"}} +{"type":"command.receipt","at":"2026-08-04T01:11:11.402Z","tick":3197,"actorId":"dev-4","commandId":1615067,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615067,"issued_at_tick":3197,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"bd26244568001d91539b9cca55670e58a515ddd1c6c29b0b54bda96f23f338d1"}} +{"type":"command.receipt","at":"2026-08-04T01:11:11.601Z","tick":3203,"actorId":"dev-4","commandId":1615069,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615069,"issued_at_tick":3203,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"8d7fcac0da9c43243cd5b24dc938f730406a700d31878508501552fcdaafec45"}} +{"type":"command.receipt","at":"2026-08-04T01:11:11.801Z","tick":3209,"actorId":"dev-4","commandId":1615071,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615071,"issued_at_tick":3209,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"fb0c3746089b455e75b189eccd796b7f2701f449b4f87f1a6c5ba5b3a3d82386"}} +{"type":"command.receipt","at":"2026-08-04T01:11:12.004Z","tick":3215,"actorId":"dev-4","commandId":1615073,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615073,"issued_at_tick":3215,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"2d55e9d19be4466507b7eb2ff72f7a271c1146eb97bb89fbd544ea1c721751c4"}} +{"type":"command.receipt","at":"2026-08-04T01:11:12.203Z","tick":3221,"actorId":"dev-4","commandId":1615075,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615075,"issued_at_tick":3221,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"b94d47dba93f249bbb48ed0c2e883dddbfc149feefa03b9507d3607b5b6899b3"}} +{"type":"command.receipt","at":"2026-08-04T01:11:12.403Z","tick":3227,"actorId":"dev-4","commandId":1615077,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615077,"issued_at_tick":3227,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"40b410ff93b8d0b09e845cdf27da59a20d838237b5c5c345109150fdad22aabe"}} +{"type":"command.receipt","at":"2026-08-04T01:11:12.602Z","tick":3233,"actorId":"dev-4","commandId":1615079,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615079,"issued_at_tick":3233,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"b93c4a53ed50b74f4f29a02fd3e4bd81a0497b4e8e750af6b64f39e8a937a01b"}} +{"type":"command.receipt","at":"2026-08-04T01:11:12.801Z","tick":3239,"actorId":"dev-4","commandId":1615081,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615081,"issued_at_tick":3239,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"bd90467577bb8158ddd7a6189978d781356b7b496c6dadbf700b43e7ff136486"}} +{"type":"command.receipt","at":"2026-08-04T01:11:13.002Z","tick":3245,"actorId":"dev-4","commandId":1615083,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615083,"issued_at_tick":3245,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"6cd8f4c91f36f20d3517489a18aaf9ec143021b043dd6bd59f629440c4a5ee43"}} +{"type":"command.receipt","at":"2026-08-04T01:11:13.201Z","tick":3251,"actorId":"dev-4","commandId":1615085,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615085,"issued_at_tick":3251,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f0428a180bb2b33b5c9c87b8e7e746c243069fac8b8743899e71563e0c18fd6a"}} +{"type":"command.receipt","at":"2026-08-04T01:11:13.401Z","tick":3257,"actorId":"dev-4","commandId":1615087,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615087,"issued_at_tick":3257,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"344069babb7a61073f75e60269ce0544d7f7d7679b56d57e0e5b4efffeb1e14d"}} +{"type":"command.receipt","at":"2026-08-04T01:11:13.603Z","tick":3263,"actorId":"dev-4","commandId":1615089,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615089,"issued_at_tick":3263,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"6e30fdd6ee893ca6164b8c883967d9373ec1731a7ccddd68a623f43dddd12639"}} +{"type":"command.receipt","at":"2026-08-04T01:11:13.802Z","tick":3269,"actorId":"dev-4","commandId":1615091,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615091,"issued_at_tick":3269,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"66aed53a818706d71dd8b6f926dbdfff62d666fb9e2c43163eee94a932b0a1d8"}} +{"type":"command.receipt","at":"2026-08-04T01:11:14.002Z","tick":3275,"actorId":"dev-4","commandId":1615093,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615093,"issued_at_tick":3275,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"4dcd16aa7c69e3c3699dbfe22ae334d13c6bc5fbcc221ce361592f4139ea73f6"}} +{"type":"command.receipt","at":"2026-08-04T01:11:14.201Z","tick":3281,"actorId":"dev-4","commandId":1615095,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615095,"issued_at_tick":3281,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"22d043f02a141073b659c838eea3f817e245cbf2747db692f55d2cdc38ccfdc9"}} +{"type":"command.receipt","at":"2026-08-04T01:11:14.402Z","tick":3287,"actorId":"dev-4","commandId":1615097,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615097,"issued_at_tick":3287,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"54ff1b3f717a7bb7b0156cb93386cfe487e61e4f895a20d0963cdcac10f44174"}} +{"type":"command.receipt","at":"2026-08-04T01:11:14.602Z","tick":3293,"actorId":"dev-4","commandId":1615099,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615099,"issued_at_tick":3293,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"7c30c493304b7c1ea4b1d8b6386fa87eaddb92401ba3c3f5217b0d7523561a97"}} +{"type":"command.receipt","at":"2026-08-04T01:11:14.802Z","tick":3299,"actorId":"dev-4","commandId":1615101,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615101,"issued_at_tick":3299,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"d43924bd04a9cb57cb9e832ef2a9996706bd3850a1f0ac70928db8d13ada0dc6"}} +{"type":"command.receipt","at":"2026-08-04T01:11:15.002Z","tick":3305,"actorId":"dev-4","commandId":1615103,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615103,"issued_at_tick":3305,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"19029ccf3849981178daf8d48fbd09e275c330bef38e465a9e11cabe5d7c894e"}} +{"type":"command.receipt","at":"2026-08-04T01:11:15.201Z","tick":3311,"actorId":"dev-4","commandId":1615105,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615105,"issued_at_tick":3311,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"1dc15be5696b87b907f25f56ca873c4f682853dac3d83d8ff944ec816ab1944e"}} +{"type":"command.receipt","at":"2026-08-04T01:11:15.402Z","tick":3317,"actorId":"dev-4","commandId":1615107,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615107,"issued_at_tick":3317,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"72629dfa4e45588dec3a9c5791898ba9a5c40047a9177aea1724017548e87489"}} +{"type":"command.receipt","at":"2026-08-04T01:11:15.603Z","tick":3323,"actorId":"dev-4","commandId":1615109,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615109,"issued_at_tick":3323,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"42945d96b923a1d18566adee1975fb6e3cc46a46a4b5adae40d952089be9cd94"}} +{"type":"command.receipt","at":"2026-08-04T01:11:15.801Z","tick":3329,"actorId":"dev-4","commandId":1615111,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615111,"issued_at_tick":3329,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"0da4ff2ca51e1dff840abcf7f82f8b4587a50787ede9bda833e7a8390cf0a9ee"}} +{"type":"command.receipt","at":"2026-08-04T01:11:16.003Z","tick":3335,"actorId":"dev-4","commandId":1615113,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615113,"issued_at_tick":3335,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"105d98baa7a6a6662189065f9d2631f8f233f4f3d41096b0e7d3dd3b337211c1"}} +{"type":"checkpoint","at":"2026-08-04T01:11:16.048Z","tick":3337,"reason":"interval","actorCount":2,"stateHash":"911f717e6ab812fd38396045132f535adf649fb5d090d732feb9045c70409c2a"} +{"type":"command.receipt","at":"2026-08-04T01:11:16.203Z","tick":3341,"actorId":"dev-4","commandId":1615115,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615115,"issued_at_tick":3341,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"7758f2de8f743fcd35410cd3be5f2f4b66e9e7629f96b46db1fafb178297d18f"}} +{"type":"command.receipt","at":"2026-08-04T01:11:16.402Z","tick":3347,"actorId":"dev-4","commandId":1615117,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615117,"issued_at_tick":3347,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"fb828c5472dcf552bf0e16b768cc770d02c4993d38b925ec7d0c8defaf69f69a"}} +{"type":"command.receipt","at":"2026-08-04T01:11:16.602Z","tick":3353,"actorId":"dev-4","commandId":1615119,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615119,"issued_at_tick":3353,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"b1078d000ee75ea3d5c8408116da2045ef703f3e2b590041132431c149a7790e"}} +{"type":"command.receipt","at":"2026-08-04T01:11:16.802Z","tick":3359,"actorId":"dev-4","commandId":1615121,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615121,"issued_at_tick":3359,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"7c8e8787a039aa4bcea01b2b2e66f4a376f3e91681944f377b3af931b589601e"}} +{"type":"command.receipt","at":"2026-08-04T01:11:17.003Z","tick":3365,"actorId":"dev-4","commandId":1615123,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615123,"issued_at_tick":3365,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"6e807a6f4673663e7ade477414391661bc7aeee90d0ff4a058c65d5af6748715"}} +{"type":"command.receipt","at":"2026-08-04T01:11:17.202Z","tick":3371,"actorId":"dev-4","commandId":1615125,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615125,"issued_at_tick":3371,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"637931b2d02b9e910353cac0d2fdd0a6e8b01793f21718e1b45138754264d323"}} +{"type":"command.receipt","at":"2026-08-04T01:11:17.402Z","tick":3377,"actorId":"dev-4","commandId":1615127,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615127,"issued_at_tick":3377,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"124b26f32d15c165eb6cb51b9c6cac93437ca04701333d5394e1991b27ab7295"}} +{"type":"command.receipt","at":"2026-08-04T01:11:17.600Z","tick":3383,"actorId":"dev-4","commandId":1615129,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615129,"issued_at_tick":3383,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"7cfc98df2ffdff64c9e0fc27f84e7b79b4b5da8fb3618f6c5d15680af7bd431f"}} +{"type":"command.receipt","at":"2026-08-04T01:11:17.802Z","tick":3389,"actorId":"dev-4","commandId":1615131,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615131,"issued_at_tick":3389,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"8090690b5705ed3de22ebfb9aeb98a9f32f6f8ab788c2dc1134f4091d658f144"}} +{"type":"command.receipt","at":"2026-08-04T01:11:18.004Z","tick":3395,"actorId":"dev-4","commandId":1615133,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615133,"issued_at_tick":3395,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"509f21abb7f51f94782dc2c6d20f2091ff0a67a5fdf26d35f72619f2afe78407"}} +{"type":"command.receipt","at":"2026-08-04T01:11:18.203Z","tick":3401,"actorId":"dev-4","commandId":1615135,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615135,"issued_at_tick":3401,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"bcd64e4874798a237689bb88a90896c4250429b19aefa60eb800ca641229f906"}} +{"type":"command.receipt","at":"2026-08-04T01:11:18.403Z","tick":3407,"actorId":"dev-4","commandId":1615137,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615137,"issued_at_tick":3407,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"a8e7f2845b24554b86effa392f244c7d38abb39e17cc46891c39aab2cdc88ccf"}} +{"type":"command.receipt","at":"2026-08-04T01:11:18.601Z","tick":3413,"actorId":"dev-4","commandId":1615139,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615139,"issued_at_tick":3413,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"332eedae1bc2064e65a17b5de69ca232d8e3cf497abda3279902222be1d400e1"}} +{"type":"command.receipt","at":"2026-08-04T01:11:18.802Z","tick":3419,"actorId":"dev-4","commandId":1615141,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615141,"issued_at_tick":3419,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"e5170fc63518d327a7726d20b6b1173b396616ee8fbc06c0ef48c4dcffa747ad"}} +{"type":"command.receipt","at":"2026-08-04T01:11:19.001Z","tick":3425,"actorId":"dev-4","commandId":1615143,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615143,"issued_at_tick":3425,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"8da6a891b06625734542c77597e13f63b1f65010f01b4cf070feab8a6554500b"}} +{"type":"command.receipt","at":"2026-08-04T01:11:19.201Z","tick":3431,"actorId":"dev-4","commandId":1615145,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615145,"issued_at_tick":3431,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"c60124c6ff6ca25a8208d73fc6c202d36dbd8986751cd7ce14a99889b745c2e7"}} +{"type":"command.receipt","at":"2026-08-04T01:11:19.402Z","tick":3437,"actorId":"dev-4","commandId":1615147,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615147,"issued_at_tick":3437,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"94c2071dd16b2f6a0a62a25a8d25f11dea5303b722e08a38ec18a0969e460a34"}} +{"type":"command.receipt","at":"2026-08-04T01:11:19.612Z","tick":3443,"actorId":"dev-4","commandId":1615149,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615149,"issued_at_tick":3443,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"4ccecde9ff55419134b1d88afaa78574f3339dced70ed05c4ac36475b28e2903"}} +{"type":"command.receipt","at":"2026-08-04T01:11:19.811Z","tick":3449,"actorId":"dev-4","commandId":1615151,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615151,"issued_at_tick":3449,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"35b347290f691404646803eecf61b08190d276c4fdf4219b558794e5f8e5c4ba"}} +{"type":"command.receipt","at":"2026-08-04T01:11:20.011Z","tick":3455,"actorId":"dev-4","commandId":1615153,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615153,"issued_at_tick":3455,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"c2409193d0574945d7c512a8657914b03009b72a306a61a6c5aee480093f146c"}} +{"type":"command.receipt","at":"2026-08-04T01:11:20.211Z","tick":3461,"actorId":"dev-4","commandId":1615155,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615155,"issued_at_tick":3461,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"0eb9cec21d72e9e2a540029caba7e7e8c4bab549bccc2d68689b809631677e8d"}} +{"type":"command.receipt","at":"2026-08-04T01:11:20.410Z","tick":3467,"actorId":"dev-4","commandId":1615157,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615157,"issued_at_tick":3467,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f27ddaa0f8fa2cb6334a38f0eb7c7b46d8f20d7277fb4e38c6ba597183fd6b3e"}} +{"type":"command.receipt","at":"2026-08-04T01:11:20.610Z","tick":3473,"actorId":"dev-4","commandId":1615159,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615159,"issued_at_tick":3473,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"02c8dd163301dae5cee5bf29eccc5aff4b8c04dccd10a6a4ac6dad79765f800b"}} +{"type":"command.receipt","at":"2026-08-04T01:11:20.882Z","tick":3482,"actorId":"dev-4","commandId":1615162,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615162,"issued_at_tick":3482,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"8c1c7f8e44cc7dc56efbed99d5bc1f863ef85ed0cb67669e1f134223419878ab"}} +{"type":"checkpoint","at":"2026-08-04T01:11:21.058Z","tick":3487,"reason":"interval","actorCount":2,"stateHash":"91e55617f1c20966fbb0df48cd4c19b75ad8a25c7daea11280f5d02eab097da8"} +{"type":"command.receipt","at":"2026-08-04T01:11:21.077Z","tick":3487,"actorId":"dev-4","commandId":1615164,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615164,"issued_at_tick":3487,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"706cc93bfe9d557ea806d40b688bbc9c37fa4e1867e6363408030a26a717f71b"}} +{"type":"command.receipt","at":"2026-08-04T01:11:21.260Z","tick":3493,"actorId":"dev-4","commandId":1615166,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615166,"issued_at_tick":3493,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"21d61813e2cc564440f12db8e93ff8c209836e8f0bd5bf027e4a4c305381d0a6"}} +{"type":"command.receipt","at":"2026-08-04T01:11:21.459Z","tick":3499,"actorId":"dev-4","commandId":1615168,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615168,"issued_at_tick":3499,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"d9b59cb9f9cd45125ed2df6e224322ff8d99fb7c96f8de4b9c47f170ae0b5287"}} +{"type":"command.receipt","at":"2026-08-04T01:11:21.654Z","tick":3505,"actorId":"dev-4","commandId":1615170,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615170,"issued_at_tick":3505,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"e11ef6833be01cd74d8178190c0bb964aab2ab9fa1a9801d06d3206d0fc26b00"}} +{"type":"command.receipt","at":"2026-08-04T01:11:21.844Z","tick":3510,"actorId":"dev-4","commandId":1615172,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615172,"issued_at_tick":3510,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"6aa625964da86c7aacffd9499f6545964e4d2cc22ca747edad46160fd94a98b8"}} +{"type":"command.receipt","at":"2026-08-04T01:11:22.027Z","tick":3516,"actorId":"dev-4","commandId":1615174,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615174,"issued_at_tick":3516,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"048e6757a47ce786a9effb3f0915ea154f351cb8a11d79eea44d199717156f5f"}} +{"type":"command.receipt","at":"2026-08-04T01:11:22.225Z","tick":3522,"actorId":"dev-4","commandId":1615176,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615176,"issued_at_tick":3522,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"4ce12ef9a33963e65d24fa2596d3035c1e6f06e6e154c320fa94629a32f67761"}} +{"type":"command.receipt","at":"2026-08-04T01:11:22.425Z","tick":3528,"actorId":"dev-4","commandId":1615178,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615178,"issued_at_tick":3528,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"bccebe50c987d2ccece35270c75dc9549cef4fd10859a774ed6dab6695ba78dc"}} +{"type":"command.receipt","at":"2026-08-04T01:11:22.626Z","tick":3534,"actorId":"dev-4","commandId":1615180,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615180,"issued_at_tick":3534,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"6e881b2001e8b431f91d3e051837eef7925e9328b7f178176f838d5ad089d17d"}} +{"type":"command.receipt","at":"2026-08-04T01:11:22.811Z","tick":3539,"actorId":"dev-4","commandId":1615182,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615182,"issued_at_tick":3539,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f4279040262e6867f0d5f1174f8098ca7da23b007faaf076cc02f1be654775a7"}} +{"type":"command.receipt","at":"2026-08-04T01:11:22.999Z","tick":3545,"actorId":"dev-4","commandId":1615184,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615184,"issued_at_tick":3545,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f5e6bd398b1e81e8caf93c8ce1490173407a181e63d8ca51f62e434cb174a4d8"}} +{"type":"command.receipt","at":"2026-08-04T01:11:23.287Z","tick":3554,"actorId":"dev-4","commandId":1615187,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615187,"issued_at_tick":3554,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"69193617e734d85ec26c1b7eb0dd3784a70bb7c2042df4234e5777496c470fea"}} +{"type":"command.receipt","at":"2026-08-04T01:11:23.474Z","tick":3559,"actorId":"dev-4","commandId":1615189,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615189,"issued_at_tick":3559,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"4a58bfeebafb60c95b6f4e5d4922cddb5ffc58c25c1a1aa51ad7f9c5ffd5720c"}} +{"type":"command.receipt","at":"2026-08-04T01:11:23.660Z","tick":3565,"actorId":"dev-4","commandId":1615191,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615191,"issued_at_tick":3565,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"0dfa77d156384ab8505406e6fc77c9ad9577bf9b14f9a845234f66c616f1e665"}} +{"type":"command.receipt","at":"2026-08-04T01:11:23.852Z","tick":3571,"actorId":"dev-4","commandId":1615193,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615193,"issued_at_tick":3571,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"74a6a1ae6c9d597285b1469a9f57e5853dcdfa6195de005468b6fa46bbe0242e"}} +{"type":"command.receipt","at":"2026-08-04T01:11:24.043Z","tick":3576,"actorId":"dev-4","commandId":1615195,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615195,"issued_at_tick":3576,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"0a287753402586c18042695b7613e83ac8f0fb6f53407ed0111b1cfd5ba0c104"}} +{"type":"command.receipt","at":"2026-08-04T01:11:24.245Z","tick":3582,"actorId":"dev-4","commandId":1615197,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615197,"issued_at_tick":3582,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"fb5f3fb023e9df003edd2fae293daa812603030fadad1f0ecff59c0a5fe052a4"}} +{"type":"command.receipt","at":"2026-08-04T01:11:24.444Z","tick":3588,"actorId":"dev-4","commandId":1615199,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615199,"issued_at_tick":3588,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"151aacb9ada590228e1bb57707cdf4b4e1230ffb2be433703f4289ea22f797e1"}} +{"type":"command.receipt","at":"2026-08-04T01:11:24.645Z","tick":3594,"actorId":"dev-4","commandId":1615201,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615201,"issued_at_tick":3594,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"7490f9c5569314399f69dc4c2b49826cc82af0966dbc184827873844a063d31d"}} +{"type":"command.receipt","at":"2026-08-04T01:11:24.845Z","tick":3600,"actorId":"dev-4","commandId":1615203,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615203,"issued_at_tick":3600,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"ad23cc7a75c25f14145603b1d433138613d25d217f1d40fac3673f3a340e78a2"}} +{"type":"command.receipt","at":"2026-08-04T01:11:25.046Z","tick":3607,"actorId":"dev-4","commandId":1615205,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615205,"issued_at_tick":3606,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"e945ae792592e652f9be01ec9e8bfcaf089889d941a4d45bbda2a4ff18b75d7d"}} +{"type":"command.receipt","at":"2026-08-04T01:11:25.230Z","tick":3612,"actorId":"dev-4","commandId":1615207,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615207,"issued_at_tick":3612,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"5f938a01206732ed08260ed3bb1eabe49d2a655d523df825b575c155d66952ec"}} +{"type":"command.receipt","at":"2026-08-04T01:11:25.428Z","tick":3618,"actorId":"dev-4","commandId":1615209,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615209,"issued_at_tick":3618,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"56b413fc1844848a01eb6cab9c5f863dc2c21200f92910b866057420fb452a1c"}} +{"type":"command.receipt","at":"2026-08-04T01:11:25.620Z","tick":3624,"actorId":"dev-4","commandId":1615211,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615211,"issued_at_tick":3624,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"9cced4313f79eab0d96c2cd53b8e78315d4667667ddc66dcc17332f7f000fc06"}} +{"type":"command.receipt","at":"2026-08-04T01:11:25.802Z","tick":3629,"actorId":"dev-4","commandId":1615213,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615213,"issued_at_tick":3629,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"b943eb141a1e61a133850c0d8368a71740422340812f5b6efc43c868835da9a2"}} +{"type":"checkpoint","at":"2026-08-04T01:11:26.068Z","tick":3637,"reason":"interval","actorCount":2,"stateHash":"6f22d4133eae92a1af324f894a919bb97b312c5115840a216b564a803ea83b41"} +{"type":"command.receipt","at":"2026-08-04T01:11:26.092Z","tick":3638,"actorId":"dev-4","commandId":1615216,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615216,"issued_at_tick":3637,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"1d2030df27f8e254b206d54b094a5fff1bbce883b3c341adfda6a033ddc0b4d0"}} +{"type":"command.receipt","at":"2026-08-04T01:11:26.280Z","tick":3644,"actorId":"dev-4","commandId":1615218,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615218,"issued_at_tick":3643,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"aee216d1114839e77b77fe56551cbef1b928aee65dcad62f30ac2426cef0e90d"}} +{"type":"command.receipt","at":"2026-08-04T01:11:26.462Z","tick":3649,"actorId":"dev-4","commandId":1615220,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615220,"issued_at_tick":3649,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"7e1ee2345f0063c8695ed6971cced1d6d9ec28acde3c5febdf986847d03c8e7f"}} +{"type":"command.receipt","at":"2026-08-04T01:11:26.650Z","tick":3655,"actorId":"dev-4","commandId":1615222,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615222,"issued_at_tick":3655,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"3d86ab78d7cd212c487fd5100369e0a1707eb4b8a39c274c3407de4f537528e0"}} +{"type":"command.receipt","at":"2026-08-04T01:11:26.831Z","tick":3660,"actorId":"dev-4","commandId":1615224,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615224,"issued_at_tick":3660,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"920c9e96ce44acf9d79aa2baf85500f979a5c8383f9bf95d40e4364cc54274d4"}} +{"type":"command.receipt","at":"2026-08-04T01:11:27.018Z","tick":3666,"actorId":"dev-4","commandId":1615226,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615226,"issued_at_tick":3666,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"fe7340e26ac5500535c4bb66e1fece8016e401091af582315c6863d8f6de4297"}} +{"type":"command.receipt","at":"2026-08-04T01:11:27.209Z","tick":3671,"actorId":"dev-4","commandId":1615228,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615228,"issued_at_tick":3671,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"a23b84c68b272d2d20f9c62c5b560036c5fd266febffe08e0f30f7b030581f23"}} +{"type":"command.receipt","at":"2026-08-04T01:11:27.399Z","tick":3677,"actorId":"dev-4","commandId":1615230,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615230,"issued_at_tick":3677,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"31b2722838361abc57084f0b304180987aa5b4dd52c297a5e71f12de938863de"}} +{"type":"command.receipt","at":"2026-08-04T01:11:27.672Z","tick":3685,"actorId":"dev-4","commandId":1615233,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615233,"issued_at_tick":3685,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"07ce0d4c52044e351bf79aa0d68e76d5feb71b6b582e3a16f4ee8d54e2daa58b"}} +{"type":"command.receipt","at":"2026-08-04T01:11:27.857Z","tick":3691,"actorId":"dev-4","commandId":1615235,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615235,"issued_at_tick":3691,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"ae594e69e8a2b621e03c9b46a4b065626d08569f74770db75a2659cb134a82cb"}} +{"type":"command.receipt","at":"2026-08-04T01:11:28.039Z","tick":3696,"actorId":"dev-4","commandId":1615237,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615237,"issued_at_tick":3696,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"d1a8d5ade1d08ef3497035709638db2f70850e8f6d11dd77b65cc2a0bac531a5"}} +{"type":"command.receipt","at":"2026-08-04T01:11:28.226Z","tick":3702,"actorId":"dev-4","commandId":1615239,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615239,"issued_at_tick":3702,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"8b05f263ca9cc36a1d00f0ea7db1d51365c2c4295ea0f3bfed7689cd534bc60a"}} +{"type":"command.receipt","at":"2026-08-04T01:11:28.429Z","tick":3708,"actorId":"dev-4","commandId":1615241,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615241,"issued_at_tick":3708,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"2bc060222a074dcf6dcc4dc4ad60dc3f078866c5c7ea75d0c5da33159c463af0"}} +{"type":"command.receipt","at":"2026-08-04T01:11:28.627Z","tick":3714,"actorId":"dev-4","commandId":1615243,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615243,"issued_at_tick":3714,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"e80d6420a0ec95030b48385ebeaaf2b0efb70af46b6fd3319011a37d700b3b74"}} +{"type":"command.receipt","at":"2026-08-04T01:11:28.828Z","tick":3720,"actorId":"dev-4","commandId":1615245,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615245,"issued_at_tick":3720,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"449c5b9688b613ceab9859d5fd206a15eeed378845b65ee8a94b22973279ed68"}} +{"type":"command.receipt","at":"2026-08-04T01:11:29.029Z","tick":3726,"actorId":"dev-4","commandId":1615247,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615247,"issued_at_tick":3726,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"227406708490904787341b804bb8f6af8ce3a92ac9a2bca1df6e03da21f056d8"}} +{"type":"command.receipt","at":"2026-08-04T01:11:29.228Z","tick":3732,"actorId":"dev-4","commandId":1615249,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615249,"issued_at_tick":3732,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"39932c45c06676fa2d7788544ef178a3d642d0646b5477fc9129d6bdb9f08db6"}} +{"type":"command.receipt","at":"2026-08-04T01:11:29.428Z","tick":3738,"actorId":"dev-4","commandId":1615251,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615251,"issued_at_tick":3738,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"3ed9d2328694596bbad8c64dd2bb9f0b719bd70a9665b2e0a352808f9fd41125"}} +{"type":"command.receipt","at":"2026-08-04T01:11:29.628Z","tick":3744,"actorId":"dev-4","commandId":1615253,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615253,"issued_at_tick":3744,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"6c998acda821a3309a0a9514e51a1ceff2c80808c8b754d707b8b773013dd4b2"}} +{"type":"command.receipt","at":"2026-08-04T01:11:29.828Z","tick":3750,"actorId":"dev-4","commandId":1615255,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615255,"issued_at_tick":3750,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"b20d9dd0deffc4920164573b269a69e164819f0516a859d6a8d03313c7899d71"}} +{"type":"command.receipt","at":"2026-08-04T01:11:30.027Z","tick":3756,"actorId":"dev-4","commandId":1615257,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615257,"issued_at_tick":3756,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"98e794efb9790153ee917fdd3ea7b42c30e465aaa31406a3fd3551fb10342183"}} +{"type":"command.receipt","at":"2026-08-04T01:11:30.228Z","tick":3762,"actorId":"dev-4","commandId":1615259,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615259,"issued_at_tick":3762,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"ba26e31873796ddac49268830ed5d15fd758ea64c3e845742e83c82c84bc313c"}} +{"type":"command.receipt","at":"2026-08-04T01:11:30.427Z","tick":3768,"actorId":"dev-4","commandId":1615261,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615261,"issued_at_tick":3768,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"56770dbf1899b52b5ee95d5a85220f03cb3f7e0167b1f0067f8ea153d6e44574"}} +{"type":"command.receipt","at":"2026-08-04T01:11:30.628Z","tick":3774,"actorId":"dev-4","commandId":1615263,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615263,"issued_at_tick":3774,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"91ba30ce49251f18a42761f8cdd216298e3dfb5e81a721f8c944d78a4a9bbf07"}} +{"type":"command.receipt","at":"2026-08-04T01:11:30.829Z","tick":3780,"actorId":"dev-4","commandId":1615265,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615265,"issued_at_tick":3780,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"0a9ccd80e91af6b1503591dc1e81a28e3b5b3ad79b37b64f8640e364b2a76c79"}} +{"type":"command.receipt","at":"2026-08-04T01:11:31.027Z","tick":3786,"actorId":"dev-4","commandId":1615267,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615267,"issued_at_tick":3786,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"b8272db322fd3448e0d8dfba9fee5c4fd628c0ca1ccc62de45b5e9678752c06f"}} +{"type":"checkpoint","at":"2026-08-04T01:11:31.081Z","tick":3788,"reason":"interval","actorCount":2,"stateHash":"c123db0efcca84cf2bcb8c659374d7c9ee46626bd69d15a865bf255fa3c690c8"} +{"type":"command.receipt","at":"2026-08-04T01:11:31.228Z","tick":3792,"actorId":"dev-4","commandId":1615269,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615269,"issued_at_tick":3792,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"d82990bd22f98337580db25fd937c12e1f166931ed41b88fcdcf2f7e08fc53a5"}} +{"type":"command.receipt","at":"2026-08-04T01:11:31.427Z","tick":3798,"actorId":"dev-4","commandId":1615271,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615271,"issued_at_tick":3798,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"5c80fe0daf0ad8eff0574ace7172a43392fd60e6a7d08790e883dacc78c76605"}} +{"type":"command.receipt","at":"2026-08-04T01:11:31.628Z","tick":3804,"actorId":"dev-4","commandId":1615273,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615273,"issued_at_tick":3804,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"896aaf8f09a65018c06431dac4023c1f5c71a89feda1a4f32c215099d5d3d33f"}} +{"type":"command.receipt","at":"2026-08-04T01:11:31.827Z","tick":3810,"actorId":"dev-4","commandId":1615275,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615275,"issued_at_tick":3810,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"ee378bc73c9bbf9349fc7317fb0832d8213388a8f164413606bbcd69edc09b2c"}} +{"type":"command.receipt","at":"2026-08-04T01:11:32.026Z","tick":3816,"actorId":"dev-4","commandId":1615277,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615277,"issued_at_tick":3816,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"1b6ecc65eb258b39e6e8a3dbb702da25bfb6b3c39015297f798486e32154d2df"}} +{"type":"command.receipt","at":"2026-08-04T01:11:32.227Z","tick":3822,"actorId":"dev-4","commandId":1615279,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615279,"issued_at_tick":3822,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"7eb06bcf1b84760d7848aa1b913905369fde2d4f0314d415a5ab2de1a1c3b57f"}} +{"type":"command.receipt","at":"2026-08-04T01:11:32.426Z","tick":3828,"actorId":"dev-4","commandId":1615281,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615281,"issued_at_tick":3828,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"d93f8037415e61ee4013e05e87527e14d17d573b7db933dcf84be50a83b0b012"}} +{"type":"command.receipt","at":"2026-08-04T01:11:32.628Z","tick":3834,"actorId":"dev-4","commandId":1615283,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615283,"issued_at_tick":3834,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"36a48fedae75d6d46601e9f3bcb0556518fde8aa3a2d9ad2d25b04ee7b979597"}} +{"type":"command.receipt","at":"2026-08-04T01:11:32.827Z","tick":3840,"actorId":"dev-4","commandId":1615285,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615285,"issued_at_tick":3840,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"9b1755457eaf8ceffa625a2f5d53385419df96fd2b4f8e869fb1f1655b0864e4"}} +{"type":"command.receipt","at":"2026-08-04T01:11:33.028Z","tick":3846,"actorId":"dev-4","commandId":1615287,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615287,"issued_at_tick":3846,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"3660927e335ae91872102b09c20510cb21f35c4a1c39d84e64d4938c3c055e5c"}} +{"type":"command.receipt","at":"2026-08-04T01:11:33.227Z","tick":3852,"actorId":"dev-4","commandId":1615289,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615289,"issued_at_tick":3852,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"00f09ad9fbc110f1e09c2a67631d7617401ecbf2c7961e8551a3782565044f8b"}} +{"type":"command.receipt","at":"2026-08-04T01:11:33.427Z","tick":3858,"actorId":"dev-4","commandId":1615291,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615291,"issued_at_tick":3858,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f3e67932daf38ab49342228137ee64bea5399a0889839851433a05b570fbd365"}} +{"type":"command.receipt","at":"2026-08-04T01:11:33.627Z","tick":3864,"actorId":"dev-4","commandId":1615293,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615293,"issued_at_tick":3864,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"df916a7a5b9a04d902d91d22af918e03f7074ca87a1e04f8f5ad1cba9ddefe1f"}} +{"type":"command.receipt","at":"2026-08-04T01:11:33.826Z","tick":3870,"actorId":"dev-4","commandId":1615295,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615295,"issued_at_tick":3870,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"c9bcea53221dd477f061e0ae39c451c03d29ca88a344eade538210f021d7471c"}} +{"type":"command.receipt","at":"2026-08-04T01:11:34.027Z","tick":3876,"actorId":"dev-4","commandId":1615297,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615297,"issued_at_tick":3876,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"a216256ff3f71d2179d6725cbf68353d56b56be9087d8671887b13f0bba4a6ce"}} +{"type":"command.receipt","at":"2026-08-04T01:11:34.227Z","tick":3882,"actorId":"dev-4","commandId":1615299,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615299,"issued_at_tick":3882,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"1282370894d99309076553fdf699c1818eaf3eaee272df7f7d235020868cc917"}} +{"type":"command.receipt","at":"2026-08-04T01:11:34.427Z","tick":3888,"actorId":"dev-4","commandId":1615301,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615301,"issued_at_tick":3888,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"a5f7eac25b7e0916d6aa35e9d16de11bd5d171dde103c76c5d8270f4a0a1b9c9"}} +{"type":"command.receipt","at":"2026-08-04T01:11:34.628Z","tick":3894,"actorId":"dev-4","commandId":1615303,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615303,"issued_at_tick":3894,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"40b04fbb34dd2a2ca0dafc9e450a394067e2f0e70541a0aa2a027e47fcc02d18"}} +{"type":"command.receipt","at":"2026-08-04T01:11:34.827Z","tick":3900,"actorId":"dev-4","commandId":1615305,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615305,"issued_at_tick":3900,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"0890798ea6fd4eb88420bb355bde7e650365df26ad4482e687761a55a28d8783"}} +{"type":"command.receipt","at":"2026-08-04T01:11:35.028Z","tick":3906,"actorId":"dev-4","commandId":1615307,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615307,"issued_at_tick":3906,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"290399849ceac9edd3c95bb26e94892d68821326951943e52ce4bbb895fe257e"}} +{"type":"command.receipt","at":"2026-08-04T01:11:35.228Z","tick":3912,"actorId":"dev-4","commandId":1615309,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615309,"issued_at_tick":3912,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f928404da5d53e301fac9365a2751a46e157d7f222370fafc2aa3939501654ce"}} +{"type":"command.receipt","at":"2026-08-04T01:11:35.427Z","tick":3918,"actorId":"dev-4","commandId":1615311,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615311,"issued_at_tick":3918,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f4e770da8ceb28a4d2ab9dca78d8b1321a5574adb13b9cae002b4247e8a05a8f"}} +{"type":"command.receipt","at":"2026-08-04T01:11:35.631Z","tick":3924,"actorId":"dev-4","commandId":1615313,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615313,"issued_at_tick":3924,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"161a1acec3761dd7eae0b5323941c616924e83c2e4fc2e55d6c02ccf61b08903"}} +{"type":"command.receipt","at":"2026-08-04T01:11:35.839Z","tick":3930,"actorId":"dev-4","commandId":1615315,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615315,"issued_at_tick":3930,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"7c088a12de15bf94631c4c5d4a25aab1b93c7652ec4e14861d7a6c86c645cab4"}} +{"type":"command.receipt","at":"2026-08-04T01:11:36.036Z","tick":3936,"actorId":"dev-4","commandId":1615317,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615317,"issued_at_tick":3936,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"974d66f1a3e3d1e64bdf81ee55d69334c74fcf21154defc0777b80a69b20ffba"}} +{"type":"checkpoint","at":"2026-08-04T01:11:36.090Z","tick":3938,"reason":"interval","actorCount":2,"stateHash":"75df6c377222c7d5e929a3cf98d5a8ea0e8d30079f8ccff3b9bd0b6d4e8bbf74"} +{"type":"command.receipt","at":"2026-08-04T01:11:36.235Z","tick":3942,"actorId":"dev-4","commandId":1615319,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615319,"issued_at_tick":3942,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"99c2d9ae5857926be24d91741b7b97e7f093092b1d1033dadde89f6d88dcbc9c"}} +{"type":"command.receipt","at":"2026-08-04T01:11:36.435Z","tick":3948,"actorId":"dev-4","commandId":1615321,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615321,"issued_at_tick":3948,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"c3e85cf3fb2ac0f4c18ddb2f94cf49b768a22644bda48eda9cb536720cff1fcd"}} +{"type":"command.receipt","at":"2026-08-04T01:11:36.635Z","tick":3954,"actorId":"dev-4","commandId":1615323,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615323,"issued_at_tick":3954,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"b5b556a406f23ce57a7bfda560131e0f9faa71c8b66a9dd6bffa5c429110ef82"}} +{"type":"command.receipt","at":"2026-08-04T01:11:36.836Z","tick":3960,"actorId":"dev-4","commandId":1615325,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615325,"issued_at_tick":3960,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"d696fdd09b3202c3d81e2c5e8ca9aced48aac44d33b96543c64ed110c17e8a62"}} +{"type":"command.receipt","at":"2026-08-04T01:11:37.035Z","tick":3966,"actorId":"dev-4","commandId":1615327,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615327,"issued_at_tick":3966,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"be43278ab42444dfc3adc9947c3f4341d681885d33898844399204b004d5217e"}} +{"type":"command.receipt","at":"2026-08-04T01:11:37.235Z","tick":3972,"actorId":"dev-4","commandId":1615329,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615329,"issued_at_tick":3972,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f21142241405aea4e8d43f88961e1abe1f1ee543960b6284f486dd63c6693b74"}} +{"type":"command.receipt","at":"2026-08-04T01:11:37.435Z","tick":3978,"actorId":"dev-4","commandId":1615331,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615331,"issued_at_tick":3978,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"bff5a6ec70f754262ff9bd302fd531b1763f384c473a5fcdc300ffe7c088ed0c"}} +{"type":"command.receipt","at":"2026-08-04T01:11:37.635Z","tick":3984,"actorId":"dev-4","commandId":1615333,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615333,"issued_at_tick":3984,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"0a333a15cfc389096e1db8d89fd3b99fa8e80eb43d837dacfaf46ae35b529b46"}} +{"type":"command.receipt","at":"2026-08-04T01:11:37.835Z","tick":3990,"actorId":"dev-4","commandId":1615335,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615335,"issued_at_tick":3990,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f362f872ece7891e0280718138a1f573e0a55c2811fc22d219bf053dc49a155e"}} +{"type":"command.receipt","at":"2026-08-04T01:11:38.035Z","tick":3996,"actorId":"dev-4","commandId":1615337,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615337,"issued_at_tick":3996,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"4b5f3ff5b7f1653fe9f3236ec0317945b4d76289e5ead0c98b955ed623086014"}} +{"type":"command.receipt","at":"2026-08-04T01:11:38.228Z","tick":4002,"actorId":"dev-4","commandId":1615339,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615339,"issued_at_tick":4002,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"69c82b6aae5dc922605108741512a81aa5d835dc1d5988201edcf6685318c3de"}} +{"type":"command.receipt","at":"2026-08-04T01:11:38.427Z","tick":4008,"actorId":"dev-4","commandId":1615341,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615341,"issued_at_tick":4008,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"3ff8aa897d975ce6e26055eebb9dff39adddf60c592e015b78bd4015f341c885"}} +{"type":"command.receipt","at":"2026-08-04T01:11:38.627Z","tick":4014,"actorId":"dev-4","commandId":1615343,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615343,"issued_at_tick":4014,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"713881a0763eb1f81e5c142961719faab0228a572c07b6ae09ac1cedf90b1a85"}} +{"type":"command.receipt","at":"2026-08-04T01:11:38.828Z","tick":4020,"actorId":"dev-4","commandId":1615345,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615345,"issued_at_tick":4020,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"809924f3122509f42f495a56cf5ade67ec09d8e70c805045eff4f5d854d98f82"}} +{"type":"command.receipt","at":"2026-08-04T01:11:39.026Z","tick":4026,"actorId":"dev-4","commandId":1615347,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615347,"issued_at_tick":4026,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"faaf63592fa51448153b8da8e5246c3e14450db59e864f1e9149336c72596e37"}} +{"type":"command.receipt","at":"2026-08-04T01:11:39.227Z","tick":4032,"actorId":"dev-4","commandId":1615349,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615349,"issued_at_tick":4032,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"bcebf6dd617681e209777b646ce3a6c41628b5af3c0fa3ba13a379de5f1ed42a"}} +{"type":"command.receipt","at":"2026-08-04T01:11:39.427Z","tick":4038,"actorId":"dev-4","commandId":1615351,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615351,"issued_at_tick":4038,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"57a538a61eae46fe0cbe46e38881989248fd7254bc8daafe2bdb6904178c16e9"}} +{"type":"command.receipt","at":"2026-08-04T01:11:39.627Z","tick":4044,"actorId":"dev-4","commandId":1615353,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615353,"issued_at_tick":4044,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"c0555574e7bf3132508dc869b94692ad83cf4f547a50437a61f6aa490998da2e"}} +{"type":"command.receipt","at":"2026-08-04T01:11:39.827Z","tick":4050,"actorId":"dev-4","commandId":1615355,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615355,"issued_at_tick":4050,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"958bd661935ae2bfc6c75f3369bbb94014a569daea1b28657151cd89edb5d7d3"}} +{"type":"command.receipt","at":"2026-08-04T01:11:40.028Z","tick":4056,"actorId":"dev-4","commandId":1615357,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615357,"issued_at_tick":4056,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"0fe90bf37477843c05eb46cf45ad970893e280fd1b9acd117503aef85b099929"}} +{"type":"command.receipt","at":"2026-08-04T01:11:40.226Z","tick":4062,"actorId":"dev-4","commandId":1615359,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615359,"issued_at_tick":4062,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f79f49e55e516b02f8e4278ecae5c5c6cc865091011373bd0e0c49bd752ce738"}} +{"type":"command.receipt","at":"2026-08-04T01:11:40.429Z","tick":4068,"actorId":"dev-4","commandId":1615361,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615361,"issued_at_tick":4068,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"8a8b0c8115d54d327e24999ca07910e32261e79b325482bf0b35b9574a3e79f0"}} +{"type":"command.receipt","at":"2026-08-04T01:11:40.628Z","tick":4074,"actorId":"dev-4","commandId":1615363,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615363,"issued_at_tick":4074,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"7fd4f51d8802f8646369319c4faa851c3093fedd16b7d600cddfd47aff87dca7"}} +{"type":"command.receipt","at":"2026-08-04T01:11:40.828Z","tick":4080,"actorId":"dev-4","commandId":1615365,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615365,"issued_at_tick":4080,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"0a910f27fcf8dcaa563cf7e0023f39b6df1389ddcf9288b680988ecab481262a"}} +{"type":"command.receipt","at":"2026-08-04T01:11:41.028Z","tick":4086,"actorId":"dev-4","commandId":1615367,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615367,"issued_at_tick":4086,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"eaf4cdec9e1448995ddb0e74ed5bac37d3700fe53dbb664a21ade625575a226c"}} +{"type":"checkpoint","at":"2026-08-04T01:11:41.109Z","tick":4088,"reason":"interval","actorCount":2,"stateHash":"66a5c1669044e35c89e1c3c35901d739f8591a92b388cda4074ac5a5040ef732"} +{"type":"command.receipt","at":"2026-08-04T01:11:41.227Z","tick":4092,"actorId":"dev-4","commandId":1615369,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615369,"issued_at_tick":4092,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"3208ab593db151f7f3948f860c9606ac73653a9fc7069c9759755ac204f10856"}} +{"type":"command.receipt","at":"2026-08-04T01:11:41.426Z","tick":4098,"actorId":"dev-4","commandId":1615371,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615371,"issued_at_tick":4098,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f33c2288b604d811329bc0a7ea5bef48d8f13d99eab00d4103c7ab6c008c614b"}} +{"type":"command.receipt","at":"2026-08-04T01:11:41.627Z","tick":4104,"actorId":"dev-4","commandId":1615373,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615373,"issued_at_tick":4104,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"06106d76f30f557f2ecd7c04935a107382c21f28fbcacd5c82f44a84f87730eb"}} +{"type":"command.receipt","at":"2026-08-04T01:11:41.826Z","tick":4110,"actorId":"dev-4","commandId":1615375,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615375,"issued_at_tick":4110,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"2c01ed025e55cbb4d43ecbfa5c6214925789cf786bf5289011be069b9cf52b72"}} +{"type":"command.receipt","at":"2026-08-04T01:11:42.027Z","tick":4116,"actorId":"dev-4","commandId":1615377,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615377,"issued_at_tick":4116,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"11483c033949586c1423c79a6a62152ccb3e6600f3ccbd20eadfdeb6c5d2267f"}} +{"type":"command.receipt","at":"2026-08-04T01:11:42.228Z","tick":4122,"actorId":"dev-4","commandId":1615379,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615379,"issued_at_tick":4122,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"8ce78af694a7ac60622792e04622e5427560b760124a051d54f21c6ce710e4b1"}} +{"type":"command.receipt","at":"2026-08-04T01:11:42.428Z","tick":4128,"actorId":"dev-4","commandId":1615381,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615381,"issued_at_tick":4128,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"3ff67d49ea161090e8570b184bbdb2190ce15c04c85b6178c9b4b986ad24891c"}} +{"type":"command.receipt","at":"2026-08-04T01:11:42.626Z","tick":4134,"actorId":"dev-4","commandId":1615383,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615383,"issued_at_tick":4134,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"5ae6122d62c46013e1a5e2ce02ad3b48d4ac4e0da23bc8a01e000be0cfb15020"}} +{"type":"command.receipt","at":"2026-08-04T01:11:42.817Z","tick":4140,"actorId":"dev-4","commandId":1615385,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615385,"issued_at_tick":4140,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"8b7b24ee9215ce66ce8349b37428b4ec59a50e524c809e1419dd9111d4919dd8"}} +{"type":"command.receipt","at":"2026-08-04T01:11:42.997Z","tick":4145,"actorId":"dev-4","commandId":1615387,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615387,"issued_at_tick":4145,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"34abfdcda73caf33482f26cee9cb98281331f81baaa4b8285b7ba938a568ffb3"}} +{"type":"command.receipt","at":"2026-08-04T01:11:43.270Z","tick":4153,"actorId":"dev-4","commandId":1615390,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615390,"issued_at_tick":4153,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f72fc0006f45679a5947733ae1f58118f92a1cf05c4cd8058bdb2addf7784888"}} +{"type":"command.receipt","at":"2026-08-04T01:11:43.462Z","tick":4159,"actorId":"dev-4","commandId":1615392,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615392,"issued_at_tick":4159,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"919912be9265d540eef0c16a6630ba918231f9cd7afef9b673b1be9a81ab8d53"}} +{"type":"command.receipt","at":"2026-08-04T01:11:43.644Z","tick":4164,"actorId":"dev-4","commandId":1615394,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615394,"issued_at_tick":4164,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"da4bfee421c0ad86a66694597b2235922646062ab1f33f18edf14f9c4c385f41"}} +{"type":"command.receipt","at":"2026-08-04T01:11:43.839Z","tick":4170,"actorId":"dev-4","commandId":1615396,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615396,"issued_at_tick":4170,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"627ee5e87f4669123044785a6608c85016fcf2607d5ae689b9ece31877d63f6e"}} +{"type":"command.receipt","at":"2026-08-04T01:11:44.026Z","tick":4176,"actorId":"dev-4","commandId":1615398,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615398,"issued_at_tick":4176,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"0c8c1f9f9733721571f0f450ed0ab728513684f47814c9db414e46991b315058"}} +{"type":"command.receipt","at":"2026-08-04T01:11:44.219Z","tick":4182,"actorId":"dev-4","commandId":1615400,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615400,"issued_at_tick":4182,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"86ccb1e085543b8cc408d14670355014c3051ba3a4ba50f036c325c02697e465"}} +{"type":"command.receipt","at":"2026-08-04T01:11:44.404Z","tick":4187,"actorId":"dev-4","commandId":1615402,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615402,"issued_at_tick":4187,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"47197ca56923395df2befb584e5feb244342b292533adecd8203a2bebbccfd48"}} +{"type":"command.receipt","at":"2026-08-04T01:11:44.602Z","tick":4193,"actorId":"dev-4","commandId":1615404,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615404,"issued_at_tick":4193,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"8410427377174e025a00e90ceb612ce23dc798456b4dc7dbbfcf7181466c3c7f"}} +{"type":"command.receipt","at":"2026-08-04T01:11:44.801Z","tick":4199,"actorId":"dev-4","commandId":1615406,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615406,"issued_at_tick":4199,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"915d047a3956e16e5b3162a6674549de581251f6a4d685af988e491db3ebd84c"}} +{"type":"command.receipt","at":"2026-08-04T01:11:45.001Z","tick":4205,"actorId":"dev-4","commandId":1615408,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615408,"issued_at_tick":4205,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"13ee61aca79e7a634f748e44d0a23b22497db875dc7a6252d2b31013a8c0bf07"}} +{"type":"command.receipt","at":"2026-08-04T01:11:45.202Z","tick":4211,"actorId":"dev-4","commandId":1615410,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615410,"issued_at_tick":4211,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"a6f6be8c7bd585a36a81e54202ef2ec8854f6d9f6780ce2d62d01478671faa26"}} +{"type":"command.receipt","at":"2026-08-04T01:11:45.402Z","tick":4217,"actorId":"dev-4","commandId":1615412,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615412,"issued_at_tick":4217,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"6b0d7276c4eba1cd9c3e22ed636199853ed39d9c94158d134d5297a4f5c537c9"}} +{"type":"command.receipt","at":"2026-08-04T01:11:45.602Z","tick":4223,"actorId":"dev-4","commandId":1615414,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615414,"issued_at_tick":4223,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"15189223c9b178f845694b857093fe9b7c8fd8ce8dd8fd681ad0066fcfdfe632"}} +{"type":"command.receipt","at":"2026-08-04T01:11:45.801Z","tick":4229,"actorId":"dev-4","commandId":1615416,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615416,"issued_at_tick":4229,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"c07ba94a222c14b96b9166c4d2076da41abd86ee93ba27c250beb12b5d0907ae"}} +{"type":"command.receipt","at":"2026-08-04T01:11:46.002Z","tick":4235,"actorId":"dev-4","commandId":1615418,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615418,"issued_at_tick":4235,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"53d7fc9da88df80128359c8f2c915562e2f1525155bdbb8e33a300ac3705063a"}} +{"type":"checkpoint","at":"2026-08-04T01:11:46.112Z","tick":4239,"reason":"interval","actorCount":2,"stateHash":"7f7ea82b9d8c78a51577d1db01d75cc15a4d69c14b0379e163af367e152e131f"} +{"type":"command.receipt","at":"2026-08-04T01:11:46.202Z","tick":4241,"actorId":"dev-4","commandId":1615420,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615420,"issued_at_tick":4241,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"902d298b1f4fa2c0d2e68e509497a6921adacdd16b84cb391a9d3b8f2e604dab"}} +{"type":"command.receipt","at":"2026-08-04T01:11:46.401Z","tick":4247,"actorId":"dev-4","commandId":1615422,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615422,"issued_at_tick":4247,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"0f39b8dd211a4e7da631f84c52d1be19f6530af29e1b288a89efc1f9f82c6701"}} +{"type":"command.receipt","at":"2026-08-04T01:11:46.602Z","tick":4253,"actorId":"dev-4","commandId":1615424,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615424,"issued_at_tick":4253,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"3cce4ef5d606955a4e46efb52a811aec5e9791c88bf0438113d7673b84d4e6fe"}} +{"type":"command.receipt","at":"2026-08-04T01:11:46.802Z","tick":4259,"actorId":"dev-4","commandId":1615426,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615426,"issued_at_tick":4259,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"b4d0ddb7122d6fc480bae453134e8fa6719f145b29463ff36601481b8e71749e"}} +{"type":"command.receipt","at":"2026-08-04T01:11:47.002Z","tick":4265,"actorId":"dev-4","commandId":1615428,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615428,"issued_at_tick":4265,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"903d5a150bdeee9f41d55db98c51056907a48a1460d0f8e14b6b26069d6dbc95"}} +{"type":"command.receipt","at":"2026-08-04T01:11:47.202Z","tick":4271,"actorId":"dev-4","commandId":1615430,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615430,"issued_at_tick":4271,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"cbdd855df3fb247d91561829eb81bb923bfcc68f97dbed11599b8eaa205d74e1"}} +{"type":"command.receipt","at":"2026-08-04T01:11:47.403Z","tick":4277,"actorId":"dev-4","commandId":1615432,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615432,"issued_at_tick":4277,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"368f9f075738925605011a587ecc8fd80418d1fd8b07b26b95e978aba25d3034"}} +{"type":"command.receipt","at":"2026-08-04T01:11:47.612Z","tick":4283,"actorId":"dev-4","commandId":1615434,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615434,"issued_at_tick":4283,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"75f169b93f84f5aea550f2291b7218d92ff4385257a667ecf26786654ccc2b4f"}} +{"type":"command.receipt","at":"2026-08-04T01:11:47.809Z","tick":4289,"actorId":"dev-4","commandId":1615436,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615436,"issued_at_tick":4289,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"108c69edc59aca707feb1ba30362ef76f4c8c14a9026bc2195f7f352196a3867"}} +{"type":"command.receipt","at":"2026-08-04T01:11:48.009Z","tick":4295,"actorId":"dev-4","commandId":1615438,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615438,"issued_at_tick":4295,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"49f26c1ff1427b03b57a471f91de3bc0b4181a2a327ad1afe4ed8b86c5b4b901"}} +{"type":"command.receipt","at":"2026-08-04T01:11:48.210Z","tick":4301,"actorId":"dev-4","commandId":1615440,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615440,"issued_at_tick":4301,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"88ab7cd50c047a24548da2ec7f615f5dd6e06891b32fe75b5a324ee9dd538668"}} +{"type":"command.receipt","at":"2026-08-04T01:11:48.410Z","tick":4307,"actorId":"dev-4","commandId":1615442,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615442,"issued_at_tick":4307,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"96159b50d9d48d05e37b17fc4349714cb5860cdd1aad003da63aac1e66f0ac81"}} +{"type":"command.receipt","at":"2026-08-04T01:11:48.610Z","tick":4313,"actorId":"dev-4","commandId":1615444,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615444,"issued_at_tick":4313,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"b0b72b603884173dd765da2da3323d1dea6ac967adf9b8f05ac7ead83a0d0783"}} +{"type":"command.receipt","at":"2026-08-04T01:11:48.809Z","tick":4319,"actorId":"dev-4","commandId":1615446,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615446,"issued_at_tick":4319,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"8092875c91f4877e27f4b05542f504290a72ce39cd4db38eee94cd97b518f0aa"}} +{"type":"command.receipt","at":"2026-08-04T01:11:49.010Z","tick":4325,"actorId":"dev-4","commandId":1615448,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615448,"issued_at_tick":4325,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"cdf9eae6f04d8e41edf1fe081eea9a1d8bc18f58ac6bae08ea44cb99f77fe9ee"}} +{"type":"command.receipt","at":"2026-08-04T01:11:49.210Z","tick":4331,"actorId":"dev-4","commandId":1615450,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615450,"issued_at_tick":4331,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"c87889cafa7ce9c3feeb26b90c4854ef8e24eac2da530c7022652446e1044101"}} +{"type":"command.receipt","at":"2026-08-04T01:11:49.410Z","tick":4337,"actorId":"dev-4","commandId":1615452,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615452,"issued_at_tick":4337,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"27281fec0e2df98785ddfa1e32fb14c706ff26fed7b683ab79e05a8311006ad8"}} +{"type":"command.receipt","at":"2026-08-04T01:11:49.610Z","tick":4343,"actorId":"dev-4","commandId":1615454,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615454,"issued_at_tick":4343,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"9303cd0cd51b33392b7008bbd7263fbdd51f3ce569b2345920a1667970727cba"}} +{"type":"command.receipt","at":"2026-08-04T01:11:49.809Z","tick":4349,"actorId":"dev-4","commandId":1615456,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615456,"issued_at_tick":4349,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"816ecf625abde98f0da3f87b93a10e50b5536189b848c69661dd6aac51fb7b7d"}} +{"type":"command.receipt","at":"2026-08-04T01:11:50.011Z","tick":4355,"actorId":"dev-4","commandId":1615458,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615458,"issued_at_tick":4355,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"fdb19132c0a418f7f9428beb82861772f45a1d6ff845f69d926b3f43200190ca"}} +{"type":"command.receipt","at":"2026-08-04T01:11:50.210Z","tick":4361,"actorId":"dev-4","commandId":1615460,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615460,"issued_at_tick":4361,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"ddf80bbb61a87a9e7285e702ae8a2f7c0893db26d46253bd907fd905afc7e148"}} +{"type":"command.receipt","at":"2026-08-04T01:11:50.410Z","tick":4367,"actorId":"dev-4","commandId":1615462,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615462,"issued_at_tick":4367,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"dd94db3fdd61d71578dd1c326e8aeae24e76a08b7c60fda1563b971fe333f4d5"}} +{"type":"command.receipt","at":"2026-08-04T01:11:50.611Z","tick":4373,"actorId":"dev-4","commandId":1615464,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615464,"issued_at_tick":4373,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"941bef875bbe7daa850f262c19b33be0ed7bc3614f8ec0a9e2f5bb8282b76352"}} +{"type":"command.receipt","at":"2026-08-04T01:11:50.809Z","tick":4379,"actorId":"dev-4","commandId":1615466,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615466,"issued_at_tick":4379,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f19a5a1ab8c3bb888fbc2e3a1c05d3f1591e2c70ced9748788931d229226a8c7"}} +{"type":"command.receipt","at":"2026-08-04T01:11:51.012Z","tick":4385,"actorId":"dev-4","commandId":1615468,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615468,"issued_at_tick":4385,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"ca7acaa9310729af966489d240906214884f54ad8e1c42ef605c102eb128ea21"}} +{"type":"checkpoint","at":"2026-08-04T01:11:51.116Z","tick":4389,"reason":"interval","actorCount":2,"stateHash":"c3f84ec02b1fab236d3f389d4a2301b8f36cf44a38392b1147223d6cf12153f9"} +{"type":"command.receipt","at":"2026-08-04T01:11:51.211Z","tick":4391,"actorId":"dev-4","commandId":1615470,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615470,"issued_at_tick":4391,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"b951f47f17e7e34dcc677648273bbbebeda5b7fe5e607f36a403b6f56265fbf1"}} +{"type":"command.receipt","at":"2026-08-04T01:11:51.411Z","tick":4397,"actorId":"dev-4","commandId":1615472,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615472,"issued_at_tick":4397,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"2481d2067a928b6de6eeb3d855e9f3bb3804bfbe9cdaee63c0d54d71ab3bbcfb"}} +{"type":"command.receipt","at":"2026-08-04T01:11:51.610Z","tick":4403,"actorId":"dev-4","commandId":1615474,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615474,"issued_at_tick":4403,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"49b6d374a8393ac8d68edc00d0d601fe7c2ab1444a02a950e1086f579ea2ca9e"}} +{"type":"command.receipt","at":"2026-08-04T01:11:51.810Z","tick":4409,"actorId":"dev-4","commandId":1615476,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615476,"issued_at_tick":4409,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"57030476e40ee5d78c474887e7629d384290b6e0469935c99dc52bd6521cfbc6"}} +{"type":"command.receipt","at":"2026-08-04T01:11:52.011Z","tick":4415,"actorId":"dev-4","commandId":1615478,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615478,"issued_at_tick":4415,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f08d2a0fbf34bc6e42ae5d35ad4b403c3789a238a2357ce383b0a712f4a81799"}} +{"type":"command.receipt","at":"2026-08-04T01:11:52.212Z","tick":4421,"actorId":"dev-4","commandId":1615480,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615480,"issued_at_tick":4421,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"ceef97093de446dae77bec1344131376fd47a5589836a58ecc16eab0091318b4"}} +{"type":"command.receipt","at":"2026-08-04T01:11:52.410Z","tick":4427,"actorId":"dev-4","commandId":1615482,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615482,"issued_at_tick":4427,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"bdb544d68634f862bcd333670f82992280982ab18fd0955dc004e185e515e5dd"}} +{"type":"command.receipt","at":"2026-08-04T01:11:52.611Z","tick":4433,"actorId":"dev-4","commandId":1615484,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615484,"issued_at_tick":4433,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f2478f896263520c63b06b39fe9a9ddcecd0f09f6e2d4555d0f82fcd4bd967af"}} +{"type":"command.receipt","at":"2026-08-04T01:11:52.811Z","tick":4439,"actorId":"dev-4","commandId":1615486,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615486,"issued_at_tick":4439,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"1b6aa77c5f777542c54fb1795b59901735a0d3c151208c2d104df1e649809857"}} +{"type":"command.receipt","at":"2026-08-04T01:11:53.010Z","tick":4445,"actorId":"dev-4","commandId":1615488,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615488,"issued_at_tick":4445,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"64dc08c124263d1f02f9891ddce85bcd91db31b8b92257396eee953981b1a42c"}} +{"type":"command.receipt","at":"2026-08-04T01:11:53.211Z","tick":4451,"actorId":"dev-4","commandId":1615490,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615490,"issued_at_tick":4451,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"6551ee4435f2091851fe3c30daa8491aec57ae109e315f1e4c440415f781eff5"}} +{"type":"command.receipt","at":"2026-08-04T01:11:53.411Z","tick":4457,"actorId":"dev-4","commandId":1615492,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615492,"issued_at_tick":4457,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"37865dcb853a230c6916694effda90b8b861212699bea24d8f0d94dd193c56b7"}} +{"type":"command.receipt","at":"2026-08-04T01:11:53.611Z","tick":4463,"actorId":"dev-4","commandId":1615494,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615494,"issued_at_tick":4463,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"7c4fbb49b0676552ee243cea5cc503cda8445cf4a2b897b6d233e82c56f73a33"}} +{"type":"command.receipt","at":"2026-08-04T01:11:53.812Z","tick":4469,"actorId":"dev-4","commandId":1615496,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615496,"issued_at_tick":4469,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"bd9100db2eb865e20b4d03e2868964e6831acef2c860d7d3aec1b0b078b9b5fb"}} +{"type":"command.receipt","at":"2026-08-04T01:11:54.012Z","tick":4475,"actorId":"dev-4","commandId":1615498,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615498,"issued_at_tick":4475,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"a262f3e272fe22f5e7197b2c5befe75956f809172df0d7a2e4b81626738be852"}} +{"type":"command.receipt","at":"2026-08-04T01:11:54.211Z","tick":4481,"actorId":"dev-4","commandId":1615500,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615500,"issued_at_tick":4481,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"c6812390f9686b7defe1d03cdb71a19f6e793ede0fb1b81e2a1c09cfc6cd9553"}} +{"type":"command.receipt","at":"2026-08-04T01:11:54.412Z","tick":4487,"actorId":"dev-4","commandId":1615502,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615502,"issued_at_tick":4487,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"49cdcbf2837c361f4c23d893373afd2784a1811c2607d96391a29f9f2439f3a7"}} +{"type":"command.receipt","at":"2026-08-04T01:11:54.611Z","tick":4493,"actorId":"dev-4","commandId":1615504,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615504,"issued_at_tick":4493,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"eb1aa46359a66495bb618d33aafcec6b26a8e6141443edb0432db0158a0ec4f5"}} +{"type":"command.receipt","at":"2026-08-04T01:11:54.811Z","tick":4499,"actorId":"dev-4","commandId":1615506,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615506,"issued_at_tick":4499,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"8fdea75d299e2dea723f7ec7bfad35c30c06830f520acefee975414f66aec601"}} +{"type":"command.receipt","at":"2026-08-04T01:11:55.011Z","tick":4505,"actorId":"dev-4","commandId":1615508,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615508,"issued_at_tick":4505,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"7f453111a2a7cf6da05e43685084ff9f4ac863ea717cc9bb6700029ea1881216"}} +{"type":"command.receipt","at":"2026-08-04T01:11:55.211Z","tick":4511,"actorId":"dev-4","commandId":1615510,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615510,"issued_at_tick":4511,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"8b4f7c042239d03830ec61bc568e9133b5ea0daea9814dfcb6dfe0a075891c9a"}} +{"type":"command.receipt","at":"2026-08-04T01:11:55.410Z","tick":4517,"actorId":"dev-4","commandId":1615512,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615512,"issued_at_tick":4517,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"4c7274ba5dcc48d9002f355a8205313baf9cc6d6ac9eb697f03b33cbdf565773"}} +{"type":"command.receipt","at":"2026-08-04T01:11:55.611Z","tick":4523,"actorId":"dev-4","commandId":1615514,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615514,"issued_at_tick":4523,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"16662e89952381748628f9caa6959acdf3d2ad6cd5b66387853fd80c3e0b2d41"}} +{"type":"command.receipt","at":"2026-08-04T01:11:55.812Z","tick":4529,"actorId":"dev-4","commandId":1615516,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615516,"issued_at_tick":4529,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"ce30e4634a5601eeaa04a390a118dacc429cf544a224026f3899afe0aa801c05"}} +{"type":"command.receipt","at":"2026-08-04T01:11:56.010Z","tick":4535,"actorId":"dev-4","commandId":1615518,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615518,"issued_at_tick":4535,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"78c393a6cd41a41701af9aab0f7774f2ea2baf0c0c56cddcac53bf2a80bb72d0"}} +{"type":"checkpoint","at":"2026-08-04T01:11:56.131Z","tick":4539,"reason":"interval","actorCount":2,"stateHash":"f6452427770642fc8f8d953880740fada3a33652c8fde18d509c3e4a4761f51e"} +{"type":"command.receipt","at":"2026-08-04T01:11:56.211Z","tick":4541,"actorId":"dev-4","commandId":1615520,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615520,"issued_at_tick":4541,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"c16329382f878ddbe76e450cd50260eb18f9cf5d8f8860330277ae5da7ec1487"}} +{"type":"command.receipt","at":"2026-08-04T01:11:56.411Z","tick":4547,"actorId":"dev-4","commandId":1615522,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615522,"issued_at_tick":4547,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"7364bdd24fd69ad8e53c3e21d551602995f53e17499beea29f68f6755ace8be2"}} +{"type":"command.receipt","at":"2026-08-04T01:11:56.613Z","tick":4553,"actorId":"dev-4","commandId":1615524,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615524,"issued_at_tick":4553,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"911dca7a5962782bd683b662afd86ecc46a90e28f65ddcd5f27c86256eb2aaf1"}} +{"type":"command.receipt","at":"2026-08-04T01:11:56.814Z","tick":4560,"actorId":"dev-4","commandId":1615526,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615526,"issued_at_tick":4559,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"77cd59e0bf461c6213dc95e38e5282b07e94a83e62af92deb568cccdacdabd2a"}} +{"type":"command.receipt","at":"2026-08-04T01:11:57.010Z","tick":4565,"actorId":"dev-4","commandId":1615528,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615528,"issued_at_tick":4565,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"ca59a8aad36935107d0f32afd8c0a5203455b123ef3631bc1a2f0746849c95e4"}} +{"type":"command.receipt","at":"2026-08-04T01:11:57.211Z","tick":4571,"actorId":"dev-4","commandId":1615530,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615530,"issued_at_tick":4571,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"ee6891b55e76d5cbd018ce1509bf40811d483a45cd53b93b4de36391f628d4d1"}} +{"type":"command.receipt","at":"2026-08-04T01:11:57.411Z","tick":4577,"actorId":"dev-4","commandId":1615532,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615532,"issued_at_tick":4577,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"5eb0bd4fbd7a25fddc0b1b519c3a9d1abfa8f20983d428b05dc078cde46d1cba"}} +{"type":"command.receipt","at":"2026-08-04T01:11:57.610Z","tick":4583,"actorId":"dev-4","commandId":1615534,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615534,"issued_at_tick":4583,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"a60ae1168b2ac2bbef148033206285b6b1a7b64e3d42e0257f1107fbae443b81"}} +{"type":"command.receipt","at":"2026-08-04T01:11:57.810Z","tick":4589,"actorId":"dev-4","commandId":1615536,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615536,"issued_at_tick":4589,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"b7ddeadb8cc19609fc30da71b5299b89f9a3c216d63eeb16b32422c08bc8dc5d"}} +{"type":"command.receipt","at":"2026-08-04T01:11:58.009Z","tick":4595,"actorId":"dev-4","commandId":1615538,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615538,"issued_at_tick":4595,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"e7246e280a5dda34711a14638fea577505213debb0e11947536fd9d4470ac73c"}} +{"type":"command.receipt","at":"2026-08-04T01:11:58.211Z","tick":4601,"actorId":"dev-4","commandId":1615540,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615540,"issued_at_tick":4601,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"4d7e6d82b80b23d4cf10afdadcaf70160d3cfed5ec728b38c223744088a097c5"}} +{"type":"command.receipt","at":"2026-08-04T01:11:58.412Z","tick":4607,"actorId":"dev-4","commandId":1615542,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615542,"issued_at_tick":4607,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"5381626db6483c8cbf30ecf449f4020102629cab019a6a2d412446f41640d35b"}} +{"type":"command.receipt","at":"2026-08-04T01:11:58.612Z","tick":4613,"actorId":"dev-4","commandId":1615544,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615544,"issued_at_tick":4613,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"4d1b0a216a48fe32151912caed0ae148a6c3d4836a790aef46d17578c3c60e28"}} +{"type":"command.receipt","at":"2026-08-04T01:11:58.810Z","tick":4619,"actorId":"dev-4","commandId":1615546,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615546,"issued_at_tick":4619,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f7fdbb145c77ceba8acb900a161ab7992361157a6d4812525c367e734b882ca5"}} +{"type":"command.receipt","at":"2026-08-04T01:11:59.011Z","tick":4625,"actorId":"dev-4","commandId":1615548,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615548,"issued_at_tick":4625,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"1aab67c3e51f3b5017c9092f9fa4f098c8e568fc02aea64d640e198d7c91b829"}} +{"type":"command.receipt","at":"2026-08-04T01:11:59.211Z","tick":4631,"actorId":"dev-4","commandId":1615550,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615550,"issued_at_tick":4631,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"151c897aef3225667e3dec923f443d304417b4415ba3293ab88f035e4e9dcf4c"}} +{"type":"command.receipt","at":"2026-08-04T01:11:59.411Z","tick":4637,"actorId":"dev-4","commandId":1615552,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615552,"issued_at_tick":4637,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"562ab87919320cefa5ddfa0687842f5c0fe07dd9798a5d6f1e28ecc026a0cc93"}} +{"type":"command.receipt","at":"2026-08-04T01:11:59.610Z","tick":4643,"actorId":"dev-4","commandId":1615554,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615554,"issued_at_tick":4643,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"7da9a614499cc73360c9c048cc53cff689810a0e694ce9a3ff0c943fe4fa1be9"}} +{"type":"command.receipt","at":"2026-08-04T01:11:59.811Z","tick":4649,"actorId":"dev-4","commandId":1615556,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615556,"issued_at_tick":4649,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"3eac6af8425223af3c4846b1becc9083538dfb9a8f531f43e592703cc3e16fac"}} +{"type":"command.receipt","at":"2026-08-04T01:12:00.011Z","tick":4655,"actorId":"dev-4","commandId":1615558,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615558,"issued_at_tick":4655,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"d32d5a4459ebee8404a11cf30f4ff5db1e79a0121fa0f548267e269506011b9c"}} +{"type":"command.receipt","at":"2026-08-04T01:12:00.211Z","tick":4661,"actorId":"dev-4","commandId":1615560,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615560,"issued_at_tick":4661,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"0a88db18b7d0150e2467cf9364a69fffc82b7dc15d4f5663ceced4a20cd7e921"}} +{"type":"command.receipt","at":"2026-08-04T01:12:00.413Z","tick":4668,"actorId":"dev-4","commandId":1615562,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615562,"issued_at_tick":4667,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"0dcaa35b0e266c50d384787d8f26dfb56ee1f5261b550fd2b17974002f007e6d"}} +{"type":"command.receipt","at":"2026-08-04T01:12:00.603Z","tick":4673,"actorId":"dev-4","commandId":1615564,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615564,"issued_at_tick":4673,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"652b8969f4a0e4226de989b1497f6a9176bda340b474292c6bd71829b363ce14"}} +{"type":"command.receipt","at":"2026-08-04T01:12:00.804Z","tick":4679,"actorId":"dev-4","commandId":1615566,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615566,"issued_at_tick":4679,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"fed85535ad69d4ea6dce86445c855bd0efbc23771dac5fab36e6566c30845f8a"}} +{"type":"command.receipt","at":"2026-08-04T01:12:01.002Z","tick":4685,"actorId":"dev-4","commandId":1615568,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615568,"issued_at_tick":4685,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"64a6bdea878cc9e76823662e6369bd4d34f50eebb257536fe093a66c70d3e566"}} +{"type":"checkpoint","at":"2026-08-04T01:12:01.143Z","tick":4689,"reason":"interval","actorCount":2,"stateHash":"e655edcf16dfb1c8aed66bebb9ab8393f35841de0a4c97502845a65f45c1e7d9"} +{"type":"command.receipt","at":"2026-08-04T01:12:01.204Z","tick":4691,"actorId":"dev-4","commandId":1615570,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615570,"issued_at_tick":4691,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"de2f295d499ad4a300a7b3c0e8985c895e0c1264c12beb0b63d65ad53be37d88"}} +{"type":"command.receipt","at":"2026-08-04T01:12:01.403Z","tick":4697,"actorId":"dev-4","commandId":1615572,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615572,"issued_at_tick":4697,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"bd7e8f8f5595b5748a426ed3fb4c33cd34f187ee92cde8fdd2856aaf94704c9b"}} +{"type":"command.receipt","at":"2026-08-04T01:12:01.603Z","tick":4703,"actorId":"dev-4","commandId":1615574,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615574,"issued_at_tick":4703,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"d521a492e6e385a0f029a8402a4e3b3e0d101cbb746ecc57c68397e54804b0c8"}} +{"type":"command.receipt","at":"2026-08-04T01:12:01.803Z","tick":4709,"actorId":"dev-4","commandId":1615576,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615576,"issued_at_tick":4709,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"228b5fee8405e8f06845cf962215889319db0f339ba77d2fd293e708a6e6bd9c"}} +{"type":"command.receipt","at":"2026-08-04T01:12:02.002Z","tick":4715,"actorId":"dev-4","commandId":1615578,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615578,"issued_at_tick":4715,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"9e65c8eb97a95b7ceabbcecee2f8818cf5b7b7c4d085df8b6daad33a44a9d9a2"}} +{"type":"command.receipt","at":"2026-08-04T01:12:02.203Z","tick":4721,"actorId":"dev-4","commandId":1615580,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615580,"issued_at_tick":4721,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"d1dde4c50b751af51001371fdc2dbad35c99eaf2892a5164299aef52a7b2fa07"}} +{"type":"command.receipt","at":"2026-08-04T01:12:02.402Z","tick":4727,"actorId":"dev-4","commandId":1615582,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615582,"issued_at_tick":4727,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"24565a38cab0d511003b69a304ae38cc95c3c79ec7cfc6fe6b4855e4229ea0f9"}} +{"type":"command.receipt","at":"2026-08-04T01:12:02.602Z","tick":4733,"actorId":"dev-4","commandId":1615584,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615584,"issued_at_tick":4733,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f8cf07c9fac7cdf641e0704f55544d9832083beffddba20a58451e68e972b975"}} +{"type":"command.receipt","at":"2026-08-04T01:12:02.801Z","tick":4739,"actorId":"dev-4","commandId":1615586,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615586,"issued_at_tick":4739,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"3d8b941f9904b55ec230405448982c570d891713152a6e007a56716c34d7a0eb"}} +{"type":"command.receipt","at":"2026-08-04T01:12:03.004Z","tick":4745,"actorId":"dev-4","commandId":1615588,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615588,"issued_at_tick":4745,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"3d2a11421390e98810cc853ba7757838c13779398f1d1a455c92dc0ac2c9cd44"}} +{"type":"command.receipt","at":"2026-08-04T01:12:03.201Z","tick":4751,"actorId":"dev-4","commandId":1615590,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615590,"issued_at_tick":4751,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"389363bf22a4dc96f436bbfe85a8f6a1ef0a3e7afaddc0070505ef33b4754dbd"}} +{"type":"command.receipt","at":"2026-08-04T01:12:03.403Z","tick":4757,"actorId":"dev-4","commandId":1615592,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615592,"issued_at_tick":4757,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"ea3d868032aa3dd0b5b008037afd48e2939577f11c70be963879e90fe68822bb"}} +{"type":"command.receipt","at":"2026-08-04T01:12:03.602Z","tick":4763,"actorId":"dev-4","commandId":1615594,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615594,"issued_at_tick":4763,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"30d288ac552556141dcf143c42665ef9c7b715f52618aecd8c36b9f4d8cf4a81"}} +{"type":"command.receipt","at":"2026-08-04T01:12:03.803Z","tick":4769,"actorId":"dev-4","commandId":1615596,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615596,"issued_at_tick":4769,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"c171fb88c43ac08086a210008467cc60aa96542f08b997bd4df1fa6419e14863"}} +{"type":"command.receipt","at":"2026-08-04T01:12:04.001Z","tick":4775,"actorId":"dev-4","commandId":1615598,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"player","envelope":{"session":1615,"player":488412757,"command_id":1615598,"issued_at_tick":4775,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"2b0ad9cac770e3d72e9f86eaf856702a57f05969ef18092530ddc6ee31c7877b"}} +{"type":"session.disconnect","at":"2026-08-04T01:12:04.214Z","tick":4782,"sessionId":"g_4","actorId":"dev-4","removedTransientActor":true} +{"type":"command.receipt","at":"2026-08-04T01:12:04.228Z","tick":4782,"actorId":"dev-4","commandId":1615600,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-4","rustActorId":"dev-4","envelope":{"session":1615,"player":488412757,"command_id":1615600,"issued_at_tick":4781,"command":{"SetMoveIntent":{"dx":0,"dy":0,"sprint":false}}},"session":4,"player":1,"stateHash":"ba5d5b62fe87601aa3c5dc909d1415e5c75913e5d38b9fc58f0a59df5e930333"}} +{"type":"session.connect","at":"2026-08-04T01:12:06.105Z","tick":4782,"sessionId":"g_5","actorId":"dev-5","playerId":"dev-5","zoneId":"open-desert"} +{"type":"command.receipt","at":"2026-08-04T01:12:06.125Z","tick":4783,"actorId":"dev-5","commandId":1592000,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592000,"issued_at_tick":4783,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"80a646a62502645c1eb29b7237dabe6595be6a37579abc4195671d9aa9ac4afe"}} +{"type":"checkpoint","at":"2026-08-04T01:12:06.150Z","tick":4784,"reason":"interval","actorCount":2,"stateHash":"c786d5fcfe75b81e4b71b43cafefd02fbfccc333c5078a1ef8eccd4418cc6bf3"} +{"type":"command.receipt","at":"2026-08-04T01:12:09.453Z","tick":4883,"actorId":"dev-5","commandId":1592001,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592001,"issued_at_tick":4883,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"fbe841c67d0826385167049e0c92d9c895432b0fb69b87d0b328c0406bb5667f"}} +{"type":"command.receipt","at":"2026-08-04T01:12:09.552Z","tick":4886,"actorId":"dev-5","commandId":1592002,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592002,"issued_at_tick":4886,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"0fd71935f37bfdbe24253f5ebe4da4ae33572dc716e9c5b39cca7ba25a52327a"}} +{"type":"command.receipt","at":"2026-08-04T01:12:09.654Z","tick":4889,"actorId":"dev-5","commandId":1592003,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592003,"issued_at_tick":4889,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"ce0f508ce8080488a0668be5c69c42c6dc7983701f1c8f19633f5f97b9fc64b2"}} +{"type":"command.receipt","at":"2026-08-04T01:12:09.752Z","tick":4892,"actorId":"dev-5","commandId":1592004,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592004,"issued_at_tick":4892,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"afee5f1046baa97401c1a9f40c72719cc1fb469bf6e083f3b12386a5f4670fd9"}} +{"type":"command.receipt","at":"2026-08-04T01:12:09.852Z","tick":4895,"actorId":"dev-5","commandId":1592005,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592005,"issued_at_tick":4895,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"badfb45296da0f2a992ec8ca77cb0d87f796c31f102276b2441e0f846abad9c1"}} +{"type":"command.receipt","at":"2026-08-04T01:12:09.952Z","tick":4898,"actorId":"dev-5","commandId":1592006,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592006,"issued_at_tick":4898,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"0291d483dfebf19ad8c474ef3c68f8d9453c2a066e86200f4e279e8243bb735d"}} +{"type":"command.receipt","at":"2026-08-04T01:12:10.052Z","tick":4901,"actorId":"dev-5","commandId":1592007,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592007,"issued_at_tick":4901,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"6ce5b19acd55bdc5fff8b4403fa9124d55f50db4637a040216c4501a5b5f250a"}} +{"type":"command.receipt","at":"2026-08-04T01:12:10.151Z","tick":4904,"actorId":"dev-5","commandId":1592008,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592008,"issued_at_tick":4904,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"dab7b88a410fccd3ff5183d07e7012eb517e2b0fcc3bf0a731a37542a69a53be"}} +{"type":"command.receipt","at":"2026-08-04T01:12:10.253Z","tick":4907,"actorId":"dev-5","commandId":1592009,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592009,"issued_at_tick":4907,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"bded52d8707eb11065937c2207ba8024b081ba8847e2a2431fbbd6ec7d014b0a"}} +{"type":"command.receipt","at":"2026-08-04T01:12:10.354Z","tick":4910,"actorId":"dev-5","commandId":1592010,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592010,"issued_at_tick":4910,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b61d0374e0e1a4537bfcd49a56fbba8221df547bb87d1e0454ff1cc2c7cc7c01"}} +{"type":"command.receipt","at":"2026-08-04T01:12:10.452Z","tick":4913,"actorId":"dev-5","commandId":1592011,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592011,"issued_at_tick":4913,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"9f4f1dca49a81b807c705ae37a277405d2c549ac1aa378fe0d2899c2e9c2bf92"}} +{"type":"command.receipt","at":"2026-08-04T01:12:10.552Z","tick":4916,"actorId":"dev-5","commandId":1592012,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592012,"issued_at_tick":4916,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"0f73dbc4cf030b5d66479351aa416d60954bf7010e467f8d0e29716f6bc4ca3f"}} +{"type":"command.receipt","at":"2026-08-04T01:12:10.653Z","tick":4919,"actorId":"dev-5","commandId":1592013,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592013,"issued_at_tick":4919,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"2e8b666f48bc5582c171dcfbe7fa52f31aa21981795ae63c2c0620603e5fe302"}} +{"type":"command.receipt","at":"2026-08-04T01:12:10.752Z","tick":4922,"actorId":"dev-5","commandId":1592014,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592014,"issued_at_tick":4922,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"5bc218412d16045e051326c52d146680ec19e4633c14bc17ca03164fb0c17e1c"}} +{"type":"command.receipt","at":"2026-08-04T01:12:10.852Z","tick":4925,"actorId":"dev-5","commandId":1592015,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592015,"issued_at_tick":4925,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"5e8086cebdc377ce7a9f59438ce71bed1a1a3796e03de45d088fe3cbb33b79f3"}} +{"type":"command.receipt","at":"2026-08-04T01:12:10.952Z","tick":4928,"actorId":"dev-5","commandId":1592016,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592016,"issued_at_tick":4928,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"d3de25b32fcf400cf1ca14d47a13bdffb7d086f679bf3290efdc70c64f080f44"}} +{"type":"command.receipt","at":"2026-08-04T01:12:11.053Z","tick":4931,"actorId":"dev-5","commandId":1592017,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592017,"issued_at_tick":4931,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"9dcfe690155493e61939262bdfc511ae3d0023ab3ced6bf7350491d715e4c5b7"}} +{"type":"command.receipt","at":"2026-08-04T01:12:11.152Z","tick":4934,"actorId":"dev-5","commandId":1592018,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592018,"issued_at_tick":4934,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"e12d24907a1f2ad3ed7fdaafc8cde6c50798ac6aec0eb54fafb1a478829d1855"}} +{"type":"checkpoint","at":"2026-08-04T01:12:11.165Z","tick":4934,"reason":"interval","actorCount":2,"stateHash":"e12d24907a1f2ad3ed7fdaafc8cde6c50798ac6aec0eb54fafb1a478829d1855"} +{"type":"command.receipt","at":"2026-08-04T01:12:11.352Z","tick":4940,"actorId":"dev-5","commandId":1592020,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592020,"issued_at_tick":4940,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b83e765ff3e584b7c90d2eaaf77364c49ff8ae78bf8284db2cf6b1cf55e5ddc4"}} +{"type":"command.receipt","at":"2026-08-04T01:12:11.551Z","tick":4946,"actorId":"dev-5","commandId":1592022,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592022,"issued_at_tick":4946,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"bbebde5d567821216f481ef483f799a99516c363c1e1910c0a960a51277bc914"}} +{"type":"command.receipt","at":"2026-08-04T01:12:11.754Z","tick":4952,"actorId":"dev-5","commandId":1592024,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592024,"issued_at_tick":4952,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"ee2fb9671177a80ebfad18f2f9da4143dc3f4ba6ecb71507bc4251384ab8e471"}} +{"type":"command.receipt","at":"2026-08-04T01:12:11.854Z","tick":4955,"actorId":"dev-5","commandId":1592025,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592025,"issued_at_tick":4955,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"c6612442be60cd5993aa0d023a7f84219b2e89de313c98a82b10c039213f7984"}} +{"type":"command.receipt","at":"2026-08-04T01:12:12.147Z","tick":4963,"actorId":"dev-5","commandId":1592028,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592028,"issued_at_tick":4963,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"5409fc730ccd07b129fcb6334ccf5c0f5f3de64f53e748a20b0de2b45087eb64"}} +{"type":"command.receipt","at":"2026-08-04T01:12:12.343Z","tick":4969,"actorId":"dev-5","commandId":1592030,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592030,"issued_at_tick":4969,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"2704358a0bfdfd3e8a8b6ad22ddd65d69ffe6efa4f5155d580a21a0db5c09270"}} +{"type":"command.receipt","at":"2026-08-04T01:12:12.545Z","tick":4975,"actorId":"dev-5","commandId":1592032,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592032,"issued_at_tick":4975,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"31d5473b126188f23d347950b26e03d2ace4170184b3166c508ca12cb74e3478"}} +{"type":"command.receipt","at":"2026-08-04T01:12:12.743Z","tick":4981,"actorId":"dev-5","commandId":1592034,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592034,"issued_at_tick":4981,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"7ef639ab1314b228214c98d800096fd2b1ed916d48590d79d25a689d1badada3"}} +{"type":"command.receipt","at":"2026-08-04T01:12:12.943Z","tick":4987,"actorId":"dev-5","commandId":1592036,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592036,"issued_at_tick":4987,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"f0abb65731d4ec8743bca2348617fa5b67b4a8e9d1a9b2fbcb32f9643ac7d2b0"}} +{"type":"command.receipt","at":"2026-08-04T01:12:13.144Z","tick":4993,"actorId":"dev-5","commandId":1592038,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592038,"issued_at_tick":4993,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"631c7b15c6dffa158fc72da53e39fe7e09fce6f9ab7d99a89715c7481250896e"}} +{"type":"command.receipt","at":"2026-08-04T01:12:13.344Z","tick":4999,"actorId":"dev-5","commandId":1592040,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592040,"issued_at_tick":4999,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"d3647b7ee0dce5bd929ea4cd47184cf9ea2c938621854e39b4ef08d4b66f88c6"}} +{"type":"command.receipt","at":"2026-08-04T01:12:13.536Z","tick":5005,"actorId":"dev-5","commandId":1592042,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592042,"issued_at_tick":5005,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"8805d553684fbaa4c267fa23188283303c6ce2d09f8ee0a82d6f1ab23e4ad7e4"}} +{"type":"command.receipt","at":"2026-08-04T01:12:13.728Z","tick":5011,"actorId":"dev-5","commandId":1592044,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592044,"issued_at_tick":5011,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"82fd92a15a3190e4b96ae6e08980b3522d909fc1ca6ba29d727c392165e95ea0"}} +{"type":"command.receipt","at":"2026-08-04T01:12:13.922Z","tick":5017,"actorId":"dev-5","commandId":1592046,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592046,"issued_at_tick":5017,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"97a52ad50b85d4e3436460209a99a5a82de0480cda2f54ec041735882cf8ca8b"}} +{"type":"command.receipt","at":"2026-08-04T01:12:14.119Z","tick":5023,"actorId":"dev-5","commandId":1592048,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592048,"issued_at_tick":5023,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"9132635b2df92a67047d0a366ce61f8db323e4422cdab4468d610862a307db6c"}} +{"type":"command.receipt","at":"2026-08-04T01:12:14.313Z","tick":5029,"actorId":"dev-5","commandId":1592050,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592050,"issued_at_tick":5028,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"7b4b1c59a5cd68ad314f43f13f39c72247bb685dce24d57f0d472a56d30972e0"}} +{"type":"command.receipt","at":"2026-08-04T01:12:14.502Z","tick":5034,"actorId":"dev-5","commandId":1592052,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592052,"issued_at_tick":5034,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"04013a7e09fcf153930dddf4a28dcb39d45f387dbf017ace2275cd51c7832fe1"}} +{"type":"command.receipt","at":"2026-08-04T01:12:14.703Z","tick":5040,"actorId":"dev-5","commandId":1592054,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592054,"issued_at_tick":5040,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"fa64d47338fe6598d0b727c3dfd3cccb7eae33982b60cc84774de556217fdc53"}} +{"type":"command.receipt","at":"2026-08-04T01:12:14.895Z","tick":5046,"actorId":"dev-5","commandId":1592056,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592056,"issued_at_tick":5046,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"21ab47d7ca7a6646e67c7ff7f04a934ec2fc280aadadcda90a1c6ecf6fdf926f"}} +{"type":"command.receipt","at":"2026-08-04T01:12:15.079Z","tick":5051,"actorId":"dev-5","commandId":1592058,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592058,"issued_at_tick":5051,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"653d42bf01f2985ffb68f102c1d025df514c696b1d407d37a2f6d0e34b5a2506"}} +{"type":"command.receipt","at":"2026-08-04T01:12:15.273Z","tick":5057,"actorId":"dev-5","commandId":1592060,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592060,"issued_at_tick":5057,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"41678f9ed722fd06a48eda9f9348a415302b8eeac9eb05143a5846b279927814"}} +{"type":"command.receipt","at":"2026-08-04T01:12:15.463Z","tick":5063,"actorId":"dev-5","commandId":1592062,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592062,"issued_at_tick":5063,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"7142a7bccc9cc50b7142f11024a93eb438feecbd8debc0c01af56b742d09f2df"}} +{"type":"command.receipt","at":"2026-08-04T01:12:15.739Z","tick":5071,"actorId":"dev-5","commandId":1592065,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592065,"issued_at_tick":5071,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"af045822c6d9ffd2a58d6da469691fcac022f8df8b9ff8698e87a0f9f512db67"}} +{"type":"command.receipt","at":"2026-08-04T01:12:15.915Z","tick":5077,"actorId":"dev-5","commandId":1592067,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592067,"issued_at_tick":5076,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"424970811fb3810009bfd52ab0a9dd35c284017b407196ae607cfc0348b88b30"}} +{"type":"command.receipt","at":"2026-08-04T01:12:16.111Z","tick":5082,"actorId":"dev-5","commandId":1592069,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592069,"issued_at_tick":5082,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"79b2fabc58a83164678b37e31ee981bf7c1271a20e6cdca7ba41fa948afe14d0"}} +{"type":"checkpoint","at":"2026-08-04T01:12:16.179Z","tick":5085,"reason":"interval","actorCount":2,"stateHash":"e7fa3729dcc87f9c10440c166d8a9482626725c3350cdf2f523f22975c4fe98f"} +{"type":"command.receipt","at":"2026-08-04T01:12:16.304Z","tick":5088,"actorId":"dev-5","commandId":1592071,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592071,"issued_at_tick":5088,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"ed805143aee8e183b4a6dab3e418bd5c2b4d7732da0df21c8564d405e09448f6"}} +{"type":"command.receipt","at":"2026-08-04T01:12:16.495Z","tick":5094,"actorId":"dev-5","commandId":1592073,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592073,"issued_at_tick":5094,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"3b1326647905bb67da90009119f4ed4c2424db1a2a09abfbcac226e6dea677ea"}} +{"type":"command.receipt","at":"2026-08-04T01:12:16.681Z","tick":5100,"actorId":"dev-5","commandId":1592075,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592075,"issued_at_tick":5099,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"74984f681e3cde27a920141d1573f39deae4473b286a20910d0399a2d120671b"}} +{"type":"command.receipt","at":"2026-08-04T01:12:16.867Z","tick":5105,"actorId":"dev-5","commandId":1592077,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592077,"issued_at_tick":5105,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b5cc8e1a89b393f6155e26de7ff0a0992ec08e91d3d4f30c6808d26526e66ea8"}} +{"type":"command.receipt","at":"2026-08-04T01:12:17.062Z","tick":5111,"actorId":"dev-5","commandId":1592079,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592079,"issued_at_tick":5111,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b9c0d7b3ae27b8824fc72efbbe3e939b6aea3497966f337adbbbb32dca4d9dc6"}} +{"type":"command.receipt","at":"2026-08-04T01:12:17.261Z","tick":5117,"actorId":"dev-5","commandId":1592081,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592081,"issued_at_tick":5117,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"eefd15898afed4b0c8ec8765ed7e50fbf4a7de2ad98739c0d47e1d9cf03b8afd"}} +{"type":"command.receipt","at":"2026-08-04T01:12:17.461Z","tick":5123,"actorId":"dev-5","commandId":1592083,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592083,"issued_at_tick":5123,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"2a43245b604e9f90a0bc41ea56de3a6078fcdc7f284e86bc43df740ba3a4ae0a"}} +{"type":"command.receipt","at":"2026-08-04T01:12:17.661Z","tick":5129,"actorId":"dev-5","commandId":1592085,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592085,"issued_at_tick":5129,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"43e96e42330477c0e93a5fecc9a4fce0c647c5b17e6afedbf2acde40361d24a5"}} +{"type":"command.receipt","at":"2026-08-04T01:12:17.954Z","tick":5138,"actorId":"dev-5","commandId":1592088,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592088,"issued_at_tick":5138,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"636822180ee30ca923e5376ee1724f4aa4c6c49355dd2eaa788695ac9de40aa6"}} +{"type":"command.receipt","at":"2026-08-04T01:12:18.147Z","tick":5143,"actorId":"dev-5","commandId":1592090,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592090,"issued_at_tick":5143,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"efcc53c39310492933585b0fbb55fc5af999b1cec4f4808763ab267cfbef9f00"}} +{"type":"command.receipt","at":"2026-08-04T01:12:18.337Z","tick":5149,"actorId":"dev-5","commandId":1592092,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592092,"issued_at_tick":5149,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"bd5ee63bc533e2f9942706fbf02c01dcc1e3954639348c68209a77c2fa5388cd"}} +{"type":"command.receipt","at":"2026-08-04T01:12:18.527Z","tick":5155,"actorId":"dev-5","commandId":1592094,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592094,"issued_at_tick":5155,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"26fa9510a58f9dd81828cadf7dbc769f98da4e1a34eb12f261a28e2a0c0adc5e"}} +{"type":"command.receipt","at":"2026-08-04T01:12:18.715Z","tick":5161,"actorId":"dev-5","commandId":1592096,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592096,"issued_at_tick":5160,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"4679cf2ac42b244d164736af27649af72686c1375625280a29d5b58c8f56c93a"}} +{"type":"command.receipt","at":"2026-08-04T01:12:18.895Z","tick":5166,"actorId":"dev-5","commandId":1592098,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592098,"issued_at_tick":5166,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"3fa2fbdfdb596c49645b7cad0df1553da178c66bed718e3f71a62270d7646a83"}} +{"type":"command.receipt","at":"2026-08-04T01:12:19.072Z","tick":5171,"actorId":"dev-5","commandId":1592100,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592100,"issued_at_tick":5171,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"f92000b159e7ac89dcdf0d5f0bc2cc61a8d127a451a0c064ddf61b715e0a120b"}} +{"type":"command.receipt","at":"2026-08-04T01:12:19.345Z","tick":5179,"actorId":"dev-5","commandId":1592103,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592103,"issued_at_tick":5179,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"f94ea5fced8d4ae867732152fb2de2d085865260632a1b5300c433f0f14892e2"}} +{"type":"command.receipt","at":"2026-08-04T01:12:19.539Z","tick":5185,"actorId":"dev-5","commandId":1592105,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592105,"issued_at_tick":5185,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"9ac593ba84041b68676b6fbf368fefdd1957bba26c05bba2bd120d1d1ba749d7"}} +{"type":"command.receipt","at":"2026-08-04T01:12:19.738Z","tick":5191,"actorId":"dev-5","commandId":1592107,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592107,"issued_at_tick":5191,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"70ee43094c0029376dfcd4cfe9805ddd818a815a885326ce15a5388d3cf74b8c"}} +{"type":"command.receipt","at":"2026-08-04T01:12:19.936Z","tick":5197,"actorId":"dev-5","commandId":1592109,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592109,"issued_at_tick":5197,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"d8d091ca887e02a57334f0061f55da039b97a90cc85060669f97342f19053b12"}} +{"type":"command.receipt","at":"2026-08-04T01:12:20.136Z","tick":5203,"actorId":"dev-5","commandId":1592111,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592111,"issued_at_tick":5203,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"191f7ab1a38b6a007f08c0ae65cebf5b129f110502fe4d500caee3227032638d"}} +{"type":"command.receipt","at":"2026-08-04T01:12:20.336Z","tick":5209,"actorId":"dev-5","commandId":1592113,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592113,"issued_at_tick":5209,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"6ac1af1b54374eeece4178d6e7557f3062883af2da7e721b05f220a53179b07b"}} +{"type":"command.receipt","at":"2026-08-04T01:12:20.537Z","tick":5215,"actorId":"dev-5","commandId":1592115,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592115,"issued_at_tick":5215,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"8873382d717520e5914ea340365335468427ffa7f12662477babe7cbd23d148f"}} +{"type":"command.receipt","at":"2026-08-04T01:12:20.736Z","tick":5221,"actorId":"dev-5","commandId":1592117,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592117,"issued_at_tick":5221,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"3d0192e662a211890d9f29c86f60ca0debdda99c7177920aead2682c6daa50b7"}} +{"type":"command.receipt","at":"2026-08-04T01:12:20.935Z","tick":5227,"actorId":"dev-5","commandId":1592119,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592119,"issued_at_tick":5227,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"0225a67e13b524fd9e2a4c7ccc01df62ca08fca0217c263a94c2423e5746cd90"}} +{"type":"command.receipt","at":"2026-08-04T01:12:21.136Z","tick":5233,"actorId":"dev-5","commandId":1592121,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592121,"issued_at_tick":5233,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"a0cc6bdb4bfbc78b569152796176c7cefbfe301c304973fd9a14ef856f95da04"}} +{"type":"checkpoint","at":"2026-08-04T01:12:21.182Z","tick":5235,"reason":"interval","actorCount":2,"stateHash":"fd1307ade7478cf65c3f367aaad0ddb86d8f2271472c9a70b84cccb57bbbd78c"} +{"type":"command.receipt","at":"2026-08-04T01:12:21.331Z","tick":5239,"actorId":"dev-5","commandId":1592123,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592123,"issued_at_tick":5239,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"9cd13df4a78d2f1f87f8df896c9972a9c04197f49c8c34f9c1af51cb610482ce"}} +{"type":"command.receipt","at":"2026-08-04T01:12:21.527Z","tick":5245,"actorId":"dev-5","commandId":1592125,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592125,"issued_at_tick":5245,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"aaf0d9754884feac67211097550f9487415396bc7192d835a025e60ca3c775e6"}} +{"type":"command.receipt","at":"2026-08-04T01:12:21.727Z","tick":5251,"actorId":"dev-5","commandId":1592127,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592127,"issued_at_tick":5251,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"2b9ed43c4800a93b660c8c52c984ef0546200b45023fa5a69ff2de3f2d9e9ac8"}} +{"type":"command.receipt","at":"2026-08-04T01:12:21.929Z","tick":5257,"actorId":"dev-5","commandId":1592129,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592129,"issued_at_tick":5257,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"83ff9923638f405f3987ec739a2f9b43aa243df36c386cf4da531d466ba07f42"}} +{"type":"command.receipt","at":"2026-08-04T01:12:22.128Z","tick":5263,"actorId":"dev-5","commandId":1592131,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592131,"issued_at_tick":5263,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"de3cfa2eacbff51beee90b8c7e8764f46e67adcdd500e28c5c626004f9b1772c"}} +{"type":"command.receipt","at":"2026-08-04T01:12:22.329Z","tick":5269,"actorId":"dev-5","commandId":1592133,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592133,"issued_at_tick":5269,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"679fa91c35849ee91325bef28ca90be47afbb15f31272f6690483bceafd448ea"}} +{"type":"command.receipt","at":"2026-08-04T01:12:22.529Z","tick":5275,"actorId":"dev-5","commandId":1592135,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592135,"issued_at_tick":5275,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"24697d70474e0a21fc6c35401e7ba93ebd6ee91e9875a1759d68958e340d0fb2"}} +{"type":"command.receipt","at":"2026-08-04T01:12:22.729Z","tick":5281,"actorId":"dev-5","commandId":1592137,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592137,"issued_at_tick":5281,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b85f71fd07e3a2ce06677edc121a1a2766f50a6837bf54de49b8954741a07703"}} +{"type":"command.receipt","at":"2026-08-04T01:12:22.927Z","tick":5287,"actorId":"dev-5","commandId":1592139,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592139,"issued_at_tick":5287,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"e4e8b2edb16c6ac1a2c5a948e60bd47ff4c3aa542b09713bc9d792245dce5772"}} +{"type":"command.receipt","at":"2026-08-04T01:12:23.127Z","tick":5293,"actorId":"dev-5","commandId":1592141,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592141,"issued_at_tick":5293,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"3a962263b3d9a8e70cc16651d428f89801526b871e60354695e00e92f7835c1d"}} +{"type":"command.receipt","at":"2026-08-04T01:12:23.328Z","tick":5299,"actorId":"dev-5","commandId":1592143,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592143,"issued_at_tick":5299,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"3ce80fc28836994524899c8c0c9bb662d7cf9aab1fd45894986a6907b832342f"}} +{"type":"command.receipt","at":"2026-08-04T01:12:23.528Z","tick":5305,"actorId":"dev-5","commandId":1592145,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592145,"issued_at_tick":5305,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"2f91b1a4ade300a4ba2244fbe24e22a4449ddc020930c7d882c3f10de534d7b4"}} +{"type":"command.receipt","at":"2026-08-04T01:12:23.729Z","tick":5311,"actorId":"dev-5","commandId":1592147,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592147,"issued_at_tick":5311,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"5249ba1d49fa6494ccf63f3b6d090f69046ba92217313c9b357fc402918f3d23"}} +{"type":"command.receipt","at":"2026-08-04T01:12:23.927Z","tick":5317,"actorId":"dev-5","commandId":1592149,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592149,"issued_at_tick":5317,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"9f5f01a23e41157db7a075cf962abf7ce21d90f596b8d959db35360675dac5bf"}} +{"type":"command.receipt","at":"2026-08-04T01:12:24.128Z","tick":5323,"actorId":"dev-5","commandId":1592151,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592151,"issued_at_tick":5323,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"2c4f0c51e2e10a7b1a3dd2f982ba5cc3ae7ca609e2ce04562f9caa5548bf772f"}} +{"type":"command.receipt","at":"2026-08-04T01:12:24.328Z","tick":5329,"actorId":"dev-5","commandId":1592153,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592153,"issued_at_tick":5329,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"2a03d85f930bfe8643dac85cb80431237f90734668b0c4d5bba4544bcfd549a8"}} +{"type":"command.receipt","at":"2026-08-04T01:12:24.527Z","tick":5335,"actorId":"dev-5","commandId":1592155,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592155,"issued_at_tick":5335,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"9ce7eb59e1aa9be530600da908e8bb01f56079a159209afe0c10c6ca79a1ba04"}} +{"type":"command.receipt","at":"2026-08-04T01:12:24.729Z","tick":5341,"actorId":"dev-5","commandId":1592157,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592157,"issued_at_tick":5341,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"23b818b85d07083c7d4007c17fc05678945ddb0935f092ee36a9125dc921745c"}} +{"type":"command.receipt","at":"2026-08-04T01:12:24.928Z","tick":5347,"actorId":"dev-5","commandId":1592159,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592159,"issued_at_tick":5347,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"32a90943db8d985a77ce5f5e0477f477722af4ea2e56fd04a063e0c4bb12f3b1"}} +{"type":"command.receipt","at":"2026-08-04T01:12:25.128Z","tick":5353,"actorId":"dev-5","commandId":1592161,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592161,"issued_at_tick":5353,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"39736703e3dc8febd06961d81089cf4ccbb10cb0dc5ea506e90e12b9d0663f54"}} +{"type":"command.receipt","at":"2026-08-04T01:12:25.328Z","tick":5359,"actorId":"dev-5","commandId":1592163,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592163,"issued_at_tick":5359,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"d5057d2e900ea40775af13cf428f91b1222f880c0a750160b73425e354662157"}} +{"type":"command.receipt","at":"2026-08-04T01:12:25.528Z","tick":5365,"actorId":"dev-5","commandId":1592165,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592165,"issued_at_tick":5365,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"668b2292b2b925cb2c38264dbea9832fffb02f660262aad0b77616e9faa87bb6"}} +{"type":"command.receipt","at":"2026-08-04T01:12:25.728Z","tick":5371,"actorId":"dev-5","commandId":1592167,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592167,"issued_at_tick":5371,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"fbc6adb5f6713fd85d9041f3190da4d4859f253dbb1207fef33aa6b46275e05f"}} +{"type":"command.receipt","at":"2026-08-04T01:12:25.929Z","tick":5377,"actorId":"dev-5","commandId":1592169,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592169,"issued_at_tick":5377,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"d84ae6114883c857a1c6fc7586a776c82d51c00ace8c4fc971f947cc7eb6a4fd"}} +{"type":"command.receipt","at":"2026-08-04T01:12:26.128Z","tick":5383,"actorId":"dev-5","commandId":1592171,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592171,"issued_at_tick":5383,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"29e4147b727a5bf72dff3ef269c31cc046b75c745441c5bfbd197807588e531a"}} +{"type":"checkpoint","at":"2026-08-04T01:12:26.201Z","tick":5385,"reason":"interval","actorCount":2,"stateHash":"4b806a98ff8507ee56caee72707133bceb97ecfd46075001f1a03ac1705250e3"} +{"type":"command.receipt","at":"2026-08-04T01:12:26.327Z","tick":5389,"actorId":"dev-5","commandId":1592173,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592173,"issued_at_tick":5389,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"f59c861914bc7f963b1a356ce26ddfa6a917426590174b818d5f7d7ef20539a6"}} +{"type":"command.receipt","at":"2026-08-04T01:12:26.528Z","tick":5395,"actorId":"dev-5","commandId":1592175,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592175,"issued_at_tick":5395,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"80627df39d6bedb6e2c604b4ed45e61c3ae644a128cf401f3faccd85c8c8c35d"}} +{"type":"command.receipt","at":"2026-08-04T01:12:26.728Z","tick":5401,"actorId":"dev-5","commandId":1592177,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592177,"issued_at_tick":5401,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"bdb958f0d26a7276dffd3c4e0ac293b2e7c0a0dff873af5f951fca0735d84fd0"}} +{"type":"command.receipt","at":"2026-08-04T01:12:26.927Z","tick":5407,"actorId":"dev-5","commandId":1592179,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592179,"issued_at_tick":5407,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"bbdcb6dcf8809b56f19886388d622b7eaa3a7bf991847c715773ab7d0f61cdc0"}} +{"type":"command.receipt","at":"2026-08-04T01:12:27.128Z","tick":5413,"actorId":"dev-5","commandId":1592181,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592181,"issued_at_tick":5413,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"0db6496739e0354a2f3459ec4a4e2703371bfb09ff03797b00bec49a328b6cf2"}} +{"type":"command.receipt","at":"2026-08-04T01:12:27.327Z","tick":5419,"actorId":"dev-5","commandId":1592183,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592183,"issued_at_tick":5419,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"5e9aa796154066058806f24baa769e32fbc1a3cc63df058ed7440377279fcad5"}} +{"type":"command.receipt","at":"2026-08-04T01:12:27.527Z","tick":5425,"actorId":"dev-5","commandId":1592185,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592185,"issued_at_tick":5425,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"923ce3bc20afa5e01a0912c0f24e55e012c99cb3fe5963d4c62fcb5ff8f03cbc"}} +{"type":"command.receipt","at":"2026-08-04T01:12:27.728Z","tick":5431,"actorId":"dev-5","commandId":1592187,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592187,"issued_at_tick":5431,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"d6770a1e6ae14d465f077471b39af6c2e5725af5034421b1991abaaa51f3df4d"}} +{"type":"command.receipt","at":"2026-08-04T01:12:27.928Z","tick":5437,"actorId":"dev-5","commandId":1592189,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592189,"issued_at_tick":5437,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"df1f1f7aa97455f326387e97e422f5d27499baa6de6b7b5f2d2e7be8285c5fe4"}} +{"type":"command.receipt","at":"2026-08-04T01:12:28.127Z","tick":5443,"actorId":"dev-5","commandId":1592191,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592191,"issued_at_tick":5443,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b83d44fcc3ae50b39394a96595ef2519636c4e48bd3cba9067cf34612dab2d0a"}} +{"type":"command.receipt","at":"2026-08-04T01:12:28.329Z","tick":5449,"actorId":"dev-5","commandId":1592193,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592193,"issued_at_tick":5449,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"3033717dea2dc9e4e32dcc3dd663b2f3106238743834171bc270e9d3258f6595"}} +{"type":"command.receipt","at":"2026-08-04T01:12:28.524Z","tick":5455,"actorId":"dev-5","commandId":1592195,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592195,"issued_at_tick":5455,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"88f2f9d3928c9c31ebf78a8ccbf5668bee65f93954255351df82edd406306437"}} +{"type":"command.receipt","at":"2026-08-04T01:12:28.711Z","tick":5460,"actorId":"dev-5","commandId":1592197,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592197,"issued_at_tick":5460,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"d2b71d3550860b2dd329f5ae44aa51462b94446fca8a45c2039a13bde869cb29"}} +{"type":"command.receipt","at":"2026-08-04T01:12:28.895Z","tick":5466,"actorId":"dev-5","commandId":1592199,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592199,"issued_at_tick":5466,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"d717a121bb8a533ccbc7c3b611833a842145b0c8d4da9aa1147a7db386781547"}} +{"type":"command.receipt","at":"2026-08-04T01:12:29.075Z","tick":5471,"actorId":"dev-5","commandId":1592201,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592201,"issued_at_tick":5471,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"a6e933a70765351e1e15e2cbf1b917c19ce3598fac864218f9313264d8e04087"}} +{"type":"command.receipt","at":"2026-08-04T01:12:29.262Z","tick":5477,"actorId":"dev-5","commandId":1592203,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592203,"issued_at_tick":5477,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"eb6502254297889898f9dad323b1ccf5dc6c5fb3ab6fd36c410b7a56a314ed6d"}} +{"type":"command.receipt","at":"2026-08-04T01:12:29.455Z","tick":5483,"actorId":"dev-5","commandId":1592205,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592205,"issued_at_tick":5483,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"a73abf7d1e6baec8bce8dde27a6f06768fff180f2d6f6c56c9fa38e7f792c454"}} +{"type":"command.receipt","at":"2026-08-04T01:12:29.725Z","tick":5491,"actorId":"dev-5","commandId":1592208,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592208,"issued_at_tick":5491,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"16812af47b1e93dd647cfc961c04755819ffbc261bbfdbfa8d0f05194bf988ec"}} +{"type":"command.receipt","at":"2026-08-04T01:12:29.912Z","tick":5496,"actorId":"dev-5","commandId":1592210,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592210,"issued_at_tick":5496,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"2dd4685bd6cf5efd7fc53373684a89ca1c159e42b4ba4529291f67dc1a6e08b8"}} +{"type":"command.receipt","at":"2026-08-04T01:12:30.105Z","tick":5502,"actorId":"dev-5","commandId":1592212,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592212,"issued_at_tick":5502,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"21768e4fc05a400656790115ed1fc6728527c3286cde22015737def79c9c8dad"}} +{"type":"command.receipt","at":"2026-08-04T01:12:30.291Z","tick":5508,"actorId":"dev-5","commandId":1592214,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592214,"issued_at_tick":5508,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"d5c40b7f41f50255b0e031047ec38175c4dd97fc78a9a68551f270105c35fe8d"}} +{"type":"command.receipt","at":"2026-08-04T01:12:30.472Z","tick":5513,"actorId":"dev-5","commandId":1592216,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592216,"issued_at_tick":5513,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"aeccde7cc84bf9c8fdc4f2cf1860deff6a686c85030c8d13bacb84b5a5f2fe54"}} +{"type":"command.receipt","at":"2026-08-04T01:12:30.663Z","tick":5519,"actorId":"dev-5","commandId":1592218,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592218,"issued_at_tick":5519,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"eeee77e40c8bf583b0730b74a2bd783288cb9a717dd0059bf973d74e8e0bda8c"}} +{"type":"command.receipt","at":"2026-08-04T01:12:30.949Z","tick":5528,"actorId":"dev-5","commandId":1592221,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592221,"issued_at_tick":5527,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"5698c6608f3c53050a5a740c6c51808559bfde0aa8d71a942da9d9849f879fe6"}} +{"type":"command.receipt","at":"2026-08-04T01:12:31.146Z","tick":5533,"actorId":"dev-5","commandId":1592223,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592223,"issued_at_tick":5533,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"c46a3c8123e77389b1f6410e6cdac52ba9e26fae481d2b1037d430b802023e66"}} +{"type":"checkpoint","at":"2026-08-04T01:12:31.206Z","tick":5535,"reason":"interval","actorCount":2,"stateHash":"32d37477881cfbe9f0f9147827433cefd8977e1ac52fe59f9ba8829b9f3e9ffc"} +{"type":"command.receipt","at":"2026-08-04T01:12:31.344Z","tick":5539,"actorId":"dev-5","commandId":1592225,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592225,"issued_at_tick":5539,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"a9a3065acb00c45023c4a50377c53ee51cc61512f49a14eb22c4ffc2330ed5a5"}} +{"type":"command.receipt","at":"2026-08-04T01:12:31.527Z","tick":5545,"actorId":"dev-5","commandId":1592227,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592227,"issued_at_tick":5545,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"bbb93845233a526e39a0a7db2941d230f183e0de4076265696423525e3de7496"}} +{"type":"command.receipt","at":"2026-08-04T01:12:31.716Z","tick":5551,"actorId":"dev-5","commandId":1592229,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592229,"issued_at_tick":5550,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"530f8266823f39714038926211631d6a29c4c2e067523dfd6de0ad0a5f3fde41"}} +{"type":"command.receipt","at":"2026-08-04T01:12:31.901Z","tick":5556,"actorId":"dev-5","commandId":1592231,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592231,"issued_at_tick":5556,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"f2666631540f3a3275996eab7a15197020908acaf5130e4a13c72fa0484567ad"}} +{"type":"command.receipt","at":"2026-08-04T01:12:32.094Z","tick":5562,"actorId":"dev-5","commandId":1592233,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592233,"issued_at_tick":5562,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"05816bfe7c1d3b7d50c2ee473cfa03d3d4511416ea5d3322b58dd0e64ab92c72"}} +{"type":"command.receipt","at":"2026-08-04T01:12:32.297Z","tick":5568,"actorId":"dev-5","commandId":1592235,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592235,"issued_at_tick":5568,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"ae6b2e5edf315ac76bfd1b8a5089acc0358998d6d23807e88cce053a3841b92a"}} +{"type":"command.receipt","at":"2026-08-04T01:12:32.497Z","tick":5574,"actorId":"dev-5","commandId":1592237,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592237,"issued_at_tick":5574,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"38a0b967a9ba7931f457a703196c5fe426ae84cc1482dbd38a735132b20ae2dd"}} +{"type":"command.receipt","at":"2026-08-04T01:12:32.695Z","tick":5580,"actorId":"dev-5","commandId":1592239,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592239,"issued_at_tick":5580,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"0877feb069d0e7b756d420bc513679bdc97226d6c83c5938ed640c5660644405"}} +{"type":"command.receipt","at":"2026-08-04T01:12:32.894Z","tick":5586,"actorId":"dev-5","commandId":1592241,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592241,"issued_at_tick":5586,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"de9dbe497acd17b1cc5f719d8246984bebe1bf1cac396efb3ddafab083a20e5c"}} +{"type":"command.receipt","at":"2026-08-04T01:12:33.094Z","tick":5592,"actorId":"dev-5","commandId":1592243,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592243,"issued_at_tick":5592,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"6b074e3626ef8c3fa19ea6b24ead53a24f4a7fc0288498e0acc42b1443f981cf"}} +{"type":"command.receipt","at":"2026-08-04T01:12:33.295Z","tick":5598,"actorId":"dev-5","commandId":1592245,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592245,"issued_at_tick":5598,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"0b176113036e6a7f3b2e2b0e0a3fda94d1f4530c0ba9f224447a88623206f112"}} +{"type":"command.receipt","at":"2026-08-04T01:12:33.494Z","tick":5604,"actorId":"dev-5","commandId":1592247,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592247,"issued_at_tick":5604,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"7e4606b3981b81361ad686bfa8405b4ac5b5078aec97e3cf93ddf385366bc30a"}} +{"type":"command.receipt","at":"2026-08-04T01:12:33.696Z","tick":5610,"actorId":"dev-5","commandId":1592249,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592249,"issued_at_tick":5610,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b9e3a6e1a059dddc11041066eeea8c67162bf40683ba0e06188460a1f6247104"}} +{"type":"command.receipt","at":"2026-08-04T01:12:33.894Z","tick":5616,"actorId":"dev-5","commandId":1592251,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592251,"issued_at_tick":5616,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"8df7c019a1532cb52ad1ee87db60df2b4bc4c8e9b96083302f8d5b7314a1d3b2"}} +{"type":"command.receipt","at":"2026-08-04T01:12:34.094Z","tick":5622,"actorId":"dev-5","commandId":1592253,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592253,"issued_at_tick":5622,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"e8ef5d7b3406bf2031e42aa937b04946839e0edbb60a922351795114c2c26872"}} +{"type":"command.receipt","at":"2026-08-04T01:12:34.294Z","tick":5628,"actorId":"dev-5","commandId":1592255,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592255,"issued_at_tick":5628,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"cc39e922109f7f0ff916da0b4f8822a49d81056ef1195d4a034a4d79d9bafb2f"}} +{"type":"command.receipt","at":"2026-08-04T01:12:34.493Z","tick":5634,"actorId":"dev-5","commandId":1592257,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592257,"issued_at_tick":5634,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"3cf0f4b6e49e2c4874389aeb34f6eb7e0dfddff64c1d9ecea550c27030e5a620"}} +{"type":"command.receipt","at":"2026-08-04T01:12:34.693Z","tick":5640,"actorId":"dev-5","commandId":1592259,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592259,"issued_at_tick":5640,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"ed1b5d24561aeb5e806c33572ea89332f3e212d5f972d75517fb24f48d53d69a"}} +{"type":"command.receipt","at":"2026-08-04T01:12:34.893Z","tick":5646,"actorId":"dev-5","commandId":1592261,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592261,"issued_at_tick":5646,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"bf9ac6d22481e2eadd2ac244b44b5d316181cd559ed2d87f94cc12a873a9b9ed"}} +{"type":"command.receipt","at":"2026-08-04T01:12:35.093Z","tick":5652,"actorId":"dev-5","commandId":1592263,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592263,"issued_at_tick":5652,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"02d9b7cbd0a13145108ad5f6efae39b0f8247242b618a30bf0436d24f3786a7e"}} +{"type":"command.receipt","at":"2026-08-04T01:12:35.293Z","tick":5658,"actorId":"dev-5","commandId":1592265,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592265,"issued_at_tick":5658,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"be60cf89e10ed08cd39d7d404ce225a64f9b5e291f31c617bbf1d748dfb7248e"}} +{"type":"command.receipt","at":"2026-08-04T01:12:35.493Z","tick":5664,"actorId":"dev-5","commandId":1592267,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592267,"issued_at_tick":5664,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"5005bcc5abe93e6e3de4d7bfe0b9208cf61a17ec32c61d6671eaf0a9f9dba0d8"}} +{"type":"command.receipt","at":"2026-08-04T01:12:35.693Z","tick":5670,"actorId":"dev-5","commandId":1592269,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592269,"issued_at_tick":5670,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"eb4b7d119fd06b2d8916fd04f29b2434e1da872224294b7a60b77a9556543c36"}} +{"type":"command.receipt","at":"2026-08-04T01:12:35.893Z","tick":5676,"actorId":"dev-5","commandId":1592271,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592271,"issued_at_tick":5676,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"cedae823b49a9c70ed077a8ffa61916051e0e69267cc11f976b88edfc4950b5c"}} +{"type":"command.receipt","at":"2026-08-04T01:12:36.094Z","tick":5682,"actorId":"dev-5","commandId":1592273,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592273,"issued_at_tick":5682,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"c9c89d8d90583b719048e04c25db37bbe5f2fe42937c2a081e435d9062beb867"}} +{"type":"checkpoint","at":"2026-08-04T01:12:36.210Z","tick":5685,"reason":"interval","actorCount":2,"stateHash":"9fbc51ea59fcc1c4a56aaa4f8be9f4469198015ec88217c5d933097a62ef5162"} +{"type":"command.receipt","at":"2026-08-04T01:12:36.294Z","tick":5688,"actorId":"dev-5","commandId":1592275,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592275,"issued_at_tick":5688,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"f4f24be3216234f73940e08a8de12f4535d22ba9a383d4822597b1054b2fc1aa"}} +{"type":"command.receipt","at":"2026-08-04T01:12:36.494Z","tick":5694,"actorId":"dev-5","commandId":1592277,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592277,"issued_at_tick":5694,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"2c8236f5348e53fd4c273c98cfea5d7d7029eaebc281406b20edd2e7fba41c1c"}} +{"type":"command.receipt","at":"2026-08-04T01:12:36.694Z","tick":5700,"actorId":"dev-5","commandId":1592279,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592279,"issued_at_tick":5700,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"7a7cbf74981e38fdfa4fb3c95a70aeffc00982f3beb02e5d18d93a54cf596da1"}} +{"type":"command.receipt","at":"2026-08-04T01:12:36.895Z","tick":5706,"actorId":"dev-5","commandId":1592281,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592281,"issued_at_tick":5706,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"ec8c9932de17382eb08663e9edd014342d007cf4d35adb4e15ab43df2ab01bab"}} +{"type":"command.receipt","at":"2026-08-04T01:12:37.093Z","tick":5712,"actorId":"dev-5","commandId":1592283,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592283,"issued_at_tick":5712,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"8a22315c2f3f5aa9789054eb371368cf6caf8d124f8954e38b0a1a4d3286c293"}} +{"type":"command.receipt","at":"2026-08-04T01:12:37.294Z","tick":5718,"actorId":"dev-5","commandId":1592285,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592285,"issued_at_tick":5718,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"3778d59436d35960942ca8e12e2ed4a4b1ddde00821bdac9ad3773c6c9ec3187"}} +{"type":"command.receipt","at":"2026-08-04T01:12:37.494Z","tick":5724,"actorId":"dev-5","commandId":1592287,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592287,"issued_at_tick":5724,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b1a85d1bfcaf7334e5dedee9e349a74e849de526748eea41d6676e86cf4de80f"}} +{"type":"command.receipt","at":"2026-08-04T01:12:37.694Z","tick":5730,"actorId":"dev-5","commandId":1592289,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592289,"issued_at_tick":5730,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"6dfdc5ac92599d89def008eb35c1069dea25018148b7f7ed83053ae282a2d738"}} +{"type":"command.receipt","at":"2026-08-04T01:12:37.895Z","tick":5736,"actorId":"dev-5","commandId":1592291,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592291,"issued_at_tick":5736,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"1d91a92fe91fc619fe168d45d75be4957d418ae2b4fd7cf779d442a241717fce"}} +{"type":"command.receipt","at":"2026-08-04T01:12:38.095Z","tick":5742,"actorId":"dev-5","commandId":1592293,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592293,"issued_at_tick":5742,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"6a756e882b9d088491859ed5f470f4de2cf181e5124779bd1d7863820a9500b5"}} +{"type":"command.receipt","at":"2026-08-04T01:12:38.294Z","tick":5748,"actorId":"dev-5","commandId":1592295,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592295,"issued_at_tick":5748,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"efc6c6d64c1c511aaa5c257cf002554997e7b47467ee376819710a643cc1846c"}} +{"type":"command.receipt","at":"2026-08-04T01:12:38.493Z","tick":5754,"actorId":"dev-5","commandId":1592297,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592297,"issued_at_tick":5754,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"57d3eeb71279e515e958e7a50420e887dc4edd811d43abdef5097735b7d37f80"}} +{"type":"command.receipt","at":"2026-08-04T01:12:38.694Z","tick":5760,"actorId":"dev-5","commandId":1592299,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592299,"issued_at_tick":5760,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b635fa8086d6afd77dceca238a5c48b3589e5a3c26d2692dc759570205a564b1"}} +{"type":"command.receipt","at":"2026-08-04T01:12:38.895Z","tick":5766,"actorId":"dev-5","commandId":1592301,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592301,"issued_at_tick":5766,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"67bf7ea162334f5a6d92bc8888396434910e9385ba7c83be2e9e4537099c34be"}} +{"type":"command.receipt","at":"2026-08-04T01:12:39.095Z","tick":5772,"actorId":"dev-5","commandId":1592303,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592303,"issued_at_tick":5772,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"d5b11995266d057cb12a2c3422dc49730756b46e50a0462518d8c83c18cee3b4"}} +{"type":"command.receipt","at":"2026-08-04T01:12:39.294Z","tick":5778,"actorId":"dev-5","commandId":1592305,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592305,"issued_at_tick":5778,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"f0cf967d0c8db58d8891bed335b53777c4f7d6a6c56dc3d1e66e65c51ca9b373"}} +{"type":"command.receipt","at":"2026-08-04T01:12:39.494Z","tick":5784,"actorId":"dev-5","commandId":1592307,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592307,"issued_at_tick":5784,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"53f1999eccd82c928daf5ff66a157973422bf413118ba99fcadf7a5f4c347452"}} +{"type":"command.receipt","at":"2026-08-04T01:12:39.696Z","tick":5790,"actorId":"dev-5","commandId":1592309,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592309,"issued_at_tick":5790,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"1c950e3a090d75847769ce4160dc5a4a71f8b15338d2e10fad56e34a67fbea77"}} +{"type":"command.receipt","at":"2026-08-04T01:12:39.895Z","tick":5796,"actorId":"dev-5","commandId":1592311,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592311,"issued_at_tick":5796,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"3e97af069a354de339edbfa2480a67f29e32648f14dbd869b4f8a57bea2118f8"}} +{"type":"command.receipt","at":"2026-08-04T01:12:40.095Z","tick":5802,"actorId":"dev-5","commandId":1592313,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592313,"issued_at_tick":5802,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"71499d35626ae642925cae71340b250c86a684b3e9fd98c7459d9e21b1b2c7ed"}} +{"type":"command.receipt","at":"2026-08-04T01:12:40.293Z","tick":5808,"actorId":"dev-5","commandId":1592315,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592315,"issued_at_tick":5808,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"c7c57bdccc1ffd8b86ed246cca09c0c7f2c25a9e56d2d2f81fdb671be8f1f7eb"}} +{"type":"command.receipt","at":"2026-08-04T01:12:40.495Z","tick":5814,"actorId":"dev-5","commandId":1592317,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592317,"issued_at_tick":5814,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"a1102a18fcfa0353de99ba96c7eba5cd2c5d5012215481aef86617019cac8214"}} +{"type":"command.receipt","at":"2026-08-04T01:12:40.695Z","tick":5820,"actorId":"dev-5","commandId":1592319,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592319,"issued_at_tick":5820,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"180c8baecd2a4f661862e2d75b910521ec2f43855db9b3690649b79673cf095c"}} +{"type":"command.receipt","at":"2026-08-04T01:12:40.894Z","tick":5826,"actorId":"dev-5","commandId":1592321,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592321,"issued_at_tick":5826,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"e3f7cf48fb3a93d0dcdcbeeebe750768e0905aef28830c41ed9fdc6aceae3bb1"}} +{"type":"command.receipt","at":"2026-08-04T01:12:41.094Z","tick":5832,"actorId":"dev-5","commandId":1592323,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592323,"issued_at_tick":5832,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"9b583dfd1b4d0a83b378131da25375e1906dc04c81a537821a77220a5a9ccc7b"}} +{"type":"checkpoint","at":"2026-08-04T01:12:41.217Z","tick":5836,"reason":"interval","actorCount":2,"stateHash":"10d9c1f76d608b8d60f0c2b86cd1381bf1104ef258dc2361e66426607a9b4add"} +{"type":"command.receipt","at":"2026-08-04T01:12:41.293Z","tick":5838,"actorId":"dev-5","commandId":1592325,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592325,"issued_at_tick":5838,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"fd4c1f9bb9a678a91b6d66e96ac64b3b73f273e2799b4d6c7bf2b81258e306d3"}} +{"type":"command.receipt","at":"2026-08-04T01:12:41.494Z","tick":5844,"actorId":"dev-5","commandId":1592327,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592327,"issued_at_tick":5844,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"7b99f245fe9830086688b1271e8e9a432f52b032ec6b0d9ff6142744bf06a224"}} +{"type":"command.receipt","at":"2026-08-04T01:12:41.694Z","tick":5850,"actorId":"dev-5","commandId":1592329,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592329,"issued_at_tick":5850,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"eae45af07369427c4eeffcbe93a3633b4248a023beb8fde03773a431facbcf3a"}} +{"type":"command.receipt","at":"2026-08-04T01:12:41.894Z","tick":5856,"actorId":"dev-5","commandId":1592331,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592331,"issued_at_tick":5856,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"7a0f188d395d20c8b2d4b3d5971c3f5deb5d41c7c39b9f7ff4b6e2060fb58c88"}} +{"type":"command.receipt","at":"2026-08-04T01:12:42.094Z","tick":5862,"actorId":"dev-5","commandId":1592333,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592333,"issued_at_tick":5862,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"e9f31d1ca035ab09dbe940844188cbb18d7bd8eb09a6b69e705d70cea282a074"}} +{"type":"command.receipt","at":"2026-08-04T01:12:42.294Z","tick":5868,"actorId":"dev-5","commandId":1592335,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592335,"issued_at_tick":5868,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"50916481b6478272c775f6b10620032002df28e7a18f022e782945b35630f464"}} +{"type":"command.receipt","at":"2026-08-04T01:12:42.495Z","tick":5874,"actorId":"dev-5","commandId":1592337,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592337,"issued_at_tick":5874,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"300dee766f1569ce5b3f57a6cbdcaa13b5926005aa2e8ba434bf97329b0edaa1"}} +{"type":"command.receipt","at":"2026-08-04T01:12:42.694Z","tick":5880,"actorId":"dev-5","commandId":1592339,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592339,"issued_at_tick":5880,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"8f5d4e60908ad7f030cb39d242179e893695513f79271f7e210a9ee24b39d205"}} +{"type":"command.receipt","at":"2026-08-04T01:12:42.894Z","tick":5886,"actorId":"dev-5","commandId":1592341,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592341,"issued_at_tick":5886,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"d5cf3b01f271ef60c95756aa0e47586d4ccf2959d1410b4541a5001369e1396d"}} +{"type":"command.receipt","at":"2026-08-04T01:12:43.094Z","tick":5892,"actorId":"dev-5","commandId":1592343,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592343,"issued_at_tick":5892,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"3135e1876f1a16b1ad8b90af7b05b1bdcb71b1cbae13fa132b5eb545f375331d"}} +{"type":"command.receipt","at":"2026-08-04T01:12:43.295Z","tick":5898,"actorId":"dev-5","commandId":1592345,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592345,"issued_at_tick":5898,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"17655a218952cac39ae6beab9b51d59f8f1e2111014448ff940c43c8ace3af03"}} +{"type":"command.receipt","at":"2026-08-04T01:12:43.494Z","tick":5904,"actorId":"dev-5","commandId":1592347,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592347,"issued_at_tick":5904,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"95a9a3d4100994988387c646645aef8c12f96b35402b338dde6d264b215b3a2f"}} +{"type":"command.receipt","at":"2026-08-04T01:12:43.695Z","tick":5910,"actorId":"dev-5","commandId":1592349,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592349,"issued_at_tick":5910,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b1f7f30481641dfe098cf69aa56d78ad5c7f157ebb1f3f512b02e74a432972ff"}} +{"type":"command.receipt","at":"2026-08-04T01:12:43.895Z","tick":5916,"actorId":"dev-5","commandId":1592351,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592351,"issued_at_tick":5916,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"f76e9c09b111c5da5a00241bd0a4a83b501db1d2efdda1c39d6fe0f861f31700"}} +{"type":"command.receipt","at":"2026-08-04T01:12:44.094Z","tick":5922,"actorId":"dev-5","commandId":1592353,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592353,"issued_at_tick":5922,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"6d12810281afd85693712b6907bce55c7dde8a837fb5cbac184f9fe311532a8d"}} +{"type":"command.receipt","at":"2026-08-04T01:12:44.295Z","tick":5928,"actorId":"dev-5","commandId":1592355,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592355,"issued_at_tick":5928,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"23b1d19db26a0fb671fbc2479de4fff5dd7528998983c215e4b586c373914c66"}} +{"type":"command.receipt","at":"2026-08-04T01:12:44.494Z","tick":5934,"actorId":"dev-5","commandId":1592357,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592357,"issued_at_tick":5934,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"eea93845e7eb67be5f2e333ffcb3adc0b9f75fa0d75c0f5059c747e5033a5d5d"}} +{"type":"command.receipt","at":"2026-08-04T01:12:44.694Z","tick":5940,"actorId":"dev-5","commandId":1592359,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592359,"issued_at_tick":5940,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"790ce26e03e34d233288def3b82d90e1abf0543bf8cfef12a28e7dcdc4c673f7"}} +{"type":"command.receipt","at":"2026-08-04T01:12:44.894Z","tick":5946,"actorId":"dev-5","commandId":1592361,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592361,"issued_at_tick":5946,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"bcc2585bb88996bee9fd898d2e3be96aa41a6dbd535a9321ecad5af20281905a"}} +{"type":"command.receipt","at":"2026-08-04T01:12:45.097Z","tick":5952,"actorId":"dev-5","commandId":1592363,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592363,"issued_at_tick":5952,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b62095bfa94d85ba6455c2b609aea528be1343a1b5111ed8807273de91181523"}} +{"type":"command.receipt","at":"2026-08-04T01:12:45.294Z","tick":5958,"actorId":"dev-5","commandId":1592365,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592365,"issued_at_tick":5958,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"44777223d83659311ff50156e0001b65dfa6dbf9263a5f50c29c3fb2f2875595"}} +{"type":"command.receipt","at":"2026-08-04T01:12:45.493Z","tick":5964,"actorId":"dev-5","commandId":1592367,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592367,"issued_at_tick":5964,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"bf608bcf336241739f01195b97f5fc91c532278f929fd8fb06fc6c8b5767533d"}} +{"type":"command.receipt","at":"2026-08-04T01:12:45.695Z","tick":5970,"actorId":"dev-5","commandId":1592369,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592369,"issued_at_tick":5970,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"e3cf974f330b1826575b46333a001787212750b4e31df178c32edc2bb9496198"}} +{"type":"command.receipt","at":"2026-08-04T01:12:45.894Z","tick":5976,"actorId":"dev-5","commandId":1592371,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592371,"issued_at_tick":5976,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"53153823a2f19e9203ffafc66797767158434240912d00a5ed7c383aaa206432"}} +{"type":"command.receipt","at":"2026-08-04T01:12:46.094Z","tick":5982,"actorId":"dev-5","commandId":1592373,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592373,"issued_at_tick":5982,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"5818e9a9e7a99c203a6216f75b3983f3271a56568b7acea2c9244a79fd150864"}} +{"type":"checkpoint","at":"2026-08-04T01:12:46.223Z","tick":5986,"reason":"interval","actorCount":2,"stateHash":"25397a98ad7877b990cfa0f74848e2f4a45f2a25ee7d386a894b101f6aad112c"} +{"type":"command.receipt","at":"2026-08-04T01:12:46.293Z","tick":5988,"actorId":"dev-5","commandId":1592375,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592375,"issued_at_tick":5988,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"a5822f4a2a32c10c86c69c6f4f96c6471b6b27a58a1d56f8c64162e1de26165d"}} +{"type":"command.receipt","at":"2026-08-04T01:12:46.495Z","tick":5994,"actorId":"dev-5","commandId":1592377,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592377,"issued_at_tick":5994,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"e60244a92b1c7070f746341b8d213d5a5e28a63047fa4a9cf85346d1cbff61ca"}} +{"type":"command.receipt","at":"2026-08-04T01:12:46.696Z","tick":6000,"actorId":"dev-5","commandId":1592379,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592379,"issued_at_tick":6000,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"8e253785a2b1849a6381e00a4d03a1a08f6c0ab5d17f822b766da88a27a78344"}} +{"type":"command.receipt","at":"2026-08-04T01:12:46.893Z","tick":6006,"actorId":"dev-5","commandId":1592381,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592381,"issued_at_tick":6006,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"5b716b03c87c89fd19efc47d2b7f29819b7b38f841d7e419d11fdd7a683912e8"}} +{"type":"command.receipt","at":"2026-08-04T01:12:47.095Z","tick":6012,"actorId":"dev-5","commandId":1592383,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592383,"issued_at_tick":6012,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"df7c0af02543a7b41f80817dcafb3bbe371fc530f98e086c556a47b08362ffae"}} +{"type":"command.receipt","at":"2026-08-04T01:12:47.293Z","tick":6018,"actorId":"dev-5","commandId":1592385,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592385,"issued_at_tick":6018,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"659945c9700ac11959436551af57985c66a556be8b7318d92fae08ed56ad8147"}} +{"type":"command.receipt","at":"2026-08-04T01:12:47.494Z","tick":6024,"actorId":"dev-5","commandId":1592387,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592387,"issued_at_tick":6024,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"97b3dac28d1aef88599f02dc5ed31aadfa3987a3e1c4dfce90cd34cd5a3fe911"}} +{"type":"command.receipt","at":"2026-08-04T01:12:47.694Z","tick":6030,"actorId":"dev-5","commandId":1592389,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592389,"issued_at_tick":6030,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"3b2ae3d2d46b1b882f10f87f5ce72f5d3f853a2abcc59659e441f3a412028a27"}} +{"type":"command.receipt","at":"2026-08-04T01:12:47.893Z","tick":6036,"actorId":"dev-5","commandId":1592391,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592391,"issued_at_tick":6036,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"360f0257c58c86b7da5b569c89abcef8696dc7bce347b7455e5d333360bb8a5d"}} +{"type":"command.receipt","at":"2026-08-04T01:12:48.094Z","tick":6042,"actorId":"dev-5","commandId":1592393,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592393,"issued_at_tick":6042,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"51e97c4464544afe1b67835d3872344de9988a824b7497a01acb222b95339894"}} +{"type":"command.receipt","at":"2026-08-04T01:12:48.294Z","tick":6048,"actorId":"dev-5","commandId":1592395,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592395,"issued_at_tick":6048,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b5de992147353c9ee9549607601face4cfac9004f7754684dfa40637ace9cebd"}} +{"type":"command.receipt","at":"2026-08-04T01:12:48.495Z","tick":6054,"actorId":"dev-5","commandId":1592397,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592397,"issued_at_tick":6054,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"39833fb5dc3968e1c9cc9576771034fe0a08190117cd27535b148f8eb3c0a84c"}} +{"type":"command.receipt","at":"2026-08-04T01:12:48.694Z","tick":6060,"actorId":"dev-5","commandId":1592399,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592399,"issued_at_tick":6060,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"dc31db5e8216563ca873d10478c3bca438b13a41ddf56c7e930982ddb5f4067f"}} +{"type":"command.receipt","at":"2026-08-04T01:12:48.894Z","tick":6066,"actorId":"dev-5","commandId":1592401,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592401,"issued_at_tick":6066,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"059b639376db00924da691a2879a3d5055ab4b6d7097929ebbc3a8a709517c3d"}} +{"type":"command.receipt","at":"2026-08-04T01:12:49.096Z","tick":6072,"actorId":"dev-5","commandId":1592403,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592403,"issued_at_tick":6072,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"582a515a3f398466a5fec71e010ea6998da7b43aefa6f4bcab04fe5b383467ed"}} +{"type":"command.receipt","at":"2026-08-04T01:12:49.294Z","tick":6078,"actorId":"dev-5","commandId":1592405,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592405,"issued_at_tick":6078,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"0357be8187665027759454ab1c746f4a2a2126301de6be24bffd78c44afbe10e"}} +{"type":"command.receipt","at":"2026-08-04T01:12:49.494Z","tick":6084,"actorId":"dev-5","commandId":1592407,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592407,"issued_at_tick":6084,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"76a4d09ad4eef442ae1523e28b779bd6c84b26aa667a29cb4be90bff2c3c40fe"}} +{"type":"command.receipt","at":"2026-08-04T01:12:49.694Z","tick":6090,"actorId":"dev-5","commandId":1592409,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592409,"issued_at_tick":6090,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"a981194b85409966bcd70048d14a0c519d93ab1a6938de474d7d6b8b138d050b"}} +{"type":"command.receipt","at":"2026-08-04T01:12:49.895Z","tick":6096,"actorId":"dev-5","commandId":1592411,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592411,"issued_at_tick":6096,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"aa7f79202bca34e2e056db8b257d49bdbb5f74abf67537410cb96da72fe6f678"}} +{"type":"command.receipt","at":"2026-08-04T01:12:50.096Z","tick":6102,"actorId":"dev-5","commandId":1592413,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592413,"issued_at_tick":6102,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"37e7a3650ce61a0005d87a753044d7b08bef9f180a5e78f063c4aa24b29562ba"}} +{"type":"command.receipt","at":"2026-08-04T01:12:50.294Z","tick":6108,"actorId":"dev-5","commandId":1592415,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592415,"issued_at_tick":6108,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"2037235b305b654e48794c344833a047146ceb9f8ec2ff970430ac324c04685d"}} +{"type":"command.receipt","at":"2026-08-04T01:12:50.493Z","tick":6114,"actorId":"dev-5","commandId":1592417,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592417,"issued_at_tick":6114,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"6737e7a1f1ef221fc055b2e9a7071477749497abc3c1a4689a530dce73c1bc51"}} +{"type":"command.receipt","at":"2026-08-04T01:12:50.694Z","tick":6120,"actorId":"dev-5","commandId":1592419,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592419,"issued_at_tick":6120,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"8a1a0181ea371e4d7f5231ad77808a4c3344516123ef1eff3cd671cca5146f1e"}} +{"type":"command.receipt","at":"2026-08-04T01:12:50.893Z","tick":6126,"actorId":"dev-5","commandId":1592421,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592421,"issued_at_tick":6126,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"f10ab9f8daaa26a0d2428bde1c8a5ea07ad452902244276b66c44770b18be38b"}} +{"type":"command.receipt","at":"2026-08-04T01:12:51.097Z","tick":6132,"actorId":"dev-5","commandId":1592423,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592423,"issued_at_tick":6132,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"052ac1ffdc5cab3da88a1afd951f8269ff742a3cdca196f5bf41099ee485004e"}} +{"type":"checkpoint","at":"2026-08-04T01:12:51.243Z","tick":6136,"reason":"interval","actorCount":2,"stateHash":"891624b3f061eab28044b42759bc07541f6abb08a3197f2d5deb72ec1b6239a9"} +{"type":"command.receipt","at":"2026-08-04T01:12:51.293Z","tick":6138,"actorId":"dev-5","commandId":1592425,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592425,"issued_at_tick":6138,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b2bd0b1234c7d8bbc86fc13447a32099a523133f0a7e566afd3f241028260543"}} +{"type":"command.receipt","at":"2026-08-04T01:12:51.495Z","tick":6144,"actorId":"dev-5","commandId":1592427,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592427,"issued_at_tick":6144,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"ea35ee3b380bced39b3bdf827fa6cfcec4484df1175c299b7baec75b8b871356"}} +{"type":"command.receipt","at":"2026-08-04T01:12:51.695Z","tick":6150,"actorId":"dev-5","commandId":1592429,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592429,"issued_at_tick":6150,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"820e2c5d261b1251978250f08b76ece86abc3e0b5e674da028b980183bb35844"}} +{"type":"command.receipt","at":"2026-08-04T01:12:51.896Z","tick":6156,"actorId":"dev-5","commandId":1592431,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592431,"issued_at_tick":6156,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b0ca1667c1af24af756b1c5a7d20d6e6e3c921ab6af83663c7320af7c2f55c1f"}} +{"type":"command.receipt","at":"2026-08-04T01:12:52.096Z","tick":6162,"actorId":"dev-5","commandId":1592433,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592433,"issued_at_tick":6162,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"a4d6cc7e1a00519a76c153cb2d28114bef08f9d1eb648296019e3648badcf082"}} +{"type":"command.receipt","at":"2026-08-04T01:12:52.295Z","tick":6168,"actorId":"dev-5","commandId":1592435,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592435,"issued_at_tick":6168,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"a39b9923b18ada350e55a60b24e87061d4ac8cc047b529bb3658738b4d57eda3"}} +{"type":"command.receipt","at":"2026-08-04T01:12:52.494Z","tick":6174,"actorId":"dev-5","commandId":1592437,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592437,"issued_at_tick":6174,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"d28905d352bb9a63c281211946f4fed044f9514b34cccad0cfc8b2a3fa3d8727"}} +{"type":"command.receipt","at":"2026-08-04T01:12:52.694Z","tick":6180,"actorId":"dev-5","commandId":1592439,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592439,"issued_at_tick":6180,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"0b167ad69ca6f3715a2148baf967186edc3c55d72908e897a7ca8c7d73f0b148"}} +{"type":"command.receipt","at":"2026-08-04T01:12:52.894Z","tick":6186,"actorId":"dev-5","commandId":1592441,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592441,"issued_at_tick":6186,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"842c63574e940e438c4f2de8633e9e063e252f4f632f9bbab1af9fc6a56317be"}} +{"type":"command.receipt","at":"2026-08-04T01:12:53.093Z","tick":6192,"actorId":"dev-5","commandId":1592443,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592443,"issued_at_tick":6192,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"dd405da63fc4657ad5acf72b0c75276a0dedb0f7c3f13b22bd21542057842025"}} +{"type":"command.receipt","at":"2026-08-04T01:12:53.293Z","tick":6198,"actorId":"dev-5","commandId":1592445,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592445,"issued_at_tick":6198,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"1df00719058fc9d22cc8c4bc17ef42e88e80422a18e30dca7a439e56e476e2d6"}} +{"type":"command.receipt","at":"2026-08-04T01:12:53.494Z","tick":6204,"actorId":"dev-5","commandId":1592447,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592447,"issued_at_tick":6204,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b112770f3baec58821a8978c7491be67b9d39d1823650dc8169da06ef85d7703"}} +{"type":"command.receipt","at":"2026-08-04T01:12:53.693Z","tick":6210,"actorId":"dev-5","commandId":1592449,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592449,"issued_at_tick":6210,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"fa49bc5bef170d1cabef05fc51bcfd5b96b648873a24b4b0b0f5475dce3ca3c6"}} +{"type":"command.receipt","at":"2026-08-04T01:12:53.894Z","tick":6216,"actorId":"dev-5","commandId":1592451,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592451,"issued_at_tick":6216,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"286f707324efb347158cf0b8563917717990902993905ece4101ce8149ed1e80"}} +{"type":"command.receipt","at":"2026-08-04T01:12:54.096Z","tick":6222,"actorId":"dev-5","commandId":1592453,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592453,"issued_at_tick":6222,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"bae27601c63bdf91cff738b65bfc9ee1120139c386adae3a86fa26d303b3b510"}} +{"type":"command.receipt","at":"2026-08-04T01:12:54.294Z","tick":6228,"actorId":"dev-5","commandId":1592455,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592455,"issued_at_tick":6228,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"c188e9a55acc10750fc5758066defb7438180f9afe2f81c0f1b83ec98f34063f"}} +{"type":"command.receipt","at":"2026-08-04T01:12:54.495Z","tick":6234,"actorId":"dev-5","commandId":1592457,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592457,"issued_at_tick":6234,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"f4b4b8d2ad44f3c118e58122865fc921ce4252fd83dbdeb6e041f7daa39627db"}} +{"type":"command.receipt","at":"2026-08-04T01:12:54.694Z","tick":6240,"actorId":"dev-5","commandId":1592459,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592459,"issued_at_tick":6240,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"3e674023b42b58035840cedf173901060b22f807443d93c98556c6b7154e0db9"}} +{"type":"command.receipt","at":"2026-08-04T01:12:54.893Z","tick":6246,"actorId":"dev-5","commandId":1592461,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592461,"issued_at_tick":6246,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"a561d40ce0d593029f4a727f6fdd9b2d7cf70852e781d15697b28f8f8df92f4b"}} +{"type":"command.receipt","at":"2026-08-04T01:12:55.096Z","tick":6252,"actorId":"dev-5","commandId":1592463,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592463,"issued_at_tick":6252,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"fdbf811ccf7e40d40572dac68371b06122794ae56976024821070ce7806634f7"}} +{"type":"command.receipt","at":"2026-08-04T01:12:55.294Z","tick":6258,"actorId":"dev-5","commandId":1592465,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592465,"issued_at_tick":6258,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"f0a036a801f2b766d5041a83eb99657c8f65b72686d55c0ac9dec7baaf953c75"}} +{"type":"command.receipt","at":"2026-08-04T01:12:55.494Z","tick":6264,"actorId":"dev-5","commandId":1592467,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592467,"issued_at_tick":6264,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"c360a03db9d6913aae08ddcdc7e323876ce5983eac3d281e48c971311c4eb981"}} +{"type":"command.receipt","at":"2026-08-04T01:12:55.694Z","tick":6270,"actorId":"dev-5","commandId":1592469,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592469,"issued_at_tick":6270,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"2f5ef6798bcdb7bf49733ded7e1d638d9f18aa305563d74bd80a884724c50aeb"}} +{"type":"command.receipt","at":"2026-08-04T01:12:55.895Z","tick":6276,"actorId":"dev-5","commandId":1592471,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592471,"issued_at_tick":6276,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"ed79aacbc4ebe40554065283ab4470ca2f1489be2f8e2d26170e625864f97696"}} +{"type":"command.receipt","at":"2026-08-04T01:12:56.095Z","tick":6282,"actorId":"dev-5","commandId":1592473,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592473,"issued_at_tick":6282,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"c532f7be55877f4107fbde4bf63788e0f134fbb9ea85c636c648bf531e6b3c2c"}} +{"type":"checkpoint","at":"2026-08-04T01:12:56.250Z","tick":6287,"reason":"interval","actorCount":2,"stateHash":"81f720b2e7843ffb0fa07d899b2da45433d7f5939dac14924623db02aa824421"} +{"type":"command.receipt","at":"2026-08-04T01:12:56.294Z","tick":6288,"actorId":"dev-5","commandId":1592475,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592475,"issued_at_tick":6288,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"af3c97d3168096ea03bbf275a1692085ed955e400ded7773a9f36ce29e18aa30"}} +{"type":"command.receipt","at":"2026-08-04T01:12:56.493Z","tick":6294,"actorId":"dev-5","commandId":1592477,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592477,"issued_at_tick":6294,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"06fc5a471832d58802174d5947fea9cde92aac12a54fa263a3008da2346cbf7c"}} +{"type":"command.receipt","at":"2026-08-04T01:12:56.694Z","tick":6300,"actorId":"dev-5","commandId":1592479,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592479,"issued_at_tick":6300,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"bcef0f30d84e817f2a1e0b3441ee40ccc86417b6b3c0c8ac0492f0102279fe74"}} +{"type":"command.receipt","at":"2026-08-04T01:12:56.894Z","tick":6306,"actorId":"dev-5","commandId":1592481,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592481,"issued_at_tick":6306,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"0d4009c389ea50acb6d068a98d6387d109b52fb9bb60e9000a1e44d56331b46d"}} +{"type":"command.receipt","at":"2026-08-04T01:12:57.094Z","tick":6312,"actorId":"dev-5","commandId":1592483,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592483,"issued_at_tick":6312,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"8d6d9c2aeb65ac324d293f097e04949bc672ac75d13cf2bfb9c4e35fa09cf15c"}} +{"type":"command.receipt","at":"2026-08-04T01:12:57.294Z","tick":6318,"actorId":"dev-5","commandId":1592485,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592485,"issued_at_tick":6318,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b59e533a610accda169cdbe1e94af3866e6ab9dc75c49a5582115f43e55deeb0"}} +{"type":"command.receipt","at":"2026-08-04T01:12:57.495Z","tick":6324,"actorId":"dev-5","commandId":1592487,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592487,"issued_at_tick":6324,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"393b64ebb79ae38fa33252cdddc80ee2322c71e2c6ad384e147e14882097a225"}} +{"type":"command.receipt","at":"2026-08-04T01:12:57.694Z","tick":6330,"actorId":"dev-5","commandId":1592489,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592489,"issued_at_tick":6330,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"51e92313c7a40d9658c6de0769ad8b5cc3a23a982b4031434b56ae98a4bc8440"}} +{"type":"command.receipt","at":"2026-08-04T01:12:57.895Z","tick":6336,"actorId":"dev-5","commandId":1592491,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592491,"issued_at_tick":6336,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"e19acbbc88acc4efc382c897f462a8ee206c9641c6c0cd00df6686a2829e3a7c"}} +{"type":"command.receipt","at":"2026-08-04T01:12:58.094Z","tick":6342,"actorId":"dev-5","commandId":1592493,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592493,"issued_at_tick":6342,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"a51517b0e1abd26c4f8793e6df513ae5a0602c7e72c5641b6ecde43feefdf663"}} +{"type":"command.receipt","at":"2026-08-04T01:12:58.294Z","tick":6348,"actorId":"dev-5","commandId":1592495,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592495,"issued_at_tick":6348,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"ba76743d74396ad034a02bb0d91a4226742e5ce781e7144eb420718dcc9bfc1a"}} +{"type":"command.receipt","at":"2026-08-04T01:12:58.494Z","tick":6354,"actorId":"dev-5","commandId":1592497,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592497,"issued_at_tick":6354,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"4d8e125353686f3e19ffc40ec04fbc7cd6eee6c8ffbd381a0fdc937157d62829"}} +{"type":"command.receipt","at":"2026-08-04T01:12:58.695Z","tick":6360,"actorId":"dev-5","commandId":1592499,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592499,"issued_at_tick":6360,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"3fbac0e63ed9affe9767ef80832e6fa3c8acaf45ae7c35dc3175455f170e7fb5"}} +{"type":"command.receipt","at":"2026-08-04T01:12:58.894Z","tick":6366,"actorId":"dev-5","commandId":1592501,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592501,"issued_at_tick":6366,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"307ffcd34783c00206f24820418e171cc07738f73f92bf4705f4da8616503429"}} +{"type":"command.receipt","at":"2026-08-04T01:12:59.096Z","tick":6372,"actorId":"dev-5","commandId":1592503,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592503,"issued_at_tick":6372,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b4afaad2fa22936ccfe8185a3cffa5007dac1c8e72265d69b40cd350365a7512"}} +{"type":"command.receipt","at":"2026-08-04T01:12:59.295Z","tick":6378,"actorId":"dev-5","commandId":1592505,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592505,"issued_at_tick":6378,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b2bf5b455e3028dcb2a6137a041bac44ddc1d89eac0609ddd5fe82590ec9a23a"}} +{"type":"command.receipt","at":"2026-08-04T01:12:59.494Z","tick":6384,"actorId":"dev-5","commandId":1592507,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592507,"issued_at_tick":6384,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"662371703ec9b8ea21f5b10da15aa33fcdad3769aba157db10b18fa7c296d72f"}} +{"type":"command.receipt","at":"2026-08-04T01:12:59.696Z","tick":6390,"actorId":"dev-5","commandId":1592509,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592509,"issued_at_tick":6390,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"190b10aeeafefae7c65194abb87d9cdb6019506a97ae07df975e1e3d4831ba6a"}} +{"type":"command.receipt","at":"2026-08-04T01:12:59.895Z","tick":6396,"actorId":"dev-5","commandId":1592511,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592511,"issued_at_tick":6396,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"1daf7830e2541745bc5652d111d012e5013029d54078be7e59b60d8b99dcb0af"}} +{"type":"command.receipt","at":"2026-08-04T01:13:00.095Z","tick":6402,"actorId":"dev-5","commandId":1592513,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592513,"issued_at_tick":6402,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"6797068de8e92c6fee71c87cb620959bd788a8d6e31ead3f8c9f9fc0ed37bd17"}} +{"type":"command.receipt","at":"2026-08-04T01:13:00.294Z","tick":6408,"actorId":"dev-5","commandId":1592515,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592515,"issued_at_tick":6408,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"619942cd8a3b10e33c826767ef44e7c377416fb0c72360a8af30c09e316e4db9"}} +{"type":"command.receipt","at":"2026-08-04T01:13:00.495Z","tick":6414,"actorId":"dev-5","commandId":1592517,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592517,"issued_at_tick":6414,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"0077f0c054ed8dd2157bd5c2a2fe9fac1a28a81fb08490b81cfd31a0860b9991"}} +{"type":"command.receipt","at":"2026-08-04T01:13:00.694Z","tick":6420,"actorId":"dev-5","commandId":1592519,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592519,"issued_at_tick":6420,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"f652b2a9de87a00b0c89ddaf68950322000f774b2d38cca6a1e2c9764f7ae0d3"}} +{"type":"command.receipt","at":"2026-08-04T01:13:00.895Z","tick":6426,"actorId":"dev-5","commandId":1592521,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592521,"issued_at_tick":6426,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"266dbdc01a7007c4768b32544fa2e0cba91d31c5e3966b707b81d87c0e68e70c"}} +{"type":"command.receipt","at":"2026-08-04T01:13:01.093Z","tick":6432,"actorId":"dev-5","commandId":1592523,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592523,"issued_at_tick":6432,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"f6ec3eafa12fb1bab5d1dfb8c0f7b323d0be5cda96a5f4acf4635a19dbd7f379"}} +{"type":"checkpoint","at":"2026-08-04T01:13:01.268Z","tick":6437,"reason":"interval","actorCount":2,"stateHash":"cdd84b18efb7996e43ab3413e013f8fb98365891e81221439ea2ee0757c06be0"} +{"type":"command.receipt","at":"2026-08-04T01:13:01.294Z","tick":6438,"actorId":"dev-5","commandId":1592525,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592525,"issued_at_tick":6438,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"51d0c21dbe5b0346cab57d4d1046bae9f4bbebdf7e76461fe000f01317e5e3c8"}} +{"type":"command.receipt","at":"2026-08-04T01:13:01.495Z","tick":6444,"actorId":"dev-5","commandId":1592527,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592527,"issued_at_tick":6444,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"c84daa8715c5fa5c6edf12be24342c8fc6c9122cff753a0f8dd092a0a9db1f74"}} +{"type":"command.receipt","at":"2026-08-04T01:13:01.696Z","tick":6450,"actorId":"dev-5","commandId":1592529,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592529,"issued_at_tick":6450,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"4435e09ec5c7e742af6327a5b16edcb0968989ccccae4f1e91d938c11604c407"}} +{"type":"command.receipt","at":"2026-08-04T01:13:01.895Z","tick":6456,"actorId":"dev-5","commandId":1592531,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592531,"issued_at_tick":6456,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b1aad25c7fdcc18b902081edbae33802de7066182cd03e1066a6a9fb1a4791cb"}} +{"type":"command.receipt","at":"2026-08-04T01:13:02.093Z","tick":6462,"actorId":"dev-5","commandId":1592533,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592533,"issued_at_tick":6462,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"cc4c80c5abc9c1eb181268dd8d0fc7e2875d0194289685c494f52f98eb69de73"}} +{"type":"command.receipt","at":"2026-08-04T01:13:02.295Z","tick":6468,"actorId":"dev-5","commandId":1592535,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592535,"issued_at_tick":6468,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"90d490cfd8f23af562d0dfa5b5244952af7900b99fc668b6c91fb36ccaaa6d01"}} +{"type":"command.receipt","at":"2026-08-04T01:13:02.495Z","tick":6474,"actorId":"dev-5","commandId":1592537,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592537,"issued_at_tick":6474,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"814339134683183f60fa7b3af543a702bc324c6b12e585e0e339499b0ef4bac4"}} +{"type":"command.receipt","at":"2026-08-04T01:13:02.697Z","tick":6480,"actorId":"dev-5","commandId":1592539,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592539,"issued_at_tick":6480,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"3471735cf2e0ce4c111a12762def3e28e8c41d99393dccdcdc3ed04015e44723"}} +{"type":"command.receipt","at":"2026-08-04T01:13:02.894Z","tick":6486,"actorId":"dev-5","commandId":1592541,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592541,"issued_at_tick":6486,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"591c0fb7fa4509fd62c96fff4aba0018a80add9815831bfb2660029f23cdd66e"}} +{"type":"command.receipt","at":"2026-08-04T01:13:03.095Z","tick":6492,"actorId":"dev-5","commandId":1592543,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592543,"issued_at_tick":6492,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"de7abdd4e30b2b8b92d1ada97cc6b7c0eda14e6428e635a25127fe16b7e6f5cc"}} +{"type":"command.receipt","at":"2026-08-04T01:13:03.296Z","tick":6498,"actorId":"dev-5","commandId":1592545,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592545,"issued_at_tick":6498,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"119b1536b244c8070300186f15039331dbc6b43f5ea0efde1cebd63aa45188a3"}} +{"type":"command.receipt","at":"2026-08-04T01:13:03.494Z","tick":6504,"actorId":"dev-5","commandId":1592547,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592547,"issued_at_tick":6504,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"524b1c98551fc9150f30fae72cb2e410718e8a06200369bc7db64ec784fffc22"}} +{"type":"command.receipt","at":"2026-08-04T01:13:03.693Z","tick":6510,"actorId":"dev-5","commandId":1592549,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592549,"issued_at_tick":6510,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"ca3ae701c112b612bd4e3e4d168062d4c9061750348df617ecf8e46b3e66ad56"}} +{"type":"command.receipt","at":"2026-08-04T01:13:03.897Z","tick":6516,"actorId":"dev-5","commandId":1592551,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592551,"issued_at_tick":6516,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"d508384dbc72717c5eaf2a36df550d919a2b6db83ef1e16ab1c6503fa9be5722"}} +{"type":"command.receipt","at":"2026-08-04T01:13:04.095Z","tick":6522,"actorId":"dev-5","commandId":1592553,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592553,"issued_at_tick":6522,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"322d56acfb4849be31c43072df2fa4f5c8c4ad9748ce81e24ce65bd5aab58fc4"}} +{"type":"command.receipt","at":"2026-08-04T01:13:04.301Z","tick":6528,"actorId":"dev-5","commandId":1592555,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592555,"issued_at_tick":6528,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"1d5759fb7a9468391a566497f76174a33e738dc0f7b482c9da431ec513c701c9"}} +{"type":"command.receipt","at":"2026-08-04T01:13:04.503Z","tick":6534,"actorId":"dev-5","commandId":1592557,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592557,"issued_at_tick":6534,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b2f2fb038326dc553d8cf66f008e2af7d05307ef658789b9028d22a48e9c257d"}} +{"type":"command.receipt","at":"2026-08-04T01:13:04.703Z","tick":6540,"actorId":"dev-5","commandId":1592559,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592559,"issued_at_tick":6540,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"be5207f968c1d21864157dc435c2bb971384f17e8f0347ca136d605bd650fe68"}} +{"type":"command.receipt","at":"2026-08-04T01:13:04.904Z","tick":6546,"actorId":"dev-5","commandId":1592561,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592561,"issued_at_tick":6546,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"edbe666307a8629c66f6ee6d30702314c0dd382e85de4f25dbd57dc842f83a81"}} +{"type":"command.receipt","at":"2026-08-04T01:13:05.104Z","tick":6552,"actorId":"dev-5","commandId":1592563,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592563,"issued_at_tick":6552,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"04404a011daabd7e6e8db26b6546d4b8d703e2015f32b0961a23ce25a404e5db"}} +{"type":"command.receipt","at":"2026-08-04T01:13:05.304Z","tick":6558,"actorId":"dev-5","commandId":1592565,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592565,"issued_at_tick":6558,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"d7881899e4959e273b758ad8f07330a236505b8498ea218ce05126c8218f7bbc"}} +{"type":"command.receipt","at":"2026-08-04T01:13:05.502Z","tick":6564,"actorId":"dev-5","commandId":1592567,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592567,"issued_at_tick":6564,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"0078215dd1bf24642df77afc9fc260a1c2c9a37458158308d196d98a130652b3"}} +{"type":"command.receipt","at":"2026-08-04T01:13:05.707Z","tick":6570,"actorId":"dev-5","commandId":1592569,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592569,"issued_at_tick":6570,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"8f63aeb123eb5f57ef25e5f736c73d1f734c6d88e9f85ced05f80e64e1308f69"}} +{"type":"command.receipt","at":"2026-08-04T01:13:05.903Z","tick":6576,"actorId":"dev-5","commandId":1592571,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592571,"issued_at_tick":6576,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"9584221d8ff73f8ffc2570f4ff1c0776471d7d102c5cdec8b80dd81e8680586e"}} +{"type":"command.receipt","at":"2026-08-04T01:13:06.103Z","tick":6582,"actorId":"dev-5","commandId":1592573,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592573,"issued_at_tick":6582,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"df249a8f13e8ac7b4db8ec3f7f06edea8170602bfbea1c18131d85fdcbf7e577"}} +{"type":"checkpoint","at":"2026-08-04T01:13:06.274Z","tick":6587,"reason":"interval","actorCount":2,"stateHash":"968dc27f60c204609549cfcbc67f61ac9f8687d8bd1db1bf2455dad17aa45e6d"} +{"type":"command.receipt","at":"2026-08-04T01:13:06.302Z","tick":6588,"actorId":"dev-5","commandId":1592575,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592575,"issued_at_tick":6588,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"2a69f90dbe8926570fe632e5bb29fed67fd117540a2ebb73005332ea31bb7ad7"}} +{"type":"command.receipt","at":"2026-08-04T01:13:06.503Z","tick":6594,"actorId":"dev-5","commandId":1592577,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592577,"issued_at_tick":6594,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"473e1349aeb136af73e7906828e25c84911c9931100fefdf865574f16e135372"}} +{"type":"command.receipt","at":"2026-08-04T01:13:06.702Z","tick":6600,"actorId":"dev-5","commandId":1592579,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592579,"issued_at_tick":6600,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"92eee256e3d37db2dc4781dc96bc44f5d8e17879cc8dde381dc078a25e1a939c"}} +{"type":"command.receipt","at":"2026-08-04T01:13:06.903Z","tick":6606,"actorId":"dev-5","commandId":1592581,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592581,"issued_at_tick":6606,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"06040b74262077a82c5b41f2951c6906e590c8a66b69eb4ce8d1ab0c04b45a51"}} +{"type":"command.receipt","at":"2026-08-04T01:13:07.085Z","tick":6612,"actorId":"dev-5","commandId":1592583,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592583,"issued_at_tick":6612,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"c7126d8a82c9e392f25b932693b277ab440153dadb33d77e5fcd8f31feee04e1"}} +{"type":"command.receipt","at":"2026-08-04T01:13:07.277Z","tick":6617,"actorId":"dev-5","commandId":1592585,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592585,"issued_at_tick":6617,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"330b050c1321d79082c80e42dbe0287c223c6649e74bb1fa2001c91162f1e40e"}} +{"type":"command.receipt","at":"2026-08-04T01:13:07.476Z","tick":6623,"actorId":"dev-5","commandId":1592587,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592587,"issued_at_tick":6623,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"0806688b2e5dba4d3d70db85e1bd86060d9b1c9593b3822ef79384444a579610"}} +{"type":"command.receipt","at":"2026-08-04T01:13:07.678Z","tick":6629,"actorId":"dev-5","commandId":1592589,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592589,"issued_at_tick":6629,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"f1947ff5ffc2380b22992c0194b2b1ffce4763db1b75b084afabd9c5f56a7497"}} +{"type":"command.receipt","at":"2026-08-04T01:13:07.877Z","tick":6635,"actorId":"dev-5","commandId":1592591,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592591,"issued_at_tick":6635,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"690586b99c6ec11db8e3b652945effc7331aac4654a96b03336fb349f37b1b6c"}} +{"type":"command.receipt","at":"2026-08-04T01:13:08.077Z","tick":6641,"actorId":"dev-5","commandId":1592593,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592593,"issued_at_tick":6641,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"a859e5ed422f1620b9cd6345e9eb9d9f8c0e4d0b0baa4a8260b585f8ab121fda"}} +{"type":"command.receipt","at":"2026-08-04T01:13:08.277Z","tick":6647,"actorId":"dev-5","commandId":1592595,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592595,"issued_at_tick":6647,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"a5a747a65be9d730fd171be1ded646375b58f019a5367bb0a0b82a8b76ce16f5"}} +{"type":"command.receipt","at":"2026-08-04T01:13:08.469Z","tick":6653,"actorId":"dev-5","commandId":1592597,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592597,"issued_at_tick":6653,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"9ccaa071da1e1587c813f40bebc5cab0eef90bd3acb4672d001a7c6205fc8c5e"}} +{"type":"command.receipt","at":"2026-08-04T01:13:08.669Z","tick":6659,"actorId":"dev-5","commandId":1592599,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592599,"issued_at_tick":6659,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"4a90efb5786ce2f6e6a830c4b5fe3f55a24e81ac4b9af020731449a102b102f0"}} +{"type":"command.receipt","at":"2026-08-04T01:13:08.871Z","tick":6665,"actorId":"dev-5","commandId":1592601,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592601,"issued_at_tick":6665,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"1a509af13ddca5853a6e92ff76c33e6a1e6eddf69e5633cc0051e67ffd854558"}} +{"type":"command.receipt","at":"2026-08-04T01:13:09.069Z","tick":6671,"actorId":"dev-5","commandId":1592603,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592603,"issued_at_tick":6671,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"806ccb17c13b24e22fe8aea07f7a48150bd76ee31f37a30792695748b5096b59"}} +{"type":"command.receipt","at":"2026-08-04T01:13:09.271Z","tick":6677,"actorId":"dev-5","commandId":1592605,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592605,"issued_at_tick":6677,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"c91cdfad8fa7fdd037abb11d0e867ae25bdb06ffb2ea2fcd6dec6ec4fa91d4f7"}} +{"type":"command.receipt","at":"2026-08-04T01:13:09.469Z","tick":6683,"actorId":"dev-5","commandId":1592607,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592607,"issued_at_tick":6683,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"4b5b61d2e3496fadc01a19abb173244c074217d2def9e8bba216f2df3aeebac0"}} +{"type":"command.receipt","at":"2026-08-04T01:13:09.663Z","tick":6689,"actorId":"dev-5","commandId":1592609,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592609,"issued_at_tick":6689,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"0a580609d214b7b045e22b7350b496187d0a4522199cf2e24d33f50fb8529882"}} +{"type":"command.receipt","at":"2026-08-04T01:13:09.945Z","tick":6697,"actorId":"dev-5","commandId":1592612,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592612,"issued_at_tick":6697,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"dbf79f1c27bf4d4fd736569fdc78e037625a8698bfef7aa75d7fec3f7f089810"}} +{"type":"command.receipt","at":"2026-08-04T01:13:10.145Z","tick":6703,"actorId":"dev-5","commandId":1592614,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592614,"issued_at_tick":6703,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"750ee3d8b2279acdce2b32dd3aa3fc826d7c9e68b1a27dcb920ae97ecfe8590d"}} +{"type":"command.receipt","at":"2026-08-04T01:13:10.344Z","tick":6709,"actorId":"dev-5","commandId":1592616,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592616,"issued_at_tick":6709,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"0769daa4d5d6bd772743a6cff8f28646181ba712e0d450644f291e82d775c8c2"}} +{"type":"command.receipt","at":"2026-08-04T01:13:10.545Z","tick":6715,"actorId":"dev-5","commandId":1592618,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592618,"issued_at_tick":6715,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"aabbc0b9e730a33f0e9d2ec174be980c168b124f30540c5adf146dffc1717402"}} +{"type":"command.receipt","at":"2026-08-04T01:13:10.744Z","tick":6721,"actorId":"dev-5","commandId":1592620,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592620,"issued_at_tick":6721,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"e865d9124beab222520378d5ca98b0bf83ec0066aba43356357c59393de767a0"}} +{"type":"command.receipt","at":"2026-08-04T01:13:10.945Z","tick":6727,"actorId":"dev-5","commandId":1592622,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592622,"issued_at_tick":6727,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"cff4c6e2f8b41b9462757394ff6f475f090f8c768ca90a86cb6950250d47f257"}} +{"type":"command.receipt","at":"2026-08-04T01:13:11.145Z","tick":6733,"actorId":"dev-5","commandId":1592624,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592624,"issued_at_tick":6733,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"57f38cdaa87d0dc6e9b20c60e2ba367e990de20f076b29b650d567ee88d49acc"}} +{"type":"checkpoint","at":"2026-08-04T01:13:11.284Z","tick":6738,"reason":"interval","actorCount":2,"stateHash":"b25bbbdab38b19bf8d2eb2af22a8b99ef56db368c1faf97a77be327efd0df7a1"} +{"type":"command.receipt","at":"2026-08-04T01:13:11.344Z","tick":6739,"actorId":"dev-5","commandId":1592626,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592626,"issued_at_tick":6739,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"2e1a5f830563684bc1a2dd398cd4e57cd96bb3742aa6a2c893cd399e4d15528a"}} +{"type":"command.receipt","at":"2026-08-04T01:13:11.544Z","tick":6745,"actorId":"dev-5","commandId":1592628,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592628,"issued_at_tick":6745,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"5b798ceef511e83ad8bd9fc8c3b824a127f257ed7cb7c2bec4ba04a1e04a21ad"}} +{"type":"command.receipt","at":"2026-08-04T01:13:11.745Z","tick":6751,"actorId":"dev-5","commandId":1592630,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592630,"issued_at_tick":6751,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"698c3ecae444223341b2902d8654209d1deed4b6ca224abefe2a2a60e485e66a"}} +{"type":"command.receipt","at":"2026-08-04T01:13:11.946Z","tick":6757,"actorId":"dev-5","commandId":1592632,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592632,"issued_at_tick":6757,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"08e6e4b05ba7d19f80b77b4497559efb0c0f93ea2c8805067a2d9664440a03f5"}} +{"type":"command.receipt","at":"2026-08-04T01:13:12.145Z","tick":6763,"actorId":"dev-5","commandId":1592634,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592634,"issued_at_tick":6763,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"4fe923d29c7bb99261fd3e0f487ba8730e0798151e17a533675bed3d0c4f42b8"}} +{"type":"command.receipt","at":"2026-08-04T01:13:12.346Z","tick":6769,"actorId":"dev-5","commandId":1592636,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592636,"issued_at_tick":6769,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"4587dae42b637bb4a4848ea9d147a2cd5d22682ccfea16abf5ef5eff81a56014"}} +{"type":"command.receipt","at":"2026-08-04T01:13:12.546Z","tick":6775,"actorId":"dev-5","commandId":1592638,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592638,"issued_at_tick":6775,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"d53a646f121c3d17202a6ff14cba8c715b1d6a7f06901ebabc30725996420130"}} +{"type":"command.receipt","at":"2026-08-04T01:13:12.743Z","tick":6781,"actorId":"dev-5","commandId":1592640,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592640,"issued_at_tick":6781,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"be2e4468da31d4de84c4a9c66b8a017bfa63df5ee9c0de3e5f0bb83f081c7c26"}} +{"type":"command.receipt","at":"2026-08-04T01:13:12.944Z","tick":6787,"actorId":"dev-5","commandId":1592642,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592642,"issued_at_tick":6787,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"3558fad0a7b27eba8337c2bccac3b98cd6d9db145211621093b406d16c59555d"}} +{"type":"command.receipt","at":"2026-08-04T01:13:13.144Z","tick":6793,"actorId":"dev-5","commandId":1592644,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592644,"issued_at_tick":6793,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"0eac8adc871b3d372d295ba91f4553199a85070f1248458ebbf38f78f5aaf158"}} +{"type":"command.receipt","at":"2026-08-04T01:13:13.345Z","tick":6799,"actorId":"dev-5","commandId":1592646,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592646,"issued_at_tick":6799,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"483089736399c41feebb1565d42d164c8ccbea12391a174446442b8c3387dd79"}} +{"type":"command.receipt","at":"2026-08-04T01:13:13.544Z","tick":6805,"actorId":"dev-5","commandId":1592648,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592648,"issued_at_tick":6805,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"60c86f1180f2eab6315cf265f6b4cd5c4ccd18c676c19cae5638fce6e11590df"}} +{"type":"command.receipt","at":"2026-08-04T01:13:13.746Z","tick":6811,"actorId":"dev-5","commandId":1592650,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592650,"issued_at_tick":6811,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"61e79cdc4520d8d2fd5bbdc1d280bcbcd1444a77d80c11fdbdf3e7c493ed4898"}} +{"type":"command.receipt","at":"2026-08-04T01:13:13.944Z","tick":6817,"actorId":"dev-5","commandId":1592652,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592652,"issued_at_tick":6817,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"aa189a69fd91c82c81065d2a2f69e4bb16955869363ef8dacdea525e015de90e"}} +{"type":"command.receipt","at":"2026-08-04T01:13:14.144Z","tick":6823,"actorId":"dev-5","commandId":1592654,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592654,"issued_at_tick":6823,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b1f1db947db4b9995cdfe34c0bbbe149c24ce82440ec68c0d6118becef2df364"}} +{"type":"command.receipt","at":"2026-08-04T01:13:14.256Z","tick":6827,"actorId":"dev-5","commandId":1592655,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592655,"issued_at_tick":6827,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"4ae7a692f1cfa3a4907c4a0cbd7988dea1d617b490c3f73020ddb00cab237bb9"}} +{"type":"command.receipt","at":"2026-08-04T01:13:14.553Z","tick":6836,"actorId":"dev-5","commandId":1592658,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592658,"issued_at_tick":6836,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"c1aee40d3355f33927d6c5770d2c4e78a96723c4aa0ae945b6645cf1b5bed272"}} +{"type":"command.receipt","at":"2026-08-04T01:13:14.653Z","tick":6839,"actorId":"dev-5","commandId":1592659,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592659,"issued_at_tick":6839,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"f362ee8d9fe2df939febb74870e1db168973e2a840cd447f9c30c9392082974f"}} +{"type":"command.receipt","at":"2026-08-04T01:13:14.953Z","tick":6848,"actorId":"dev-5","commandId":1592662,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592662,"issued_at_tick":6848,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"01b53287e24cc9b63463b029948b75648138901fd0f958fc9d3ebd2c2cf660eb"}} +{"type":"command.receipt","at":"2026-08-04T01:13:15.153Z","tick":6854,"actorId":"dev-5","commandId":1592664,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592664,"issued_at_tick":6854,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"3b8ecbf5d9830e5e398d34294ff07a1195c8aa4025e6415b33d329e75181d5fa"}} +{"type":"command.receipt","at":"2026-08-04T01:13:15.354Z","tick":6860,"actorId":"dev-5","commandId":1592666,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592666,"issued_at_tick":6860,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"7cecf3dedb95ee59eab8f1e99b2298fffababcefbcbc64108237adc3bf5d69a2"}} +{"type":"command.receipt","at":"2026-08-04T01:13:15.553Z","tick":6866,"actorId":"dev-5","commandId":1592668,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592668,"issued_at_tick":6866,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"03b72b3a09d26124c1cbbda805b407bef9e348a7aa549b35fde0f33cc9a0df7e"}} +{"type":"command.receipt","at":"2026-08-04T01:13:15.753Z","tick":6872,"actorId":"dev-5","commandId":1592670,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592670,"issued_at_tick":6872,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"aa4424d2c41050bf6818ea0a2b48221266b45305a28129a393add46012091335"}} +{"type":"command.receipt","at":"2026-08-04T01:13:15.853Z","tick":6875,"actorId":"dev-5","commandId":1592671,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592671,"issued_at_tick":6875,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"c18970e0fa96beaeefed0161317658cdf306c1c6151b0c62f5f62a292fe6d88a"}} +{"type":"command.receipt","at":"2026-08-04T01:13:16.054Z","tick":6881,"actorId":"dev-5","commandId":1592673,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592673,"issued_at_tick":6881,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"bd4a201024556cb38018282ab39dea87ad896a5d66f1faf2c130228b23808577"}} +{"type":"checkpoint","at":"2026-08-04T01:13:16.294Z","tick":6888,"reason":"interval","actorCount":2,"stateHash":"af9f3aa966c253b05e253a7bd96ae14e06f46d938f8eec270937c9cfcb392c2c"} +{"type":"command.receipt","at":"2026-08-04T01:13:16.353Z","tick":6890,"actorId":"dev-5","commandId":1592676,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592676,"issued_at_tick":6890,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"e362b932d175065e01d34f45a33c412c1d1f34573174825ce175fbcc74f39fa6"}} +{"type":"command.receipt","at":"2026-08-04T01:13:16.455Z","tick":6893,"actorId":"dev-5","commandId":1592677,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592677,"issued_at_tick":6893,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b7a6defc1b43d571fc13c48a805aeefc4d0acf0d5f3b3a686832af013c627ecb"}} +{"type":"command.receipt","at":"2026-08-04T01:13:16.745Z","tick":6901,"actorId":"dev-5","commandId":1592680,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592680,"issued_at_tick":6901,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"698bfb1d545e8308fa2a1786b77f1949bd92c21ce3266c1a4cd3ac5a768ef6a5"}} +{"type":"command.receipt","at":"2026-08-04T01:13:16.936Z","tick":6907,"actorId":"dev-5","commandId":1592682,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592682,"issued_at_tick":6907,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"2625c26ea9740f7f989f823808472ba7866f9c4b411cc5cdcad7e82467944bad"}} +{"type":"command.receipt","at":"2026-08-04T01:13:17.129Z","tick":6913,"actorId":"dev-5","commandId":1592684,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592684,"issued_at_tick":6913,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"e419478525ef27476d5c032e2bfa4123bd46b47358feec00948378c9cb75a9e3"}} +{"type":"command.receipt","at":"2026-08-04T01:13:17.326Z","tick":6919,"actorId":"dev-5","commandId":1592686,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592686,"issued_at_tick":6919,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"9b5ed5671286cc279f0d3d31d7f36de46276990682de614fba66dfd5a3e539df"}} +{"type":"command.receipt","at":"2026-08-04T01:13:17.510Z","tick":6924,"actorId":"dev-5","commandId":1592688,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592688,"issued_at_tick":6924,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"ee62ee694241f1a30a1a6ce739368790eaa33184fc3d4ab474553369de2a4e15"}} +{"type":"command.receipt","at":"2026-08-04T01:13:17.695Z","tick":6930,"actorId":"dev-5","commandId":1592690,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592690,"issued_at_tick":6930,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"0dd3d55d1c67da53e13a69f690cb0fa1d8f098f74353683bc6a899ae219dd3cc"}} +{"type":"command.receipt","at":"2026-08-04T01:13:17.896Z","tick":6936,"actorId":"dev-5","commandId":1592692,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592692,"issued_at_tick":6936,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"b9fa0b8f6ac4f5ebb0cf5901d34a9a7ae0aab241d788faec254688bda1a49c3d"}} +{"type":"command.receipt","at":"2026-08-04T01:13:18.084Z","tick":6942,"actorId":"dev-5","commandId":1592694,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592694,"issued_at_tick":6942,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"85d621fb4f5f35a813900590fc1bca4654db7c0162b22493218506db4bbfd5d8"}} +{"type":"command.receipt","at":"2026-08-04T01:13:18.277Z","tick":6947,"actorId":"dev-5","commandId":1592696,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592696,"issued_at_tick":6947,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"34ce6f64e121d1c17cba720b760900781b47adff0a12ae45e42c230d0112c501"}} +{"type":"command.receipt","at":"2026-08-04T01:13:18.466Z","tick":6953,"actorId":"dev-5","commandId":1592698,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592698,"issued_at_tick":6953,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"03e3b0952f6cd4314f2fd617cf711ea398b28f00725052fe4cf027a579d0ede4"}} +{"type":"command.receipt","at":"2026-08-04T01:13:18.653Z","tick":6959,"actorId":"dev-5","commandId":1592700,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592700,"issued_at_tick":6959,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"d6e13adcfac2f9b7c83916a9bdd91314d2c7fe92f523a290f202a5c121925eb3"}} +{"type":"command.receipt","at":"2026-08-04T01:13:18.958Z","tick":6968,"actorId":"dev-5","commandId":1592703,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592703,"issued_at_tick":6968,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"23e3f45f1e239b0752c56d52705afbf09c819040089e4120e1a7bffbbac4a652"}} +{"type":"command.receipt","at":"2026-08-04T01:13:19.153Z","tick":6974,"actorId":"dev-5","commandId":1592705,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592705,"issued_at_tick":6974,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"7639a2eb9ca354f999851d2871a3d048e0c5303d327efa62c3233b378cc499ce"}} +{"type":"command.receipt","at":"2026-08-04T01:13:19.352Z","tick":6980,"actorId":"dev-5","commandId":1592707,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592707,"issued_at_tick":6980,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"3a45faaadb2f05c2efec71d9de4a0919c0f1a66e0a4d556611b807b54b6cb643"}} +{"type":"command.receipt","at":"2026-08-04T01:13:19.545Z","tick":6985,"actorId":"dev-5","commandId":1592709,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592709,"issued_at_tick":6985,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"df1e6c90152c3f9d17853ec1a255335f2b670c22b7cd36c3d00e21018b9d8df6"}} +{"type":"command.receipt","at":"2026-08-04T01:13:19.744Z","tick":6991,"actorId":"dev-5","commandId":1592711,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592711,"issued_at_tick":6991,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"0b2d3d2e4a21f1d358618993c4dc91829d7f8304f836829ddc3909267ce2b281"}} +{"type":"command.receipt","at":"2026-08-04T01:13:19.945Z","tick":6997,"actorId":"dev-5","commandId":1592713,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592713,"issued_at_tick":6997,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"21b1dab32dd46a44e1caaad58adf8a5b236728f278cfaae4850c8eebb7a688d0"}} +{"type":"command.receipt","at":"2026-08-04T01:13:20.145Z","tick":7003,"actorId":"dev-5","commandId":1592715,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592715,"issued_at_tick":7003,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"fe3548d5f44801f82911741d89fc3238e3ee7e4ffac9317afc61040b5b1a10d3"}} +{"type":"command.receipt","at":"2026-08-04T01:13:20.344Z","tick":7009,"actorId":"dev-5","commandId":1592717,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592717,"issued_at_tick":7009,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"326875532e55aa1438169e90d0849102dc1dbbf79ded502f723ad5d56ee7bcd3"}} +{"type":"command.receipt","at":"2026-08-04T01:13:20.544Z","tick":7015,"actorId":"dev-5","commandId":1592719,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592719,"issued_at_tick":7015,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"006d36641ee31be806fddc2142abbc528414f77bc93f00c434a1192e436c81cf"}} +{"type":"command.receipt","at":"2026-08-04T01:13:20.667Z","tick":7019,"actorId":"dev-5","commandId":1592720,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592720,"issued_at_tick":7019,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"750116ff8bd4ffa1b39e7a4eb210995b7ce604e7813d3d4016ab36e059aa0b17"}} +{"type":"command.receipt","at":"2026-08-04T01:13:20.880Z","tick":7025,"actorId":"dev-5","commandId":1592722,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592722,"issued_at_tick":7025,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"489ce8d045a37db5b30a4eeb5c917b90028e1b4e5363b541cb0be41c547d64b5"}} +{"type":"command.receipt","at":"2026-08-04T01:13:21.080Z","tick":7031,"actorId":"dev-5","commandId":1592724,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592724,"issued_at_tick":7031,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"a8b986baaacbab80e9ec4818bb5b28da3edc05a2ff05110e91fe3a7edbbad681"}} +{"type":"command.receipt","at":"2026-08-04T01:13:21.281Z","tick":7038,"actorId":"dev-5","commandId":1592726,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592726,"issued_at_tick":7037,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"07e8c830bd6bb465ba0c27e085d4c592ecc7d165b10f746bdb29eed99e1fee8d"}} +{"type":"checkpoint","at":"2026-08-04T01:13:21.317Z","tick":7039,"reason":"interval","actorCount":2,"stateHash":"4ff2ed735c0bb0655a63ed499ccfa8f3630546bf6e4e0487eeb07ddca68a75ff"} +{"type":"command.receipt","at":"2026-08-04T01:13:21.481Z","tick":7043,"actorId":"dev-5","commandId":1592728,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592728,"issued_at_tick":7043,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"dfe2dba2123617cf823dba66d79f21c6fc731fd0e8d88ebf597fc94b48662883"}} +{"type":"command.receipt","at":"2026-08-04T01:13:21.679Z","tick":7049,"actorId":"dev-5","commandId":1592730,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592730,"issued_at_tick":7049,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"dae50f0d615e82062f85097755c2af0176517019222e9f1359eb0d31f33d146c"}} +{"type":"command.receipt","at":"2026-08-04T01:13:21.930Z","tick":7057,"actorId":"dev-5","commandId":1592732,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592732,"issued_at_tick":7057,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"1114004108967e7872a14d175b7ad884f818247579e12c6d1713b02c7c634627"}} +{"type":"command.receipt","at":"2026-08-04T01:13:22.130Z","tick":7063,"actorId":"dev-5","commandId":1592734,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592734,"issued_at_tick":7063,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"76be96f629f186560cdb9fe0a4e6e60fd79629de19211b259bb0000e721120e0"}} +{"type":"command.receipt","at":"2026-08-04T01:13:22.331Z","tick":7069,"actorId":"dev-5","commandId":1592736,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592736,"issued_at_tick":7069,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"2833c4d3c06732216abd33d9f34e60d816046acf07959adb2fbdee897c600e75"}} +{"type":"command.receipt","at":"2026-08-04T01:13:22.530Z","tick":7075,"actorId":"dev-5","commandId":1592738,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592738,"issued_at_tick":7075,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"eb8372a8a5bfc14e1a84c04e4716e78a86b6a5759745aedfcf19b6c4f037665c"}} +{"type":"command.receipt","at":"2026-08-04T01:13:22.730Z","tick":7081,"actorId":"dev-5","commandId":1592740,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592740,"issued_at_tick":7081,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"cabb4a3f854379141a2c778f850f19a14b14bdfbf6ef8e381bd26aafff787727"}} +{"type":"command.receipt","at":"2026-08-04T01:13:22.930Z","tick":7087,"actorId":"dev-5","commandId":1592742,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592742,"issued_at_tick":7087,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"3b21e85dcaa8d238bf9423fdfb486e0b3d44b61a29cc71570d54e12d11dd6117"}} +{"type":"command.receipt","at":"2026-08-04T01:13:23.130Z","tick":7093,"actorId":"dev-5","commandId":1592744,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592744,"issued_at_tick":7093,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"3cd3075428a998b6bad3893c5be32c5cbae93796ada3a9a51d83d2a82f02f3ee"}} +{"type":"command.receipt","at":"2026-08-04T01:13:23.330Z","tick":7099,"actorId":"dev-5","commandId":1592746,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592746,"issued_at_tick":7099,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"7a111b1a7d123225bbf487cf350ad2c7e14cb04906371f021d108860d098de75"}} +{"type":"command.receipt","at":"2026-08-04T01:13:23.530Z","tick":7105,"actorId":"dev-5","commandId":1592748,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"player","envelope":{"session":1592,"player":471635138,"command_id":1592748,"issued_at_tick":7105,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":5,"player":1,"stateHash":"a4c8d883511d236c4780e1cca597c0eecb8bc8085f5df513f58caa13a26def12"}} +{"type":"session.disconnect","at":"2026-08-04T01:13:23.746Z","tick":7111,"sessionId":"g_5","actorId":"dev-5","removedTransientActor":true} +{"type":"command.receipt","at":"2026-08-04T01:13:23.753Z","tick":7111,"actorId":"dev-5","commandId":1592750,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"dev-5","rustActorId":"dev-5","envelope":{"session":1592,"player":471635138,"command_id":1592750,"issued_at_tick":7110,"command":{"SetMoveIntent":{"dx":0,"dy":0,"sprint":false}}},"session":5,"player":1,"stateHash":"45b2aa67bdaa7ebc8afa0d2b0956643e9b75dd69e3548b9c78790ccf05a3fdc1"}} +{"type":"checkpoint","at":"2026-08-04T01:13:26.330Z","tick":7111,"reason":"interval","actorCount":2,"stateHash":"45b2aa67bdaa7ebc8afa0d2b0956643e9b75dd69e3548b9c78790ccf05a3fdc1"} +{"type":"session.connect","at":"2026-08-04T01:16:22.146Z","tick":7111,"sessionId":"g_6","actorId":"drive-a","playerId":"drive-a","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T01:16:22.160Z","tick":7112,"reason":"interval","actorCount":2,"stateHash":"ce1f7e4301b1ba24b482a64af21b894d1b60f8436a637e3b79a4d1c265561cf8"} +{"type":"command.receipt","at":"2026-08-04T01:16:22.192Z","tick":7113,"actorId":"drive-a","commandId":1599000,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599000,"issued_at_tick":7112,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"164725a2f7a56e5df69649446819fafe96b6525954824a7b468de708e98009ab"}} +{"type":"command.receipt","at":"2026-08-04T01:16:25.587Z","tick":7215,"actorId":"drive-a","commandId":1599001,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599001,"issued_at_tick":7214,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"d860fcf680b9013a9bc5c1918d95a9c3b6baacf5180b907c77b9dcc67b2dea69"}} +{"type":"command.receipt","at":"2026-08-04T01:16:25.783Z","tick":7221,"actorId":"drive-a","commandId":1599002,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599002,"issued_at_tick":7220,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"eb8f4e8237ab80413c995b240b2908d47a075de8294e3e17722bbb23804b978b"}} +{"type":"command.receipt","at":"2026-08-04T01:16:25.983Z","tick":7226,"actorId":"drive-a","commandId":1599003,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599003,"issued_at_tick":7226,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"e9781eda65d01e2942e09bbfec4c35a3c4a70777b0e8479ab46007c014d0d377"}} +{"type":"command.receipt","at":"2026-08-04T01:16:26.168Z","tick":7232,"actorId":"drive-a","commandId":1599004,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599004,"issued_at_tick":7232,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"5f256190fd484d41424c9e8bf09d7606be58ce2fd8391e3377d722d6a3fb249e"}} +{"type":"command.receipt","at":"2026-08-04T01:16:26.367Z","tick":7238,"actorId":"drive-a","commandId":1599005,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599005,"issued_at_tick":7238,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"300967907d8b4d17d945ed49e017e648570c7d49915cada6a04af25df00759d5"}} +{"type":"command.receipt","at":"2026-08-04T01:16:26.567Z","tick":7244,"actorId":"drive-a","commandId":1599006,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599006,"issued_at_tick":7244,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"ca159b00cf9bf2e29ec5222d7f65341f65c78d2d6164d0ebfc986857b6bb7f68"}} +{"type":"command.receipt","at":"2026-08-04T01:16:26.768Z","tick":7250,"actorId":"drive-a","commandId":1599007,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599007,"issued_at_tick":7250,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"b4a0462920be2d5cb4a75d41fa39b2cfaf4dbff02b0dca860323447fe71ec760"}} +{"type":"command.receipt","at":"2026-08-04T01:16:26.967Z","tick":7256,"actorId":"drive-a","commandId":1599008,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599008,"issued_at_tick":7256,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"b85329379175b8fc261bd228861e13dafb4d6f5d7fee4c4dbc990cf07c75250f"}} +{"type":"checkpoint","at":"2026-08-04T01:16:27.164Z","tick":7262,"reason":"interval","actorCount":2,"stateHash":"616b42c94f5b436d4442b657ea91cf21ded7fab02f4ed72f663eca4d5db2f3f0"} +{"type":"command.receipt","at":"2026-08-04T01:16:27.193Z","tick":7263,"actorId":"drive-a","commandId":1599009,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599009,"issued_at_tick":7262,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"ffdd68d2129da24020e048797f232be3a20fca321ff473926e428489fa61a346"}} +{"type":"command.receipt","at":"2026-08-04T01:16:27.366Z","tick":7268,"actorId":"drive-a","commandId":1599010,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599010,"issued_at_tick":7268,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"bc9feca15994d2188368f32112e5746d51cadc4a2182ad50019a089531c7390f"}} +{"type":"command.receipt","at":"2026-08-04T01:16:27.554Z","tick":7274,"actorId":"drive-a","commandId":1599011,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599011,"issued_at_tick":7273,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"4a298fe75c42c46e53190c80972047ad0b71d29336fdac448ee0b9d1b9428ede"}} +{"type":"command.receipt","at":"2026-08-04T01:16:27.734Z","tick":7279,"actorId":"drive-a","commandId":1599012,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599012,"issued_at_tick":7279,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"f189015350359f03f2ee5103a1e63a6d436fa7e0009161c6a73d79005dfa7796"}} +{"type":"command.receipt","at":"2026-08-04T01:16:27.934Z","tick":7285,"actorId":"drive-a","commandId":1599013,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599013,"issued_at_tick":7285,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"71b1f30413269c7ba2054ac33166d55044be93f3904f77c11d2e4cb6734f7352"}} +{"type":"command.receipt","at":"2026-08-04T01:16:28.133Z","tick":7291,"actorId":"drive-a","commandId":1599014,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599014,"issued_at_tick":7291,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"be26d0fa5bccc6c114b1973f05a75e88d5b53080b09aeb432b3819d75cefb0e6"}} +{"type":"command.receipt","at":"2026-08-04T01:16:28.334Z","tick":7297,"actorId":"drive-a","commandId":1599015,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599015,"issued_at_tick":7297,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"defa1dcbf88f3b661ee7e77fd1beb77d00f6e6fe115d47fd8bb2ca47feb02998"}} +{"type":"command.receipt","at":"2026-08-04T01:16:28.533Z","tick":7303,"actorId":"drive-a","commandId":1599016,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599016,"issued_at_tick":7303,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"ae43b9f72e7e1b952079de6cbaa3f962cbdde4917ae19ef688cf54546ee58341"}} +{"type":"command.receipt","at":"2026-08-04T01:16:28.735Z","tick":7309,"actorId":"drive-a","commandId":1599017,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599017,"issued_at_tick":7309,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"66d71c4a0ef3f2ebffdc9589b5f3db98a09fff405f0ea2944537b9a715b4419d"}} +{"type":"command.receipt","at":"2026-08-04T01:16:28.898Z","tick":7314,"actorId":"drive-a","commandId":1599018,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599018,"issued_at_tick":7314,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"d2c6de2999e314bbc644cba7ee44ea8eb3d6402990a0ae33642cc98db064b0b9"}} +{"type":"command.receipt","at":"2026-08-04T01:16:29.049Z","tick":7318,"actorId":"drive-a","commandId":1599019,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599019,"issued_at_tick":7318,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"c46b3411164eb4ccd7d6f5f4467ab311a07a45403ceed28d139b25eb0fac0716"}} +{"type":"command.receipt","at":"2026-08-04T01:16:29.249Z","tick":7325,"actorId":"drive-a","commandId":1599020,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599020,"issued_at_tick":7324,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"fe4ef648544b09bd027c79d4b2724df3682960c07bdc7ac6f57aec8df2c9db00"}} +{"type":"command.receipt","at":"2026-08-04T01:16:29.452Z","tick":7331,"actorId":"drive-a","commandId":1599021,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599021,"issued_at_tick":7330,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"424bc0df34d538793952cf37ae5bae2d8deab0fbcffecbab3bb1d2f44a26ca34"}} +{"type":"command.receipt","at":"2026-08-04T01:16:29.651Z","tick":7337,"actorId":"drive-a","commandId":1599022,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599022,"issued_at_tick":7336,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"a5565d12be0c3012d4fefb154d5b017c5aa1850b672ba5990668c6b74b7b2b39"}} +{"type":"command.receipt","at":"2026-08-04T01:16:29.849Z","tick":7343,"actorId":"drive-a","commandId":1599023,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599023,"issued_at_tick":7343,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"859bad884269056ccc156be5572b2e89d19fa9b6778664bf49d3410a69cc72fe"}} +{"type":"command.receipt","at":"2026-08-04T01:16:30.033Z","tick":7348,"actorId":"drive-a","commandId":1599024,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599024,"issued_at_tick":7348,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"f03b3ec5d69fee88938e8e97a828cd0052b0b59fccdc65b2a09014241ca066c8"}} +{"type":"command.receipt","at":"2026-08-04T01:16:30.234Z","tick":7354,"actorId":"drive-a","commandId":1599025,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599025,"issued_at_tick":7354,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"7fc284c11e09d2a8e48df832842bde5596d4d80a9527103e068bd1b16eb10057"}} +{"type":"command.receipt","at":"2026-08-04T01:16:30.433Z","tick":7360,"actorId":"drive-a","commandId":1599026,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599026,"issued_at_tick":7360,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"5f58a19a1c6628ece3d4424263857b180a9a32b9024514313f6ce470147791d4"}} +{"type":"command.receipt","at":"2026-08-04T01:16:30.633Z","tick":7366,"actorId":"drive-a","commandId":1599027,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599027,"issued_at_tick":7366,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"69fa354b9462717ce71f8dd51a687940aa3fd1af389836a53f34b98791449da4"}} +{"type":"command.receipt","at":"2026-08-04T01:16:30.834Z","tick":7372,"actorId":"drive-a","commandId":1599028,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599028,"issued_at_tick":7372,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"1215ff85b019519879f3e42c9502b649ddfb61c8cf0c6cb09dc910c9d5f9ea14"}} +{"type":"command.receipt","at":"2026-08-04T01:16:31.035Z","tick":7378,"actorId":"drive-a","commandId":1599029,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599029,"issued_at_tick":7378,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"52c7488072038658fcb8b0dcef56039d28d033c552bdfc652f2a161a7d13b637"}} +{"type":"command.receipt","at":"2026-08-04T01:16:31.234Z","tick":7384,"actorId":"drive-a","commandId":1599030,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599030,"issued_at_tick":7384,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"7e4c18d637a0d41e8f7ee69b4e74509bd5214898cbe7ddfba66121f430b4cfcb"}} +{"type":"command.receipt","at":"2026-08-04T01:16:31.433Z","tick":7390,"actorId":"drive-a","commandId":1599031,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599031,"issued_at_tick":7390,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"bf02353b98e07d7bfe3ee051172365f7714408c6c1e55b4de0c26fee782ab0a8"}} +{"type":"command.receipt","at":"2026-08-04T01:16:31.633Z","tick":7396,"actorId":"drive-a","commandId":1599032,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599032,"issued_at_tick":7396,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"343c0e2feafd73400590a6f23b7c67334677ce9aaa50393e5af8f49a8698bc50"}} +{"type":"command.receipt","at":"2026-08-04T01:16:31.834Z","tick":7402,"actorId":"drive-a","commandId":1599033,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599033,"issued_at_tick":7402,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"41f9f25ed7fb1e5a49fb4b4bef3ca47e6204cac99e45382c9028dd77ea92a7a9"}} +{"type":"command.receipt","at":"2026-08-04T01:16:32.033Z","tick":7408,"actorId":"drive-a","commandId":1599034,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599034,"issued_at_tick":7408,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"c3866302fa858d97b98bf7005580f65ddd13f65c150bb3ee2d064b1534ea5d5c"}} +{"type":"checkpoint","at":"2026-08-04T01:16:32.182Z","tick":7412,"reason":"interval","actorCount":2,"stateHash":"bd7e03ad62dcf7ebfc013e4f7a617918c73a551a99e479da5d0ac23588dd9aa8"} +{"type":"command.receipt","at":"2026-08-04T01:16:32.232Z","tick":7414,"actorId":"drive-a","commandId":1599035,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599035,"issued_at_tick":7414,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"96b309a108fd49e66d9d93484e9a629a237e6577a6e1a2c37b6f7ac7fc7f8dc7"}} +{"type":"command.receipt","at":"2026-08-04T01:16:32.432Z","tick":7420,"actorId":"drive-a","commandId":1599036,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599036,"issued_at_tick":7420,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"85843bac73e04d1b2fc3c08c5203e1a8a5afa3b1ff8b810c060a3a64dc9f021a"}} +{"type":"command.receipt","at":"2026-08-04T01:16:32.636Z","tick":7426,"actorId":"drive-a","commandId":1599037,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599037,"issued_at_tick":7426,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"32384b64ff12114f710f8d5a2941cf9f358f9650460206b4b5036196fadca558"}} +{"type":"command.receipt","at":"2026-08-04T01:16:32.834Z","tick":7432,"actorId":"drive-a","commandId":1599038,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599038,"issued_at_tick":7432,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"012e9ff9659580c6df89e0ef6ab0288d1681fc309d3b295a04162821c7d4ef89"}} +{"type":"command.receipt","at":"2026-08-04T01:16:32.936Z","tick":7435,"actorId":"drive-a","commandId":1599039,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599039,"issued_at_tick":7435,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"4587967d2c46506c26bddd77cfbce5cfe6dad6de231ddcaa6744649a2f426d07"}} +{"type":"command.receipt","at":"2026-08-04T01:16:33.035Z","tick":7438,"actorId":"drive-a","commandId":1599040,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599040,"issued_at_tick":7438,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"128a3a46374c92a2a1098c7b35c90fcfc26293a04fb768b7ab9986f2b0aa1cdb"}} +{"type":"command.receipt","at":"2026-08-04T01:16:33.136Z","tick":7441,"actorId":"drive-a","commandId":1599041,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599041,"issued_at_tick":7441,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"4bc64807002ee977e2e484e372a464cf3391993745a86e4ce8574568a2bde239"}} +{"type":"command.receipt","at":"2026-08-04T01:16:33.235Z","tick":7444,"actorId":"drive-a","commandId":1599042,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599042,"issued_at_tick":7444,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"14e7dcb57732059236a4754af2fe6728382f6a0023286ae8b9df2971ccbd46d3"}} +{"type":"command.receipt","at":"2026-08-04T01:16:33.334Z","tick":7447,"actorId":"drive-a","commandId":1599043,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599043,"issued_at_tick":7447,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"e136f046400fb29b1eb7426d8598f00b1f3d6b56ab01794fd1638bf6bd5ac753"}} +{"type":"command.receipt","at":"2026-08-04T01:16:33.433Z","tick":7450,"actorId":"drive-a","commandId":1599044,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599044,"issued_at_tick":7450,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"a24088a7efa72527942dda9e7456f6b22a2c97c57392d768809cea04fb298310"}} +{"type":"command.receipt","at":"2026-08-04T01:16:33.533Z","tick":7453,"actorId":"drive-a","commandId":1599045,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599045,"issued_at_tick":7453,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"9f51bd3c2bde2a65dc118c3aa2bb33807ff1257e39356aa73d0a50c3e336e24b"}} +{"type":"command.receipt","at":"2026-08-04T01:16:33.634Z","tick":7456,"actorId":"drive-a","commandId":1599046,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599046,"issued_at_tick":7456,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"9df7a2ce3a64fc2d4a5b0501391001f4c8e13f2417a22a1da5573fd96522ebbd"}} +{"type":"command.receipt","at":"2026-08-04T01:16:33.733Z","tick":7459,"actorId":"drive-a","commandId":1599047,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599047,"issued_at_tick":7459,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"1feeedb52a1db70995e539259eed8950d10d5461e81e101f0a80ede28e066272"}} +{"type":"command.receipt","at":"2026-08-04T01:16:33.832Z","tick":7462,"actorId":"drive-a","commandId":1599048,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599048,"issued_at_tick":7462,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"8ed8b9c4688e8a040af28065f66e6642a0202f9c1ac68a4bf5256c8bbae5cc04"}} +{"type":"command.receipt","at":"2026-08-04T01:16:33.932Z","tick":7465,"actorId":"drive-a","commandId":1599049,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599049,"issued_at_tick":7465,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"2d65173ac83648113ea3586893a95aba0ad9ae31a31f4db5dfec6345c6a75930"}} +{"type":"command.receipt","at":"2026-08-04T01:16:34.034Z","tick":7468,"actorId":"drive-a","commandId":1599050,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599050,"issued_at_tick":7468,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"a37923dbb2f4a6413f8a2c1bf9b56ba5895d36a49aa2cc77977134a836a06ba7"}} +{"type":"command.receipt","at":"2026-08-04T01:16:34.134Z","tick":7471,"actorId":"drive-a","commandId":1599051,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599051,"issued_at_tick":7471,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"54182e89c030028203e0431413bb19d1fcf83c9395173714a5e03656639f4a45"}} +{"type":"command.receipt","at":"2026-08-04T01:16:34.232Z","tick":7474,"actorId":"drive-a","commandId":1599052,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599052,"issued_at_tick":7474,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"99466390d53def3c6f499e2efa421552fa4669dbfd8019760bdadd8677794073"}} +{"type":"command.receipt","at":"2026-08-04T01:16:34.332Z","tick":7477,"actorId":"drive-a","commandId":1599053,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599053,"issued_at_tick":7477,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"de41be5d20be5bd045a8a9de9f1fa06b32b9a7a565a611cab19d9909fdd5cb18"}} +{"type":"command.receipt","at":"2026-08-04T01:16:34.432Z","tick":7480,"actorId":"drive-a","commandId":1599054,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599054,"issued_at_tick":7480,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"71886d847fa4acb31abc0328b6c43a2f2aa38541b503ba5512a46fbe021284de"}} +{"type":"command.receipt","at":"2026-08-04T01:16:34.633Z","tick":7486,"actorId":"drive-a","commandId":1599056,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599056,"issued_at_tick":7486,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"c7e34023ad3a7306c782256abb5c5150d1c134fda83f19fd1a3432c623eed61c"}} +{"type":"command.receipt","at":"2026-08-04T01:16:34.832Z","tick":7492,"actorId":"drive-a","commandId":1599058,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599058,"issued_at_tick":7492,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"432d99f19c3376a07066a9657c7e5226bb2f5fd281ef0918b25a53741afdc22a"}} +{"type":"command.receipt","at":"2026-08-04T01:16:35.025Z","tick":7498,"actorId":"drive-a","commandId":1599060,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599060,"issued_at_tick":7498,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"591c3c6008534677da829200dca368024840dd92b1558f099b7bd1f665de2775"}} +{"type":"command.receipt","at":"2026-08-04T01:16:35.227Z","tick":7504,"actorId":"drive-a","commandId":1599062,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599062,"issued_at_tick":7504,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"dd71f4d25e0e11e456832f1d4d1a89f47a7031c5f6cd784b561dc50216cf8f8d"}} +{"type":"command.receipt","at":"2026-08-04T01:16:35.424Z","tick":7510,"actorId":"drive-a","commandId":1599064,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599064,"issued_at_tick":7510,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"45103c87895176a01f2db2cb732d22356f577ac436ee9969695c9cb896b6208a"}} +{"type":"command.receipt","at":"2026-08-04T01:16:35.627Z","tick":7516,"actorId":"drive-a","commandId":1599066,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599066,"issued_at_tick":7516,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"fded0f2df0828510b341f2f1e364a940cc97ab918aaa8a10c214d04d6e87913f"}} +{"type":"command.receipt","at":"2026-08-04T01:16:35.824Z","tick":7522,"actorId":"drive-a","commandId":1599068,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599068,"issued_at_tick":7522,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"cee251d269b19a4421d1880b73c7d4bccfe7f2f23f7a3b2db8d100be3bd48c1d"}} +{"type":"command.receipt","at":"2026-08-04T01:16:36.027Z","tick":7528,"actorId":"drive-a","commandId":1599070,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599070,"issued_at_tick":7528,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"c165b5a0c0e379d928059efd53bb969fe480ab7cc817a986f8b5cde63870ad9a"}} +{"type":"command.receipt","at":"2026-08-04T01:16:36.226Z","tick":7534,"actorId":"drive-a","commandId":1599072,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599072,"issued_at_tick":7534,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"c1723440edae115bff832bd56b225e6193ec5c854500690615b6b5046efc469b"}} +{"type":"command.receipt","at":"2026-08-04T01:16:36.427Z","tick":7540,"actorId":"drive-a","commandId":1599074,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599074,"issued_at_tick":7540,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"6859a8bfe01e0526bacd38e74a44f1e81d4357c7ef8b95ee9ea393425d98b1d4"}} +{"type":"command.receipt","at":"2026-08-04T01:16:36.627Z","tick":7546,"actorId":"drive-a","commandId":1599076,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599076,"issued_at_tick":7546,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"3a3a351e8867a515ae91000baf34a2c09a613c6f10b11ece4b044e56c2515fd4"}} +{"type":"command.receipt","at":"2026-08-04T01:16:36.827Z","tick":7552,"actorId":"drive-a","commandId":1599078,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599078,"issued_at_tick":7552,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"67bffad073a6bb5940170b7d516b38852f648bb80c74986e60756fac13a0d06c"}} +{"type":"command.receipt","at":"2026-08-04T01:16:37.026Z","tick":7558,"actorId":"drive-a","commandId":1599080,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599080,"issued_at_tick":7558,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"381068d3d1d549bfb9a99e675cdc16ee62fd5a4e121dd44025f54d261e62f1d0"}} +{"type":"checkpoint","at":"2026-08-04T01:16:37.190Z","tick":7563,"reason":"interval","actorCount":2,"stateHash":"1dfd182650e65d97e10af7210b43ac399418eaaa390af2136fbfdbd54f0d68df"} +{"type":"command.receipt","at":"2026-08-04T01:16:37.224Z","tick":7564,"actorId":"drive-a","commandId":1599082,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599082,"issued_at_tick":7564,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"99a0baad4186871cd478c4e0828e3df7da74057001ded8671d593c64eb2933ee"}} +{"type":"command.receipt","at":"2026-08-04T01:16:37.424Z","tick":7570,"actorId":"drive-a","commandId":1599084,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599084,"issued_at_tick":7570,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"b6b5b1935a335d895d6c571e03081dddc213befc9d49cf1b11ea79fc4d18ef39"}} +{"type":"command.receipt","at":"2026-08-04T01:16:37.625Z","tick":7576,"actorId":"drive-a","commandId":1599086,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599086,"issued_at_tick":7576,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"748daf9d5dfddccf531244797c025b77e02b7e2046790487c23d37a04e92de43"}} +{"type":"command.receipt","at":"2026-08-04T01:16:37.826Z","tick":7582,"actorId":"drive-a","commandId":1599088,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599088,"issued_at_tick":7582,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"23783e6b18ffad86cb2bbb541a245705154a43f92b97805b2ac1ada177c737cd"}} +{"type":"command.receipt","at":"2026-08-04T01:16:38.026Z","tick":7588,"actorId":"drive-a","commandId":1599090,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599090,"issued_at_tick":7588,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"394b7f558da0bc2103530124b561cc7efcaa51ad88e668217f630af5d88e06c9"}} +{"type":"command.receipt","at":"2026-08-04T01:16:38.225Z","tick":7594,"actorId":"drive-a","commandId":1599092,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599092,"issued_at_tick":7594,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"b684eda38972cb9f4f17d1ca2e85c71460bd7ca29208bc7d0fc4ae156e623fe7"}} +{"type":"command.receipt","at":"2026-08-04T01:16:38.425Z","tick":7600,"actorId":"drive-a","commandId":1599094,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599094,"issued_at_tick":7600,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"163cfab1a7310f95e8e4310213a58730ca530026208c129d5e3fbde0a585a1bb"}} +{"type":"command.receipt","at":"2026-08-04T01:16:38.624Z","tick":7606,"actorId":"drive-a","commandId":1599096,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599096,"issued_at_tick":7606,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"6b23673f8e070f73ef273f1455bf502af9487ec5511146d779db63c1049000b2"}} +{"type":"command.receipt","at":"2026-08-04T01:16:38.826Z","tick":7612,"actorId":"drive-a","commandId":1599098,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599098,"issued_at_tick":7612,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"ed82dacb38db8b0c3e94fb21ffc8e144371656d5e4cefac30ff97b2a17713dfa"}} +{"type":"command.receipt","at":"2026-08-04T01:16:39.025Z","tick":7618,"actorId":"drive-a","commandId":1599100,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599100,"issued_at_tick":7618,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"4910676bba6d09630fa4ede12af3b945bd031432b40f5ed7275242cee96f7fcd"}} +{"type":"command.receipt","at":"2026-08-04T01:16:39.224Z","tick":7624,"actorId":"drive-a","commandId":1599102,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599102,"issued_at_tick":7624,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"6f4eb1a28b557e83f7a99f0abbb1c23dedd9e025789d88f8739f1df039800bea"}} +{"type":"command.receipt","at":"2026-08-04T01:16:39.426Z","tick":7630,"actorId":"drive-a","commandId":1599104,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599104,"issued_at_tick":7630,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"b3af6a192fa8807b04e405d487ad866c2ae28e876d77db621ff23b11b9b3d1ee"}} +{"type":"command.receipt","at":"2026-08-04T01:16:39.626Z","tick":7636,"actorId":"drive-a","commandId":1599106,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599106,"issued_at_tick":7636,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"c68a2c3ebeb5d8da316a9941955fa1c83c7461ca1671190a0c66f27a25c3b66e"}} +{"type":"command.receipt","at":"2026-08-04T01:16:39.826Z","tick":7642,"actorId":"drive-a","commandId":1599108,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599108,"issued_at_tick":7642,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"c60d3cc16147aaee715bba93c077771e3c3e4db24f6f1b98537a441020becd3a"}} +{"type":"command.receipt","at":"2026-08-04T01:16:40.026Z","tick":7648,"actorId":"drive-a","commandId":1599110,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599110,"issued_at_tick":7648,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"a0822a479636086594ab446b662e878a9b4ed85b48a21856258eb16f6f2bc0c2"}} +{"type":"command.receipt","at":"2026-08-04T01:16:40.225Z","tick":7654,"actorId":"drive-a","commandId":1599112,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599112,"issued_at_tick":7654,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"73c8fab0b62144d76f5824927434f768fbf306fb5ae64469053bc8802d5ba31b"}} +{"type":"command.receipt","at":"2026-08-04T01:16:40.426Z","tick":7660,"actorId":"drive-a","commandId":1599114,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599114,"issued_at_tick":7660,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"663d613574e1b8eeb32d3d71a7018a814e710e941f890aaf69cdb3ad34591666"}} +{"type":"command.receipt","at":"2026-08-04T01:16:40.624Z","tick":7666,"actorId":"drive-a","commandId":1599116,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599116,"issued_at_tick":7666,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"62b94dfd10751e1ed6d16cea0f8b4b78dea48bbadc9a7604eb232c98f7ab0ee6"}} +{"type":"command.receipt","at":"2026-08-04T01:16:40.824Z","tick":7672,"actorId":"drive-a","commandId":1599118,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599118,"issued_at_tick":7672,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"3a0bfeed3a65929ce8b201b9da27d29dfd9def01d7be9052d68839074395b956"}} +{"type":"command.receipt","at":"2026-08-04T01:16:41.025Z","tick":7678,"actorId":"drive-a","commandId":1599120,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599120,"issued_at_tick":7678,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"2f42e4506956db8d838aa59e3474b524a34cc88c1af9e8997e5f366565fbea47"}} +{"type":"command.receipt","at":"2026-08-04T01:16:41.226Z","tick":7684,"actorId":"drive-a","commandId":1599122,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599122,"issued_at_tick":7684,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"e036711cb3e60135e1706802c721a348cdc1e02a13b2563d2e4cddfc4ff41443"}} +{"type":"command.receipt","at":"2026-08-04T01:16:41.427Z","tick":7690,"actorId":"drive-a","commandId":1599124,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599124,"issued_at_tick":7690,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"a2bdf69c0956e11c84de878cf1bd4707abbaa1c7611af0bd6ac4092c88f6c5d3"}} +{"type":"command.receipt","at":"2026-08-04T01:16:41.624Z","tick":7696,"actorId":"drive-a","commandId":1599126,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599126,"issued_at_tick":7696,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"549c83c21b66f71a221d28f204df76ec66f9d1c447ea267f9e4512dd6ec91a12"}} +{"type":"command.receipt","at":"2026-08-04T01:16:41.823Z","tick":7702,"actorId":"drive-a","commandId":1599128,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599128,"issued_at_tick":7702,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"92db424188da738b06fc2e9f20207906f1dcd50e6aafd3ab02d59383437e1f17"}} +{"type":"command.receipt","at":"2026-08-04T01:16:42.024Z","tick":7708,"actorId":"drive-a","commandId":1599130,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599130,"issued_at_tick":7708,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"584c2428c43faaaef3f111c2648ca1e9a90c98089ad8ea5ad31e7c371bcf47cd"}} +{"type":"checkpoint","at":"2026-08-04T01:16:42.197Z","tick":7713,"reason":"interval","actorCount":2,"stateHash":"25388d528b333a6e79b3e6fddde6540e3637468a3dafb1b6f03d0254e14b8449"} +{"type":"command.receipt","at":"2026-08-04T01:16:42.223Z","tick":7714,"actorId":"drive-a","commandId":1599132,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599132,"issued_at_tick":7714,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"67ef363378262e86b5c0d8198318347530dc541f25099047ca0afd44767622e5"}} +{"type":"command.receipt","at":"2026-08-04T01:16:42.427Z","tick":7720,"actorId":"drive-a","commandId":1599134,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599134,"issued_at_tick":7720,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"7c2792766ecbfbcf292274ed208a7aff37065dcc7501ae4b5fb01594602b2077"}} +{"type":"command.receipt","at":"2026-08-04T01:16:42.627Z","tick":7726,"actorId":"drive-a","commandId":1599136,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599136,"issued_at_tick":7726,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"8322091fc1a8d9b6b8bf4e3d6cd0edd5f7df8af7f3e5cc2b2a08041f1c4ce9a7"}} +{"type":"command.receipt","at":"2026-08-04T01:16:42.824Z","tick":7732,"actorId":"drive-a","commandId":1599138,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599138,"issued_at_tick":7732,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"2ba7db4449be61ba7abad6a3109e327a726534d79a06c473e9be16a69c4b31f9"}} +{"type":"command.receipt","at":"2026-08-04T01:16:43.013Z","tick":7737,"actorId":"drive-a","commandId":1599140,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599140,"issued_at_tick":7737,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"c268ec466215f9b8b009a678ef4a9191e9389f7386b9dda221dca50f6e98f44a"}} +{"type":"command.receipt","at":"2026-08-04T01:16:43.209Z","tick":7743,"actorId":"drive-a","commandId":1599142,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599142,"issued_at_tick":7743,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"ae817bfba70a81d8c209bb2921e8f7d8352a68dc479e5b95402e608ce15d44d1"}} +{"type":"command.receipt","at":"2026-08-04T01:16:43.409Z","tick":7749,"actorId":"drive-a","commandId":1599144,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599144,"issued_at_tick":7749,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"41141d4911e727f1b2960d8ef19692f6e7c7742cd93b15779db536f70b49681c"}} +{"type":"command.receipt","at":"2026-08-04T01:16:43.603Z","tick":7755,"actorId":"drive-a","commandId":1599146,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599146,"issued_at_tick":7755,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"8fac057059e72c8db44f7cc6275c1e8edf34342260889063ae418f46a0f2960d"}} +{"type":"command.receipt","at":"2026-08-04T01:16:43.791Z","tick":7761,"actorId":"drive-a","commandId":1599148,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599148,"issued_at_tick":7761,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"03ce0d3eb8034786a8d0a9d6a4d72b0b6f18ac49b82e379c55250a7eab86fcfc"}} +{"type":"command.receipt","at":"2026-08-04T01:16:43.876Z","tick":7763,"actorId":"drive-a","commandId":1599149,"commandKind":"ToggleDoor","accepted":true,"eventIds":[]} +{"type":"command.receipt","at":"2026-08-04T01:16:44.084Z","tick":7770,"actorId":"drive-a","commandId":1599152,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599152,"issued_at_tick":7769,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"22f2a32f8abc03d42a50c93b5e410e11031e4d9a47945fd5c3ab097252668933"}} +{"type":"command.receipt","at":"2026-08-04T01:16:44.254Z","tick":7775,"actorId":"drive-a","commandId":1599154,"commandKind":"ToggleDoor","accepted":true,"eventIds":[]} +{"type":"command.receipt","at":"2026-08-04T01:16:44.285Z","tick":7776,"actorId":"drive-a","commandId":1599155,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599155,"issued_at_tick":7775,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"8fe1511cbf68f32b37f3d90bc88471b06c3f26bbc0198e657c127c3066ca6faa"}} +{"type":"command.receipt","at":"2026-08-04T01:16:44.485Z","tick":7782,"actorId":"drive-a","commandId":1599157,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599157,"issued_at_tick":7781,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"445572bf92fe65481fa4d3437b2234c99d0993d41e92b4d92572435aecaae220"}} +{"type":"command.receipt","at":"2026-08-04T01:16:44.685Z","tick":7788,"actorId":"drive-a","commandId":1599159,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599159,"issued_at_tick":7788,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"be9d93d2e9909de8ae8a5f632eda83387b87d94c00d27d47885658735ba9090b"}} +{"type":"command.receipt","at":"2026-08-04T01:16:44.888Z","tick":7794,"actorId":"drive-a","commandId":1599161,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599161,"issued_at_tick":7793,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"b47c676334e58e6cc5a6a69c095407dfc6cd2447d4a0ba8d001fa649c7d914f5"}} +{"type":"command.receipt","at":"2026-08-04T01:16:45.085Z","tick":7800,"actorId":"drive-a","commandId":1599163,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599163,"issued_at_tick":7799,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"e7e31ca5300acc0f0a1d038c11c80b88ab767dbbc1de64b2b1847256375c017e"}} +{"type":"command.receipt","at":"2026-08-04T01:16:45.283Z","tick":7806,"actorId":"drive-a","commandId":1599165,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599165,"issued_at_tick":7805,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"0b6e29c64d0b2d2d6ead512a17a59c8b5da44eef63230922bfc296c8933b4509"}} +{"type":"command.receipt","at":"2026-08-04T01:16:45.485Z","tick":7812,"actorId":"drive-a","commandId":1599167,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599167,"issued_at_tick":7811,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"3463913bb4c10dcc751ec28bdc11d8603979c59c87089a971f3301cff825985d"}} +{"type":"command.receipt","at":"2026-08-04T01:16:45.685Z","tick":7818,"actorId":"drive-a","commandId":1599169,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599169,"issued_at_tick":7817,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"d051e27e8e6f3912723b2c20a490eca8717d96d5f4a90f4d3131c604bc442725"}} +{"type":"command.receipt","at":"2026-08-04T01:16:45.877Z","tick":7823,"actorId":"drive-a","commandId":1599171,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599171,"issued_at_tick":7823,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"75594bb40e075d96fa0a594b40774a9d5b2b73e8785e288157def85957f62fa3"}} +{"type":"command.receipt","at":"2026-08-04T01:16:46.075Z","tick":7829,"actorId":"drive-a","commandId":1599173,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599173,"issued_at_tick":7829,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"9432db64ba6eaf574ced5520b038102c9b18c14ad8d07af1a3d9fde48b1b8d42"}} +{"type":"command.receipt","at":"2026-08-04T01:16:46.278Z","tick":7835,"actorId":"drive-a","commandId":1599175,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599175,"issued_at_tick":7835,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"74dbddc0da46c311bc535486a1f00bcf914d9e9c8e58051b5813fe5cd16f50cd"}} +{"type":"command.receipt","at":"2026-08-04T01:16:46.475Z","tick":7841,"actorId":"drive-a","commandId":1599177,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599177,"issued_at_tick":7841,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"ffc00faa0af82b56a0ec17e948015f7d1d79019911789434096362a2f6f71479"}} +{"type":"command.receipt","at":"2026-08-04T01:16:46.675Z","tick":7847,"actorId":"drive-a","commandId":1599179,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599179,"issued_at_tick":7847,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"a3680988a1830a1964130221a1becf38614b3f8013f04d660b9e2ce09272e40f"}} +{"type":"command.receipt","at":"2026-08-04T01:16:46.868Z","tick":7853,"actorId":"drive-a","commandId":1599181,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599181,"issued_at_tick":7853,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"aee0415933f687bf5ad761efcd6bfdef849fa239bb8bc4d2bc0ed80ff139101c"}} +{"type":"command.receipt","at":"2026-08-04T01:16:47.062Z","tick":7859,"actorId":"drive-a","commandId":1599183,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599183,"issued_at_tick":7859,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"ca20c155556033c466dde0fd331a811bf937cc8e1019ad6a5de32b2fd31cd0df"}} +{"type":"checkpoint","at":"2026-08-04T01:16:47.219Z","tick":7864,"reason":"interval","actorCount":2,"stateHash":"0feecce484dfc4fb51a21c424065812a93fafffbc5770c8f4ac8046588e93a3d"} +{"type":"command.receipt","at":"2026-08-04T01:16:47.260Z","tick":7865,"actorId":"drive-a","commandId":1599185,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599185,"issued_at_tick":7865,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"422920f2af22df80a5d54f7e634a98dcb8fd0849402a40fcea8b8159f1c21f9a"}} +{"type":"command.receipt","at":"2026-08-04T01:16:47.458Z","tick":7871,"actorId":"drive-a","commandId":1599187,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599187,"issued_at_tick":7871,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"e81928398be50630e6d1a968d5a82564e358fc02437f8d4c10ab326cd8aaa681"}} +{"type":"command.receipt","at":"2026-08-04T01:16:47.649Z","tick":7877,"actorId":"drive-a","commandId":1599189,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599189,"issued_at_tick":7876,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"2119bdad9fe5d5fe7daee0fb61f10109533692b58a79304b32fe561d9382416d"}} +{"type":"command.receipt","at":"2026-08-04T01:16:47.844Z","tick":7882,"actorId":"drive-a","commandId":1599191,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599191,"issued_at_tick":7882,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"a7fd0183a5c436e6aa917b4c2f35a14c7663c937d2e9315f658c38812baf1081"}} +{"type":"command.receipt","at":"2026-08-04T01:16:48.042Z","tick":7888,"actorId":"drive-a","commandId":1599193,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599193,"issued_at_tick":7888,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"2cfc346ec4c603d0e46f8a43a5e138732763e5d52f91093e88cfeda934681145"}} +{"type":"command.receipt","at":"2026-08-04T01:16:48.242Z","tick":7894,"actorId":"drive-a","commandId":1599195,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599195,"issued_at_tick":7894,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"17f7865a41d3aba04e23dcc12fb69d5bdf70ceaa831cbef54516ef329ddd26cb"}} +{"type":"command.receipt","at":"2026-08-04T01:16:48.441Z","tick":7900,"actorId":"drive-a","commandId":1599197,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599197,"issued_at_tick":7900,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"7d00136582da30788eebe0fdbcd1142a5ae3e8ab38f7b6f2c670e468d8f9f417"}} +{"type":"command.receipt","at":"2026-08-04T01:16:48.643Z","tick":7906,"actorId":"drive-a","commandId":1599199,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599199,"issued_at_tick":7906,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"e9a0c1c76528d6cd636955ca5aef10dea17f3575187d4a8c0d96714011be5b10"}} +{"type":"command.receipt","at":"2026-08-04T01:16:48.841Z","tick":7912,"actorId":"drive-a","commandId":1599201,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"drive-a","rustActorId":"player","envelope":{"session":1599,"player":2721551069,"command_id":1599201,"issued_at_tick":7912,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":6,"player":1,"stateHash":"3cb819a08650a767e7db3ca1d6d87ebf376dd6a8b9d990db2b32d6af31c04edb"}} +{"type":"session.disconnect","at":"2026-08-04T01:16:48.951Z","tick":7916,"sessionId":"g_6","actorId":"drive-a","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T01:16:52.232Z","tick":7916,"reason":"interval","actorCount":2,"stateHash":"2a6abd4b64ad039f660aa06259d0fea0ff66fcfe35e8ca78acf4208d1dd2bb98"} +{"type":"session.connect","at":"2026-08-04T01:29:45.952Z","tick":7916,"sessionId":"g_1","actorId":"pd-1","playerId":"pd-1","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T01:29:45.974Z","tick":7916,"reason":"interval","actorCount":2,"stateHash":"bd85cc8f2c307d90aa871d2e0cbce067c3d0e98f669fd7f9c9722630c4594543"} +{"type":"checkpoint","at":"2026-08-04T01:29:50.979Z","tick":8067,"reason":"interval","actorCount":2,"stateHash":"79e9cba397de5f8778861ede975e6f3b561f065507b4484711b30131ff4a8880"} +{"type":"checkpoint","at":"2026-08-04T01:29:56.000Z","tick":8217,"reason":"interval","actorCount":2,"stateHash":"fee4cf4b97649a2a037194e1a037e83ffaabe19caf646e27821df7d540799a9e"} +{"type":"checkpoint","at":"2026-08-04T01:30:01.002Z","tick":8367,"reason":"interval","actorCount":2,"stateHash":"ba8ca1623b2ee9e9790b9393dc910bc0b90c6ae1f9ae8ae3f2906b3b35e77a8b"} +{"type":"checkpoint","at":"2026-08-04T01:30:06.012Z","tick":8518,"reason":"interval","actorCount":2,"stateHash":"5dddad60f1a4abd282bdd7f9abf6d1efdc8616bb8e8e944471235e654e109491"} +{"type":"checkpoint","at":"2026-08-04T01:30:11.024Z","tick":8668,"reason":"interval","actorCount":2,"stateHash":"febd68470d5731bab8e3b9aed7169040f8f1c5c49633396c4bdfb27c5b81fe0d"} +{"type":"checkpoint","at":"2026-08-04T01:30:16.028Z","tick":8818,"reason":"interval","actorCount":2,"stateHash":"24bbcf826f1b8deb7d28c6b1dcf3f65291d224d06091882867d371faa84c9942"} +{"type":"checkpoint","at":"2026-08-04T01:30:21.029Z","tick":8968,"reason":"interval","actorCount":2,"stateHash":"b189f7e4d55effdbf6badb0f0e688b1aa25fc04f3fe9c947d2b3bfdad052c139"} +{"type":"checkpoint","at":"2026-08-04T01:30:26.048Z","tick":9119,"reason":"interval","actorCount":2,"stateHash":"c919e43882b9792cb9573e6ac7b0e1054ead61e798547371239346547ed9c870"} +{"type":"checkpoint","at":"2026-08-04T01:30:31.063Z","tick":9269,"reason":"interval","actorCount":2,"stateHash":"66ea704ae1915ee1e40f90f0f7f7ad54676d543eac65c25a4a98c7ab7e482aec"} +{"type":"checkpoint","at":"2026-08-04T01:30:36.081Z","tick":9420,"reason":"interval","actorCount":2,"stateHash":"6b0ace6b274ae29939ab88165e78d035db7e54b81423084adb863ce10dad580c"} +{"type":"session.disconnect","at":"2026-08-04T01:30:39.419Z","tick":9520,"sessionId":"g_1","actorId":"pd-1","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T01:30:41.085Z","tick":9520,"reason":"interval","actorCount":2,"stateHash":"6fa8ff9f347066df13638ceb7f2834d811238dc7380032495b25a77dfd76169f"} +{"type":"session.connect","at":"2026-08-04T01:45:47.840Z","tick":9520,"sessionId":"g_2","actorId":"pd-2","playerId":"pd-2","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T01:45:47.856Z","tick":9520,"reason":"interval","actorCount":2,"stateHash":"399f3c18b6363a0d01a5d946719f7df86bb4e19038567649b3321151dca2ab5c"} +{"type":"checkpoint","at":"2026-08-04T01:45:52.871Z","tick":9671,"reason":"interval","actorCount":2,"stateHash":"9fd31a839f17a7d082c357d561a676b7a89518b24c454c74a20ffc7ae4cf21df"} +{"type":"checkpoint","at":"2026-08-04T01:45:57.893Z","tick":9821,"reason":"interval","actorCount":2,"stateHash":"8f130f8e7e783f215e467afe485bcbdc76c5997060e86c9ff716879595a7b459"} +{"type":"session.disconnect","at":"2026-08-04T01:46:01.945Z","tick":9943,"sessionId":"g_2","actorId":"pd-2","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T01:46:02.896Z","tick":9943,"reason":"interval","actorCount":2,"stateHash":"751b410097f0a9f1d3a39a7872832a50c86d45efb946300e08d2258acacbc261"} +{"type":"session.connect","at":"2026-08-04T01:53:36.299Z","tick":9943,"sessionId":"g_3","actorId":"pd-3","playerId":"pd-3","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T01:53:36.311Z","tick":9944,"reason":"interval","actorCount":2,"stateHash":"85d39f1b238797b6d660f5f970562d8d3c4afc58d3673853172411ad3b17b5b4"} +{"type":"checkpoint","at":"2026-08-04T01:53:41.325Z","tick":10094,"reason":"interval","actorCount":2,"stateHash":"6e6a4f8cd92053f85cd8b05c896fb9b6d5199a3eb97268ab1f3facdb578f464d"} +{"type":"checkpoint","at":"2026-08-04T01:53:46.340Z","tick":10244,"reason":"interval","actorCount":2,"stateHash":"7476bdaf798d3fd8f0405763fc3413a033a8ddf2525e1652378a6d468def682a"} +{"type":"session.disconnect","at":"2026-08-04T01:53:51.088Z","tick":10387,"sessionId":"g_3","actorId":"pd-3","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T01:53:51.344Z","tick":10387,"reason":"interval","actorCount":2,"stateHash":"986ddebd8404702b580dc3ad3d82e104d03be3164b0e554cf5081f390d514875"} +{"type":"session.connect","at":"2026-08-04T02:03:18.224Z","tick":10387,"sessionId":"g_4","actorId":"pd-4","playerId":"pd-4","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T02:03:18.239Z","tick":10387,"reason":"interval","actorCount":2,"stateHash":"d6bcf8fb56c6d0af2163211cc2149bbcae2a56bbfa43fa56f8623f717d7d7701"} +{"type":"checkpoint","at":"2026-08-04T02:03:23.249Z","tick":10538,"reason":"interval","actorCount":2,"stateHash":"419afbb521eb3ce39acb0219f27f4d8a68b868f02121b1ee1493fb77234f0f3b"} +{"type":"checkpoint","at":"2026-08-04T02:03:28.254Z","tick":10688,"reason":"interval","actorCount":2,"stateHash":"8993c0c19a3891c0197f03dee6e0f282eb47a9b576c782ac756c0fefdfb73881"} +{"type":"session.disconnect","at":"2026-08-04T02:03:32.486Z","tick":10815,"sessionId":"g_4","actorId":"pd-4","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T02:03:33.263Z","tick":10815,"reason":"interval","actorCount":2,"stateHash":"ad616d1fdb266d3e6d0b5739ff71958e3874371b9dec7ebff7e221741a2737a5"} +{"type":"session.connect","at":"2026-08-04T02:08:30.860Z","tick":10815,"sessionId":"g_5","actorId":"pd-5","playerId":"pd-5","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T02:08:30.870Z","tick":10815,"reason":"interval","actorCount":2,"stateHash":"8db44fdf53724ee5053c4db5c49009cac8fc54ec0b789d952bedec63c75fd13b"} +{"type":"checkpoint","at":"2026-08-04T02:08:35.885Z","tick":10966,"reason":"interval","actorCount":2,"stateHash":"553700cd69f1b28229f5c0d2211efbd885fda840a6bf1d83389c9613568269ad"} +{"type":"session.disconnect","at":"2026-08-04T02:08:37.877Z","tick":11026,"sessionId":"g_5","actorId":"pd-5","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T02:08:40.894Z","tick":11026,"reason":"interval","actorCount":2,"stateHash":"98c64b1abdb8e009c2fd9403ee84ba69012a0bb23b000d3225534c9f99e3f8ff"} +{"type":"session.connect","at":"2026-08-04T02:08:58.090Z","tick":11026,"sessionId":"g_6","actorId":"pd-6","playerId":"pd-6","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T02:08:58.108Z","tick":11027,"reason":"interval","actorCount":2,"stateHash":"9aed0640caa64924586495f01d412baf575f799f88b6a12533ea7a3cd6d4bebe"} +{"type":"checkpoint","at":"2026-08-04T02:09:03.124Z","tick":11177,"reason":"interval","actorCount":2,"stateHash":"10f648c764b6930624554ace7bf58516ab16e5857934c61c72b50952ed487677"} +{"type":"session.disconnect","at":"2026-08-04T02:09:04.883Z","tick":11230,"sessionId":"g_6","actorId":"pd-6","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T02:09:08.133Z","tick":11230,"reason":"interval","actorCount":2,"stateHash":"0069a57a1c9799e51e66d48029ec4e454a38af3548efaa02860a531b6ca19683"} +{"type":"session.connect","at":"2026-08-04T02:09:52.807Z","tick":11230,"sessionId":"g_1","actorId":"pd-7","playerId":"pd-7","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T02:09:52.821Z","tick":11230,"reason":"interval","actorCount":2,"stateHash":"07dfb43bbf1350e0bd1aa7e55d2d73c3f19d09e7c23673630ac6fd43a4596b84"} +{"type":"checkpoint","at":"2026-08-04T02:09:57.829Z","tick":11380,"reason":"interval","actorCount":2,"stateHash":"52764a6048a5eee3104ea1e906f024b64146ce70cf66d4a4e0e202351c1da3c1"} +{"type":"checkpoint","at":"2026-08-04T02:10:02.839Z","tick":11531,"reason":"interval","actorCount":2,"stateHash":"326ba36c87c79d949f1f4ec12585ae42cc7975ecd3110ab7350e555ce73fca11"} +{"type":"checkpoint","at":"2026-08-04T02:10:07.852Z","tick":11681,"reason":"interval","actorCount":2,"stateHash":"6a6381b724947dbdf00c5d32db96fab9334cfd78c48e25f2bc874e32656890f5"} +{"type":"checkpoint","at":"2026-08-04T02:10:12.861Z","tick":11831,"reason":"interval","actorCount":2,"stateHash":"663ebf123cc8ffe94c25edaa31bfca73b4e9cfaceda7a4cb2c7c86cba79f6b8a"} +{"type":"session.disconnect","at":"2026-08-04T02:10:14.225Z","tick":11872,"sessionId":"g_1","actorId":"pd-7","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T02:10:17.875Z","tick":11872,"reason":"interval","actorCount":2,"stateHash":"d2e4413d071a4c7ec6909b57d760ad5a0b686fa2ecfdac89cb404797d314bdeb"} +{"type":"session.connect","at":"2026-08-04T02:13:34.758Z","tick":11872,"sessionId":"g_2","actorId":"pd-8","playerId":"pd-8","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T02:13:34.769Z","tick":11872,"reason":"interval","actorCount":2,"stateHash":"5d874e1f1744dc0bfed21fd64925597321805b1662fb3bbfa519fdbb3c44b77d"} +{"type":"checkpoint","at":"2026-08-04T02:13:39.780Z","tick":12023,"reason":"interval","actorCount":2,"stateHash":"8f6150fbe4bbd5c2c184d960297e607ce598a4d14f35aae55ce19b9e05c4a25d"} +{"type":"checkpoint","at":"2026-08-04T02:13:44.803Z","tick":12173,"reason":"interval","actorCount":2,"stateHash":"876e3cba4ad9f45979553d4f46aacf188cb062e66c7a8d45386ffcb86820e9f2"} +{"type":"session.disconnect","at":"2026-08-04T02:13:49.530Z","tick":12315,"sessionId":"g_2","actorId":"pd-8","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T02:13:49.806Z","tick":12315,"reason":"interval","actorCount":2,"stateHash":"88f08ec4b69f492d667a1072cece82cfe1fa49e693228ec596b49d2c63954b46"} +{"type":"session.connect","at":"2026-08-04T02:21:42.793Z","tick":12315,"sessionId":"g_3","actorId":"pd-9","playerId":"pd-9","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T02:21:42.818Z","tick":12315,"reason":"interval","actorCount":2,"stateHash":"82fd94fc7ee57a4c33ed7a49173d08c11ac05e1e03880e53cee38ed6b3ab42cc"} +{"type":"checkpoint","at":"2026-08-04T02:21:47.826Z","tick":12466,"reason":"interval","actorCount":2,"stateHash":"74d24845ea1836c8c9b3e63a8c29ed31c248536c2690719912d325365cfc91a1"} +{"type":"session.disconnect","at":"2026-08-04T02:21:49.362Z","tick":12512,"sessionId":"g_3","actorId":"pd-9","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T02:21:52.838Z","tick":12512,"reason":"interval","actorCount":2,"stateHash":"41b7e51c98e562cca83ffa3b28254abab645445a3ee13d5c7f092e95806982da"} +{"type":"session.connect","at":"2026-08-04T02:22:28.399Z","tick":12512,"sessionId":"g_1","actorId":"fx-1","playerId":"fx-1","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T02:22:28.418Z","tick":12512,"reason":"interval","actorCount":2,"stateHash":"6cfc3d04b60e0387e7278be9d760bccc030d46de2ec981d351dbdd1c980d03ae"} +{"type":"checkpoint","at":"2026-08-04T02:22:33.432Z","tick":12663,"reason":"interval","actorCount":2,"stateHash":"d3b1d007e9cdc7fb534157fb5e4f9d2eeb3791835202cee2800d94acb1ed854c"} +{"type":"session.disconnect","at":"2026-08-04T02:22:34.757Z","tick":12703,"sessionId":"g_1","actorId":"fx-1","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T02:22:38.446Z","tick":12703,"reason":"interval","actorCount":2,"stateHash":"bac786bc0d6ceb3e0042327d87b9a10a4681c4f72c4344182dfc8223987a9d3c"} +{"type":"session.connect","at":"2026-08-04T02:45:04.433Z","tick":12703,"sessionId":"g_1","actorId":"cs-1","playerId":"cs-1","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T02:45:04.457Z","tick":12703,"reason":"interval","actorCount":2,"stateHash":"801d082e10a8505fb3d927f484d530e658160b2cf7a04a722065c13c63b185ab"} +{"type":"checkpoint","at":"2026-08-04T02:45:09.466Z","tick":12854,"reason":"interval","actorCount":2,"stateHash":"da6970f231eed50861e5e1cdf973e2ed0db3c7f3e0909ed20ec9cc22857e5773"} +{"type":"checkpoint","at":"2026-08-04T02:45:14.471Z","tick":13004,"reason":"interval","actorCount":2,"stateHash":"9ba67bed91b347deee87c831f30c02df757548497733b11434ccb7785160906e"} +{"type":"session.disconnect","at":"2026-08-04T02:45:14.661Z","tick":13009,"sessionId":"g_1","actorId":"cs-1","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T02:45:19.480Z","tick":13009,"reason":"interval","actorCount":2,"stateHash":"69afa4f9270097a5506b0e4f39e3e085c5de8c7f7dd0b8e6299790452543091b"} +{"type":"session.connect","at":"2026-08-04T02:55:45.366Z","tick":13009,"sessionId":"g_1","actorId":"play-1","playerId":"play-1","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T02:55:45.383Z","tick":13009,"reason":"interval","actorCount":2,"stateHash":"c13fb870a5144d7f5d54d31d7f62d99cfa1789fc8842a5404029e3546fa93d9a"} +{"type":"checkpoint","at":"2026-08-04T02:55:50.388Z","tick":13159,"reason":"interval","actorCount":2,"stateHash":"dbe2900e12f324ce96d0bc7e701fa4a16d9087dac5cde26dd2ea0be7cc9831db"} +{"type":"command.receipt","at":"2026-08-04T02:55:53.777Z","tick":13261,"actorId":"play-1","commandId":1633000,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633000,"issued_at_tick":13261,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"9cf9f49397e4300eb500b53198287a44d902f29f6ed2e2aec7ad69c37f027508"}} +{"type":"command.receipt","at":"2026-08-04T02:55:53.877Z","tick":13264,"actorId":"play-1","commandId":1633001,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633001,"issued_at_tick":13264,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"eb331dd841c47a58ca2b575241fa7fde9d6333f46c722746ee398bbb555b2b71"}} +{"type":"command.receipt","at":"2026-08-04T02:55:53.976Z","tick":13267,"actorId":"play-1","commandId":1633002,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633002,"issued_at_tick":13267,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"b7bf0e1bea5a85476ae5ec3021647e0576369cc926f0979ad919e364ac6825f4"}} +{"type":"command.receipt","at":"2026-08-04T02:55:54.076Z","tick":13270,"actorId":"play-1","commandId":1633003,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633003,"issued_at_tick":13270,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"4c53de0b58a985d3dcb028a7dc0c17f822284d940ac4d33639265148f3aae653"}} +{"type":"command.receipt","at":"2026-08-04T02:55:54.175Z","tick":13273,"actorId":"play-1","commandId":1633004,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633004,"issued_at_tick":13273,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"9d4b7667cce63a1fd0290abcaeb5ae055687c25be69cde57158c0358137952a1"}} +{"type":"command.receipt","at":"2026-08-04T02:55:54.276Z","tick":13276,"actorId":"play-1","commandId":1633005,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633005,"issued_at_tick":13276,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"4342a2fe989f935f224b84cef1eb9f7d301e46acaec6d23bbd8bec999eaeb1ad"}} +{"type":"command.receipt","at":"2026-08-04T02:55:54.376Z","tick":13279,"actorId":"play-1","commandId":1633006,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633006,"issued_at_tick":13279,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"97dbe0426a5b631e2208aac8197382dfc5245589f5614ab03fcb6efde8c226b2"}} +{"type":"command.receipt","at":"2026-08-04T02:55:54.476Z","tick":13282,"actorId":"play-1","commandId":1633007,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633007,"issued_at_tick":13282,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"ea20ec295111e37068911aee24137f59a78f84ae2cd228b4485ddf3fc079c70b"}} +{"type":"command.receipt","at":"2026-08-04T02:55:54.576Z","tick":13285,"actorId":"play-1","commandId":1633008,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633008,"issued_at_tick":13285,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"e6aaa1a98476460e4510b6ea01b80a5253b0812968fbd661cf4d021223cc848c"}} +{"type":"command.receipt","at":"2026-08-04T02:55:54.673Z","tick":13288,"actorId":"play-1","commandId":1633009,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633009,"issued_at_tick":13288,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"302f19a8dac2c7d41229013ff8ed3847ee8a40f2ab4b4f3d4d925a440d114fab"}} +{"type":"command.receipt","at":"2026-08-04T02:55:54.767Z","tick":13291,"actorId":"play-1","commandId":1633010,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633010,"issued_at_tick":13291,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"3bb1dd669cf89ed9c38b4c869682646c5413de156377afa49e6a229372c68988"}} +{"type":"command.receipt","at":"2026-08-04T02:55:54.863Z","tick":13294,"actorId":"play-1","commandId":1633011,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633011,"issued_at_tick":13294,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"7970f0af2b2e1149fded1a19c9101e8eff498a959e3560aa22939af5bd20c601"}} +{"type":"command.receipt","at":"2026-08-04T02:55:54.958Z","tick":13297,"actorId":"play-1","commandId":1633012,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633012,"issued_at_tick":13296,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"39bb4021ad169a3299835fb7d1b4b0dd33d3467fa66ed5216ffe9c5488dae024"}} +{"type":"command.receipt","at":"2026-08-04T02:55:55.050Z","tick":13299,"actorId":"play-1","commandId":1633013,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633013,"issued_at_tick":13299,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"059b101604cfdb1024f8545389192ad777f6ce3cfa621a051bc93af67918d589"}} +{"type":"command.receipt","at":"2026-08-04T02:55:55.125Z","tick":13302,"actorId":"play-1","commandId":1633014,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633014,"issued_at_tick":13302,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":1,"player":1,"stateHash":"2731b568cbdf77a6b12cee76f02295670b1154b0f996c28afef8988b3099d717"}} +{"type":"command.receipt","at":"2026-08-04T02:55:55.141Z","tick":13302,"actorId":"play-1","commandId":1633015,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633015,"issued_at_tick":13302,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":1,"player":1,"stateHash":"2731b568cbdf77a6b12cee76f02295670b1154b0f996c28afef8988b3099d717"}} +{"type":"command.receipt","at":"2026-08-04T02:55:55.236Z","tick":13305,"actorId":"play-1","commandId":1633016,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633016,"issued_at_tick":13305,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":1,"player":1,"stateHash":"01b848dabc0c69a55f45a271bf60ba91134b6e37520651768233cd1ef8bf9959"}} +{"type":"checkpoint","at":"2026-08-04T02:55:55.392Z","tick":13310,"reason":"interval","actorCount":2,"stateHash":"844d5ca82a09b07a0de632144038d32d4468addc6a09480ebbe64845aa75a8eb"} +{"type":"command.receipt","at":"2026-08-04T02:55:55.421Z","tick":13310,"actorId":"play-1","commandId":1633018,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633018,"issued_at_tick":13310,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":1,"player":1,"stateHash":"5829df95bafc435e5651354a3ed9ce240330a2dcbddb1f6d10df956c0c7fb708"}} +{"type":"command.receipt","at":"2026-08-04T02:55:55.604Z","tick":13316,"actorId":"play-1","commandId":1633020,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633020,"issued_at_tick":13316,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"793bcbff39769a94aec11ef63af9f7ffc29bdc463ec036dcf7a0ac17875806a9"}} +{"type":"command.receipt","at":"2026-08-04T02:55:55.788Z","tick":13321,"actorId":"play-1","commandId":1633022,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633022,"issued_at_tick":13321,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"64a9ef95b30ca54bb0d07331d99c256d0c943a8e612fc86d49a4b31dc6820936"}} +{"type":"command.receipt","at":"2026-08-04T02:55:56.058Z","tick":13329,"actorId":"play-1","commandId":1633025,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633025,"issued_at_tick":13329,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"f6aa02926f35cb42756ccc3ea9515ca3053bbee403500d1291966003d9945904"}} +{"type":"command.receipt","at":"2026-08-04T02:55:56.241Z","tick":13335,"actorId":"play-1","commandId":1633027,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633027,"issued_at_tick":13335,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"1047436a6278b49c3a418c4f62915a7e7af3da48c6c9bd234adf369c99297fe8"}} +{"type":"command.receipt","at":"2026-08-04T02:55:56.421Z","tick":13340,"actorId":"play-1","commandId":1633029,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633029,"issued_at_tick":13340,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"4b151e6245fedb8d27ad0b2775503e970de45449c1d08ba678bd3bec9df4c923"}} +{"type":"command.receipt","at":"2026-08-04T02:55:56.601Z","tick":13346,"actorId":"play-1","commandId":1633031,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633031,"issued_at_tick":13346,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"d484e70b034f41ae06f094589f3e425998985cbfad0fa5f5c0ab6681ed6b16a1"}} +{"type":"command.receipt","at":"2026-08-04T02:55:56.787Z","tick":13351,"actorId":"play-1","commandId":1633033,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633033,"issued_at_tick":13351,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"7ebf22328eb21eeb4b5e4e43223dcd6d7966f40c5479374a0770656872286cbd"}} +{"type":"command.receipt","at":"2026-08-04T02:55:57.060Z","tick":13360,"actorId":"play-1","commandId":1633036,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633036,"issued_at_tick":13360,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"45d7c40951ba336c99aff697d17baa330888b3a8b011372b2808dbfacba2791f"}} +{"type":"command.receipt","at":"2026-08-04T02:55:57.242Z","tick":13365,"actorId":"play-1","commandId":1633038,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633038,"issued_at_tick":13365,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"f1a394f913e755a9e93f597dbaa95392ce38ef7470d1ba0b55f7d5d60fa4ea6e"}} +{"type":"command.receipt","at":"2026-08-04T02:55:57.430Z","tick":13371,"actorId":"play-1","commandId":1633040,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633040,"issued_at_tick":13371,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"932854dda19fdcf0c04a9845a4072d4634749dacad609b76f2fee861e74479dc"}} +{"type":"command.receipt","at":"2026-08-04T02:55:57.616Z","tick":13376,"actorId":"play-1","commandId":1633042,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633042,"issued_at_tick":13376,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"782ed81ad1301ef6978bf653a9ef9758c58303825ca2600b204c2e6aa9b85c3d"}} +{"type":"command.receipt","at":"2026-08-04T02:55:57.804Z","tick":13382,"actorId":"play-1","commandId":1633044,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633044,"issued_at_tick":13382,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"8f20816c5d43db88dd7536bf7b297923b4489ce4f20a2f9b1a29de30d953fe04"}} +{"type":"command.receipt","at":"2026-08-04T02:55:57.991Z","tick":13387,"actorId":"play-1","commandId":1633046,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633046,"issued_at_tick":13387,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"14d43da25afede8476e877d7da82379f11ba1546f4abdf0a3a60cac3bd9ab6aa"}} +{"type":"command.receipt","at":"2026-08-04T02:55:58.187Z","tick":13393,"actorId":"play-1","commandId":1633048,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633048,"issued_at_tick":13393,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"92dd6a1834d5a2a3171a02c2ccd3d468d8d978f8c4d5a79a356cfad2cdc82b48"}} +{"type":"command.receipt","at":"2026-08-04T02:55:58.465Z","tick":13402,"actorId":"play-1","commandId":1633051,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633051,"issued_at_tick":13402,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"6047023226ba68e7aed5a98ffcc409b8244cc533225bcb4a6eced0a9634b35a4"}} +{"type":"command.receipt","at":"2026-08-04T02:55:58.649Z","tick":13407,"actorId":"play-1","commandId":1633053,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633053,"issued_at_tick":13407,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"d35a22e58d4c99b91165fe0e403ff3dbdd61fd5833facc441513fafe950d238c"}} +{"type":"command.receipt","at":"2026-08-04T02:55:58.833Z","tick":13413,"actorId":"play-1","commandId":1633055,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633055,"issued_at_tick":13413,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"b572187c8967ef81ac1dcf14df5fab9b1bc4d4e468ca194cdc1302bef4f4fd4b"}} +{"type":"command.receipt","at":"2026-08-04T02:55:59.025Z","tick":13419,"actorId":"play-1","commandId":1633057,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633057,"issued_at_tick":13419,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"ad046529a0d897b37084f16f064a021a5dc0dfd2b4c6c2007497618f1557a350"}} +{"type":"command.receipt","at":"2026-08-04T02:55:59.220Z","tick":13424,"actorId":"play-1","commandId":1633060,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633060,"issued_at_tick":13424,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"914ebb80c12a714feb0c9823a53e70bc2951dc8726d590625b7e0a152bbb933b"}} +{"type":"command.receipt","at":"2026-08-04T02:55:59.404Z","tick":13430,"actorId":"play-1","commandId":1633062,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633062,"issued_at_tick":13430,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"7c9773f8caffb8201011a070beddee9116af22609834678f997f8ac861a8e637"}} +{"type":"command.receipt","at":"2026-08-04T02:55:59.592Z","tick":13436,"actorId":"play-1","commandId":1633064,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633064,"issued_at_tick":13436,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"65efa04b583868054e9bbf2630d346673a8d1f7829015575a38af6fe0f850524"}} +{"type":"command.receipt","at":"2026-08-04T02:56:00.232Z","tick":13455,"actorId":"play-1","commandId":1633066,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633066,"issued_at_tick":13455,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"e98eede3e423329278b45417c625dfe75bf8d1cf985b3e80f0a21d216a52fd1f"}} +{"type":"command.receipt","at":"2026-08-04T02:56:00.328Z","tick":13458,"actorId":"play-1","commandId":1633067,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633067,"issued_at_tick":13458,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"ddc98b96be46a47aaf8d6616533e6e03399d7a7b9ca01fb60c9c332e9ecd06ae"}} +{"type":"checkpoint","at":"2026-08-04T02:56:00.405Z","tick":13460,"reason":"interval","actorCount":2,"stateHash":"53ec770066bc015dc98b00b2c64c26a7207d8b50d7621852449b9ac8639ef6c2"} +{"type":"command.receipt","at":"2026-08-04T02:56:00.429Z","tick":13461,"actorId":"play-1","commandId":1633068,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633068,"issued_at_tick":13460,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"62a08d20586f3cc3541c5f1130ccc6f69a389947dba03f25d7e47734e1cd822b"}} +{"type":"command.receipt","at":"2026-08-04T02:56:00.525Z","tick":13464,"actorId":"play-1","commandId":1633069,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633069,"issued_at_tick":13463,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"e2171e4d8c1dcbaba3b0b878f6756a93a583bafa644837f38e49452dd57be2b5"}} +{"type":"command.receipt","at":"2026-08-04T02:56:00.626Z","tick":13467,"actorId":"play-1","commandId":1633070,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633070,"issued_at_tick":13466,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"8e463c9b10cef4705e7001c1d1655d9e2540d07ae1f02e5637ae9949b2e8cee3"}} +{"type":"command.receipt","at":"2026-08-04T02:56:00.826Z","tick":13473,"actorId":"play-1","commandId":1633072,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633072,"issued_at_tick":13472,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"0cba19ef7da3d5c04c03645a25ea13c6d0826532000368bd74494ae4b4a459aa"}} +{"type":"command.receipt","at":"2026-08-04T02:56:01.025Z","tick":13479,"actorId":"play-1","commandId":1633074,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633074,"issued_at_tick":13478,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"fcb37336053b951f2ebc67d0f51d51fbb03adb1c6834bd93b907d63786ec21e9"}} +{"type":"command.receipt","at":"2026-08-04T02:56:01.226Z","tick":13485,"actorId":"play-1","commandId":1633076,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633076,"issued_at_tick":13484,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"678322ed18e9898cb3c0d6faeac7955363ccfb8834b7e43244eb81a3d617f3aa"}} +{"type":"command.receipt","at":"2026-08-04T02:56:01.426Z","tick":13491,"actorId":"play-1","commandId":1633078,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633078,"issued_at_tick":13490,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"f70c2da67e883a6c2e1c580145bd4ace1603d591f17cd6167a8b20532bc56219"}} +{"type":"command.receipt","at":"2026-08-04T02:56:01.627Z","tick":13497,"actorId":"play-1","commandId":1633080,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633080,"issued_at_tick":13496,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"7a72cdca8849d8732f8f5f361f6e28c1b1be16b19242cbad58c5e1366b25e8b1"}} +{"type":"command.receipt","at":"2026-08-04T02:56:01.826Z","tick":13503,"actorId":"play-1","commandId":1633082,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633082,"issued_at_tick":13502,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"95526dedb9caf2d23ed2791bf97cda523c03e29d3eda2fb8763af41b34b51ba0"}} +{"type":"command.receipt","at":"2026-08-04T02:56:02.029Z","tick":13509,"actorId":"play-1","commandId":1633084,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633084,"issued_at_tick":13509,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"84bd0d17712c0879e29dddfeff2424b94882c354713173a84f7b70aaf814bad4"}} +{"type":"command.receipt","at":"2026-08-04T02:56:02.225Z","tick":13515,"actorId":"play-1","commandId":1633086,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633086,"issued_at_tick":13515,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"ff985b6f595ece761734129bcc76bc1a9d742c9e6f816977c5ebdd224b1afbab"}} +{"type":"command.receipt","at":"2026-08-04T02:56:02.425Z","tick":13520,"actorId":"play-1","commandId":1633088,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633088,"issued_at_tick":13520,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"91ce4d35ecb6de45e30e24a14445f5ba9b8a3a310be580670ad84926a5769d14"}} +{"type":"command.receipt","at":"2026-08-04T02:56:02.625Z","tick":13526,"actorId":"play-1","commandId":1633090,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633090,"issued_at_tick":13526,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"6457161fae467765ef43c28ec1c9263c4da8ce36c61273e4201fbe861a3ad1b4"}} +{"type":"command.receipt","at":"2026-08-04T02:56:02.825Z","tick":13533,"actorId":"play-1","commandId":1633092,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633092,"issued_at_tick":13532,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"df546f6d29047642f1d4cb039c8ea5b29c3f7e0ba9b52aca7e028f01ceebbe07"}} +{"type":"command.receipt","at":"2026-08-04T02:56:03.026Z","tick":13539,"actorId":"play-1","commandId":1633094,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633094,"issued_at_tick":13539,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"973488811545c8370537443c49661e8edc4c9314a272ad6fdd783e5db6922882"}} +{"type":"command.receipt","at":"2026-08-04T02:56:03.226Z","tick":13545,"actorId":"play-1","commandId":1633096,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633096,"issued_at_tick":13544,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"5220b35894e4da9788357218e29f74f5bdb4267e40b58e57fa4755d07ae37693"}} +{"type":"command.receipt","at":"2026-08-04T02:56:03.427Z","tick":13551,"actorId":"play-1","commandId":1633098,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633098,"issued_at_tick":13551,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"586798f240559ca3fc873c4b4dfad81ab5396b0f256b11444fe27d6953a8ef6a"}} +{"type":"command.receipt","at":"2026-08-04T02:56:03.626Z","tick":13557,"actorId":"play-1","commandId":1633100,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633100,"issued_at_tick":13557,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"f0d81a77de9c3b53302957e72a51cd5345bf9c445d26a40493425d2ef5374792"}} +{"type":"command.receipt","at":"2026-08-04T02:56:03.825Z","tick":13563,"actorId":"play-1","commandId":1633102,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633102,"issued_at_tick":13562,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"afc4d463483d7c8c5232a04040e64b898e37b6cef303d4a9da74f52f5ae991a7"}} +{"type":"command.receipt","at":"2026-08-04T02:56:04.025Z","tick":13569,"actorId":"play-1","commandId":1633104,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633104,"issued_at_tick":13568,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"cb98e84f57ab390a4dfb4cf9976cfa1448b9b36a670e7961c49aa04697c51cd1"}} +{"type":"command.receipt","at":"2026-08-04T02:56:04.226Z","tick":13575,"actorId":"play-1","commandId":1633106,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633106,"issued_at_tick":13575,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"aa67ed6c0328f412926e9fecfad6bf35f762bc639323849dd026295ebb31b66c"}} +{"type":"command.receipt","at":"2026-08-04T02:56:04.427Z","tick":13581,"actorId":"play-1","commandId":1633108,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633108,"issued_at_tick":13581,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"4edd66657a19d5edaf8ffaddc4604ea12c85930816895857b002289d4ffb674b"}} +{"type":"command.receipt","at":"2026-08-04T02:56:04.626Z","tick":13587,"actorId":"play-1","commandId":1633110,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633110,"issued_at_tick":13586,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"d03c9f2e96108e708b0fea9fda10a67af464844cb45ef33738ac9bd0c57b204d"}} +{"type":"command.receipt","at":"2026-08-04T02:56:04.826Z","tick":13593,"actorId":"play-1","commandId":1633113,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633113,"issued_at_tick":13593,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"68846642bb3e5f193c9d661b6f275d39efd31f60545611b2db4b1c064a797fea"}} +{"type":"command.receipt","at":"2026-08-04T02:56:05.027Z","tick":13599,"actorId":"play-1","commandId":1633115,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633115,"issued_at_tick":13598,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"bb1b1de1b96ae9bc2cfd504cb9549455fcc6bd0cdb19cd366054a4a06f915642"}} +{"type":"command.receipt","at":"2026-08-04T02:56:05.230Z","tick":13605,"actorId":"play-1","commandId":1633117,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633117,"issued_at_tick":13605,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"cef03694ede169b67cd0e978cb2408a9c27ae196a31820604de9ef5f29d2f80a"}} +{"type":"checkpoint","at":"2026-08-04T02:56:05.412Z","tick":13610,"reason":"interval","actorCount":2,"stateHash":"2f3e432b2165c5ac6bdba4d584997f62bc1c4a2641c66b0e248d7ad3ec6a6160"} +{"type":"command.receipt","at":"2026-08-04T02:56:05.443Z","tick":13611,"actorId":"play-1","commandId":1633119,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633119,"issued_at_tick":13610,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"c79ec87f04993cbc54cb3b16b99cecd43cd09ecd6ef2302627887fa8f5d88066"}} +{"type":"command.receipt","at":"2026-08-04T02:56:05.634Z","tick":13617,"actorId":"play-1","commandId":1633121,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633121,"issued_at_tick":13617,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"b1f530645eccf80926e20f96c03fd9f68b3b26b29f81d6a263b510a1b4a9ad20"}} +{"type":"command.receipt","at":"2026-08-04T02:56:05.830Z","tick":13623,"actorId":"play-1","commandId":1633123,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633123,"issued_at_tick":13623,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"53841674b672d3c166196888c9e84766422ce982ca3d96aacf4d413e5a3a278a"}} +{"type":"command.receipt","at":"2026-08-04T02:56:06.025Z","tick":13629,"actorId":"play-1","commandId":1633125,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633125,"issued_at_tick":13628,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"3a8ed8c559ab66348441401e0e186ace096202a523d4d2147b3d052849675e6b"}} +{"type":"command.receipt","at":"2026-08-04T02:56:06.226Z","tick":13635,"actorId":"play-1","commandId":1633127,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633127,"issued_at_tick":13634,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"f29d60ca4e1bc59b46f04a54d70018151d95aedf740f42bf9fa5b4394920949c"}} +{"type":"command.receipt","at":"2026-08-04T02:56:06.425Z","tick":13640,"actorId":"play-1","commandId":1633129,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633129,"issued_at_tick":13640,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"555adb8418b5d893b21a2db7c9b3f3db295669f500beefb991f8aa0ec32a3dee"}} +{"type":"command.receipt","at":"2026-08-04T02:56:06.627Z","tick":13647,"actorId":"play-1","commandId":1633131,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633131,"issued_at_tick":13646,"command":{"SetMoveIntent":{"dx":-1,"dy":1,"facing":"Left","sprint":false}}},"session":1,"player":1,"stateHash":"55202d164a2eabe1d10d7af02ecc0620b6b786ccc8214ec07e5c266bd61a01c2"}} +{"type":"command.receipt","at":"2026-08-04T02:56:06.809Z","tick":13652,"actorId":"play-1","commandId":1633133,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633133,"issued_at_tick":13652,"command":{"SetMoveIntent":{"dx":0,"dy":0,"sprint":false}}},"session":1,"player":1,"stateHash":"a476c7ba9d3eb62fe67ac51988df59d47723ecfa76095520b5ef13fbee94b693"}} +{"type":"checkpoint","at":"2026-08-04T02:56:10.427Z","tick":13761,"reason":"interval","actorCount":2,"stateHash":"2c1aa33b3494c1319313b7e7c39ae12a9935f5f86d53e30bdaf429013b856156"} +{"type":"checkpoint","at":"2026-08-04T02:56:15.438Z","tick":13911,"reason":"interval","actorCount":2,"stateHash":"6a7c6098a341adf54e02fbb7d06cb93764f05a547d127b1eba0968fb11fd2515"} +{"type":"checkpoint","at":"2026-08-04T02:56:20.456Z","tick":14061,"reason":"interval","actorCount":2,"stateHash":"88e97c76759f8fe525f24d51596d4efeb2bfddd905fe3f07024e14186dcd60b3"} +{"type":"checkpoint","at":"2026-08-04T02:56:25.460Z","tick":14212,"reason":"interval","actorCount":2,"stateHash":"937425b864e86fe9a3c0fddf79fdbb8441f19a654c3160ab382d29b6bf6524e4"} +{"type":"checkpoint","at":"2026-08-04T02:56:30.478Z","tick":14362,"reason":"interval","actorCount":2,"stateHash":"3b0d33e4fcd0d5bb458c9a47c4d3e0ed3a2ff190c9c75fdbd8a1a367f7301e77"} +{"type":"checkpoint","at":"2026-08-04T02:56:35.481Z","tick":14512,"reason":"interval","actorCount":2,"stateHash":"2e9065dd44eee6042bd992cf0facbb7d3eac3e1560d8bbe8435b449826fee0d4"} +{"type":"checkpoint","at":"2026-08-04T02:56:40.482Z","tick":14662,"reason":"interval","actorCount":2,"stateHash":"de401a04ff631ab731e0593e0a075eaa7e3a6fb51c4ec900aa6dc569fe6baf1b"} +{"type":"checkpoint","at":"2026-08-04T02:56:45.493Z","tick":14813,"reason":"interval","actorCount":2,"stateHash":"7352f934ebedce5921a6869c7b0e5134c278089a78897e278d68fe145af8dc94"} +{"type":"checkpoint","at":"2026-08-04T02:56:50.512Z","tick":14963,"reason":"interval","actorCount":2,"stateHash":"fe26388ef51d8973656fbf95a8bc69f2f0f8f276aed6d1863e385163dddf4579"} +{"type":"command.receipt","at":"2026-08-04T02:56:52.153Z","tick":15012,"actorId":"play-1","commandId":1633134,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633134,"issued_at_tick":15012,"command":{"SetMoveIntent":{"dx":1,"dy":1,"facing":"Right","sprint":false}}},"session":1,"player":1,"stateHash":"1970717fe798193f788464bfb1fa41e268fd0150b842f93625dc4ea8d5786fb6"}} +{"type":"command.receipt","at":"2026-08-04T02:56:52.185Z","tick":15013,"actorId":"play-1","commandId":1633135,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633135,"issued_at_tick":15013,"command":{"SetMoveIntent":{"dx":1,"dy":1,"facing":"Right","sprint":false}}},"session":1,"player":1,"stateHash":"d1876114333eecc1d6c72abebc45f9bef41741809db8aa8d5bb15cdb440a9ac4"}} +{"type":"command.receipt","at":"2026-08-04T02:56:52.201Z","tick":15014,"actorId":"play-1","commandId":1633136,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633136,"issued_at_tick":15014,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":1,"player":1,"stateHash":"8eeafc505ae0fe6e78ef926f527f5de97217b9fb86d8e3426d228ae90a3c4cd9"}} +{"type":"command.receipt","at":"2026-08-04T02:56:52.286Z","tick":15016,"actorId":"play-1","commandId":1633137,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633137,"issued_at_tick":15016,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":1,"player":1,"stateHash":"8405536e9fe8d63fe8275c9550120992714c408d315c2ffa67e055b3bac2d8ec"}} +{"type":"command.receipt","at":"2026-08-04T02:56:52.385Z","tick":15019,"actorId":"play-1","commandId":1633138,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633138,"issued_at_tick":15019,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":1,"player":1,"stateHash":"0488900550ca675bba5482d071f15c132ea53672d76f11dd834b96603e9e30be"}} +{"type":"command.receipt","at":"2026-08-04T02:56:52.485Z","tick":15022,"actorId":"play-1","commandId":1633139,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633139,"issued_at_tick":15022,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":1,"player":1,"stateHash":"4eaa66e9b6179ab4414226653678246ebf5906906149e2ee5f577b0ab2011abe"}} +{"type":"command.receipt","at":"2026-08-04T02:56:52.536Z","tick":15024,"actorId":"play-1","commandId":1633140,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633140,"issued_at_tick":15024,"command":{"SetMoveIntent":{"dx":1,"dy":-1,"facing":"Right","sprint":false}}},"session":1,"player":1,"stateHash":"9508400b31dd866e11d9a2d1854a99650f52d000b8d4acfdf9487c2507d635b7"}} +{"type":"command.receipt","at":"2026-08-04T02:56:52.585Z","tick":15025,"actorId":"play-1","commandId":1633141,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633141,"issued_at_tick":15025,"command":{"SetMoveIntent":{"dx":1,"dy":-1,"facing":"Right","sprint":false}}},"session":1,"player":1,"stateHash":"0cd9ac77668d3a2327a060a70056f6ecead2430924ecda1f33c6146586ef9ded"}} +{"type":"command.receipt","at":"2026-08-04T02:56:52.686Z","tick":15028,"actorId":"play-1","commandId":1633142,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633142,"issued_at_tick":15028,"command":{"SetMoveIntent":{"dx":1,"dy":-1,"facing":"Right","sprint":false}}},"session":1,"player":1,"stateHash":"8f040aafe2ac9addb87beeca5369ba41a8b5d07efef62e49b560e23b184fad96"}} +{"type":"command.receipt","at":"2026-08-04T02:56:52.785Z","tick":15031,"actorId":"play-1","commandId":1633143,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633143,"issued_at_tick":15031,"command":{"SetMoveIntent":{"dx":1,"dy":-1,"facing":"Right","sprint":false}}},"session":1,"player":1,"stateHash":"aa93bc035e4541b0c6553b262be2a4e81538edec0613308c4825cfe52c51b264"}} +{"type":"command.receipt","at":"2026-08-04T02:56:52.885Z","tick":15034,"actorId":"play-1","commandId":1633144,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633144,"issued_at_tick":15034,"command":{"SetMoveIntent":{"dx":1,"dy":-1,"facing":"Right","sprint":false}}},"session":1,"player":1,"stateHash":"f613e7507b646a6c65fb379bb6a5ab6abc053875ea1a0db9da3fb6ec24a70c47"}} +{"type":"command.receipt","at":"2026-08-04T02:56:52.984Z","tick":15037,"actorId":"play-1","commandId":1633145,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633145,"issued_at_tick":15037,"command":{"SetMoveIntent":{"dx":1,"dy":-1,"facing":"Right","sprint":false}}},"session":1,"player":1,"stateHash":"911e15fd4041a5af18713a5f6210dcf987945b4b654e81cefd20718b73003503"}} +{"type":"command.receipt","at":"2026-08-04T02:56:53.084Z","tick":15040,"actorId":"play-1","commandId":1633146,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633146,"issued_at_tick":15040,"command":{"SetMoveIntent":{"dx":1,"dy":-1,"facing":"Right","sprint":false}}},"session":1,"player":1,"stateHash":"392a54515caaeee85b933c0af1f095102053da18ee50cbff542a026b730e4cf5"}} +{"type":"command.receipt","at":"2026-08-04T02:56:53.184Z","tick":15043,"actorId":"play-1","commandId":1633147,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633147,"issued_at_tick":15043,"command":{"SetMoveIntent":{"dx":1,"dy":-1,"facing":"Right","sprint":false}}},"session":1,"player":1,"stateHash":"f4dd4f0f9cf9a0c73dadcff00f0a386a2c9c1b9c78adcd1bc1d6200fc46f53b0"}} +{"type":"command.receipt","at":"2026-08-04T02:56:53.201Z","tick":15044,"actorId":"play-1","commandId":1633148,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633148,"issued_at_tick":15044,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":1,"player":1,"stateHash":"fae2edd43445d93bb5b915f218b1736424b2c3a0a546f690a3c9af4872a2c047"}} +{"type":"command.receipt","at":"2026-08-04T02:56:53.387Z","tick":15049,"actorId":"play-1","commandId":1633150,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1633,"player":2152542497,"command_id":1633150,"issued_at_tick":15049,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":1,"player":1,"stateHash":"67c15754d32be5176314bebc0995e427eae4625ba910af10c62cb3bbd6b9c350"}} +{"type":"checkpoint","at":"2026-08-04T02:56:55.518Z","tick":15113,"reason":"interval","actorCount":2,"stateHash":"9c47edb74993c5250cf3a38b32dd33c6db1f1e237f0bf4d1d16a4c6b9694d3ec"} +{"type":"checkpoint","at":"2026-08-04T02:57:00.527Z","tick":15264,"reason":"interval","actorCount":2,"stateHash":"4bc6ddcafb7fa5574ab4dacae9b9462c9b87d8e025587ade020f651f85e888cd"} +{"type":"checkpoint","at":"2026-08-04T02:57:05.533Z","tick":15414,"reason":"interval","actorCount":2,"stateHash":"9935c5294e8c5d27d3d1706728efaa5ae7e12521136bc2d5edc9c4b78eaf025f"} +{"type":"checkpoint","at":"2026-08-04T02:57:10.536Z","tick":15564,"reason":"interval","actorCount":2,"stateHash":"fc312d62649d5abeafc1957a937491ed79a6192688c6c2742ada62c350f0e1ed"} +{"type":"checkpoint","at":"2026-08-04T02:57:15.541Z","tick":15714,"reason":"interval","actorCount":2,"stateHash":"b5b4892a8926027160b540d6312fdc5db439d5391effd2e341bb4aba3a447a29"} +{"type":"checkpoint","at":"2026-08-04T02:57:20.560Z","tick":15865,"reason":"interval","actorCount":2,"stateHash":"e7529f871c6eab2fff842596c4a625789e74056146f838e74720e0cdd047e43c"} +{"type":"checkpoint","at":"2026-08-04T02:57:25.567Z","tick":16015,"reason":"interval","actorCount":2,"stateHash":"fa0ffdcf0b42a24b1d8d5a774403ed05577743615f89194d8b102b457b10076a"} +{"type":"checkpoint","at":"2026-08-04T02:57:30.587Z","tick":16165,"reason":"interval","actorCount":2,"stateHash":"eed27fe56e36a4c6afcff57663b508db1eaeba99e563059f0fc9d4c499c9f17a"} +{"type":"checkpoint","at":"2026-08-04T02:57:35.593Z","tick":16316,"reason":"interval","actorCount":2,"stateHash":"e2b2d95179c54fa6f46f7c8337e5740a2c9024d64f7bf2249acc82c23f319818"} +{"type":"checkpoint","at":"2026-08-04T02:57:40.599Z","tick":16466,"reason":"interval","actorCount":2,"stateHash":"97d4e933410d5d6b4efeb099c6027161d89bea2a769ca9ffbb344b344c329cd9"} +{"type":"checkpoint","at":"2026-08-04T02:57:45.610Z","tick":16616,"reason":"interval","actorCount":2,"stateHash":"64b71eb39c8c4b6a834f27ac7730e4a065451bebb9322ef5ca57b81ae76725c4"} +{"type":"checkpoint","at":"2026-08-04T02:57:50.622Z","tick":16766,"reason":"interval","actorCount":2,"stateHash":"3757678f96621e8c397f3bfea7f76bf3a2384b3ddcbb45fb12dcbc04c0736f46"} +{"type":"checkpoint","at":"2026-08-04T02:57:55.624Z","tick":16916,"reason":"interval","actorCount":2,"stateHash":"3ab9d3b0ef292d99a26b72eaf814424eca3e7b6ccdcc97792be2cb8131487c7e"} +{"type":"checkpoint","at":"2026-08-04T02:58:00.628Z","tick":17067,"reason":"interval","actorCount":2,"stateHash":"77fd5df1f16f9064fde35039918151f9f8e5d54f2dbdc672b99354d635c4d5d0"} +{"type":"checkpoint","at":"2026-08-04T02:58:05.637Z","tick":17217,"reason":"interval","actorCount":2,"stateHash":"d64925e767769164b71386423f80da42f782a7537b109f58bc689006cf9e84ec"} +{"type":"checkpoint","at":"2026-08-04T02:58:10.656Z","tick":17367,"reason":"interval","actorCount":2,"stateHash":"63a97737337bc8fc734d488092754b1ba7ac9395c85430205196cefc467bed67"} +{"type":"checkpoint","at":"2026-08-04T02:58:15.661Z","tick":17518,"reason":"interval","actorCount":2,"stateHash":"3b000ea7651be88a5fa9927b3fd9a3c0cafb51e83c0f48f03ea047621e97c005"} +{"type":"checkpoint","at":"2026-08-04T02:58:20.668Z","tick":17668,"reason":"interval","actorCount":2,"stateHash":"b56494a18ff017e5bc6322a7263f140d763454e60da3f6c3dc7f030a0c2b9724"} +{"type":"checkpoint","at":"2026-08-04T02:58:25.688Z","tick":17818,"reason":"interval","actorCount":2,"stateHash":"364f721d09cc27af0cbc22ad0d34224acc659353e0c20c48bc1efbd84b1298f7"} +{"type":"checkpoint","at":"2026-08-04T02:58:30.692Z","tick":17968,"reason":"interval","actorCount":2,"stateHash":"68512c5053ff0d493a47aadd843cdb232fb5e6c98d01b37f2f75afac1302f991"} +{"type":"checkpoint","at":"2026-08-04T02:58:35.695Z","tick":18119,"reason":"interval","actorCount":2,"stateHash":"721184bed41544eab6d443cd4c0d572128880d989cb649f6abe649c0480277f0"} +{"type":"checkpoint","at":"2026-08-04T02:58:40.711Z","tick":18269,"reason":"interval","actorCount":2,"stateHash":"6997694dcc759d32d0766a043099092bdeeb5da40fc782671dbef14bd2546cb0"} +{"type":"checkpoint","at":"2026-08-04T02:58:45.719Z","tick":18419,"reason":"interval","actorCount":2,"stateHash":"23882254ef529e9e801aed8ace5fa9a4bd90d74ce6db3f311ca1811bb463a175"} +{"type":"checkpoint","at":"2026-08-04T02:58:50.728Z","tick":18570,"reason":"interval","actorCount":2,"stateHash":"653d71a4c8559164ae841459c2e8087857de5aa05e8ae13796185704393516e9"} +{"type":"checkpoint","at":"2026-08-04T02:58:55.738Z","tick":18720,"reason":"interval","actorCount":2,"stateHash":"ba5edcde19835ad23977dc606cbdee07e306017705f88745c6976356062b1aec"} +{"type":"checkpoint","at":"2026-08-04T02:59:00.752Z","tick":18870,"reason":"interval","actorCount":2,"stateHash":"b91d42039d4de79c707bb46e27d8867b580876a8d0872fd089a4a3abfc375728"} +{"type":"checkpoint","at":"2026-08-04T02:59:05.761Z","tick":19021,"reason":"interval","actorCount":2,"stateHash":"a7366e3b59f19f04790f0da3c04f6a8299564f6a7407ac5c3a8bfc06361607b6"} +{"type":"checkpoint","at":"2026-08-04T02:59:10.775Z","tick":19171,"reason":"interval","actorCount":2,"stateHash":"338bbecfac922b64e625ba8a89f92f0f81c4d1ba1cc15dc9ed1286d347975b0f"} +{"type":"checkpoint","at":"2026-08-04T02:59:15.790Z","tick":19321,"reason":"interval","actorCount":2,"stateHash":"1973575a9a5467c1c8f4060b628cdf70e64713a6813bc071c91e5ba5c3369461"} +{"type":"checkpoint","at":"2026-08-04T02:59:20.796Z","tick":19472,"reason":"interval","actorCount":2,"stateHash":"7c9af5d739584253e4740bb61d73352584255f5464604f49c54d36f55e0ba277"} +{"type":"checkpoint","at":"2026-08-04T02:59:25.809Z","tick":19622,"reason":"interval","actorCount":2,"stateHash":"9069c05b0e070f3bae28352a422aa711fcdc331abef41552bbd7e8c65ec9a224"} +{"type":"checkpoint","at":"2026-08-04T02:59:30.828Z","tick":19773,"reason":"interval","actorCount":2,"stateHash":"d9cd817ca817e3a2f3211305bac54f02834b6a63606c5e71b57477b919b00210"} +{"type":"checkpoint","at":"2026-08-04T02:59:35.847Z","tick":19923,"reason":"interval","actorCount":2,"stateHash":"e064757ab002d275142ac9dbeff92fd21489ee555f00521e2911ebee41c9125c"} +{"type":"checkpoint","at":"2026-08-04T02:59:40.854Z","tick":20073,"reason":"interval","actorCount":2,"stateHash":"79f4a87e741cbd33be75b89fe39355ab4170d806ebb90156b46031901c1187ec"} +{"type":"checkpoint","at":"2026-08-04T02:59:45.861Z","tick":20224,"reason":"interval","actorCount":2,"stateHash":"dee69175dd968e41b592d140621083688692dfc13ef52d43a1ad0f8e8bcffb15"} +{"type":"checkpoint","at":"2026-08-04T02:59:50.862Z","tick":20374,"reason":"interval","actorCount":2,"stateHash":"5cde08bb92253967d0387f71639e63c0ccf9926074623efe2e6404b7ba5148e3"} +{"type":"checkpoint","at":"2026-08-04T02:59:55.880Z","tick":20524,"reason":"interval","actorCount":2,"stateHash":"62c3c91c4d7db6f1c2d87820a5c95a8ff731d5cadee7645d69a4f9c9809c8fdf"} +{"type":"checkpoint","at":"2026-08-04T03:00:00.896Z","tick":20675,"reason":"interval","actorCount":2,"stateHash":"1bb763911c1b8e7d520c25d2c81d95cf9ff126694b8c1bd8a409d01ee0149e00"} +{"type":"checkpoint","at":"2026-08-04T03:00:05.916Z","tick":20825,"reason":"interval","actorCount":2,"stateHash":"4c68213332cf397723770d7259a9fe96b2bb7767a9007bf931a5ac50c7fc1a78"} +{"type":"checkpoint","at":"2026-08-04T03:00:10.925Z","tick":20975,"reason":"interval","actorCount":2,"stateHash":"c58f2e551434f2f41f29acdad07764d9a99b79bfce13b587537b5bbba3dc185c"} +{"type":"checkpoint","at":"2026-08-04T03:00:15.931Z","tick":21126,"reason":"interval","actorCount":2,"stateHash":"80e1c63293b3ffefb6e145a326d227645a9c2effccd8c78fd9df98bb77f7ab7d"} +{"type":"checkpoint","at":"2026-08-04T03:00:20.946Z","tick":21276,"reason":"interval","actorCount":2,"stateHash":"2d11886027c5d79a90d41d43b26fcba6f56487d9e82e5bb0bd40e8d6832c81af"} +{"type":"checkpoint","at":"2026-08-04T03:00:25.950Z","tick":21426,"reason":"interval","actorCount":2,"stateHash":"410726899dc779294a4c8238df43c3fea4fd29b40a9f1e30c672523c081d677e"} +{"type":"checkpoint","at":"2026-08-04T03:00:30.957Z","tick":21576,"reason":"interval","actorCount":2,"stateHash":"168313e3c1b3297d16d9b152d164544e869a5f57613ae2629935320cd79ebaea"} +{"type":"checkpoint","at":"2026-08-04T03:00:35.962Z","tick":21727,"reason":"interval","actorCount":2,"stateHash":"98bb734c7816e828881e48e7a78d268b8a957454b47fd35c002ff454e9d0f85d"} +{"type":"checkpoint","at":"2026-08-04T03:00:40.981Z","tick":21877,"reason":"interval","actorCount":2,"stateHash":"a2218e40e83478afe982118f1247a522da2b0b0bfe667fc9b957e3b1f003a6c3"} +{"type":"checkpoint","at":"2026-08-04T03:00:45.984Z","tick":22027,"reason":"interval","actorCount":2,"stateHash":"8d38b7ee0ef5ed73c3d68ee4b3f059d9c614f770d2b5b1a66e4f890b786e8954"} +{"type":"checkpoint","at":"2026-08-04T03:00:50.997Z","tick":22178,"reason":"interval","actorCount":2,"stateHash":"439bfb30e7d7c51a6e243c75c9abc14826a26b5d4d610592f49f0fbd08777c48"} +{"type":"checkpoint","at":"2026-08-04T03:00:55.999Z","tick":22328,"reason":"interval","actorCount":2,"stateHash":"fc8649ba98414fd9bb7b8f354039e8af53b5c94d7920d6e1e8d2f0fc82330e06"} +{"type":"checkpoint","at":"2026-08-04T03:01:01.019Z","tick":22478,"reason":"interval","actorCount":2,"stateHash":"0be8357b9789dce325c633d187ab67571ec126f0f84e3e8951f7e82b794a3a9f"} +{"type":"checkpoint","at":"2026-08-04T03:01:06.022Z","tick":22628,"reason":"interval","actorCount":2,"stateHash":"d254c3c94c291d446e5c886f79ed80140369079946d403e1a3c2ed03ddca443f"} +{"type":"checkpoint","at":"2026-08-04T03:01:11.030Z","tick":22779,"reason":"interval","actorCount":2,"stateHash":"b1af5cca5acd3dd836b9e6faf4bbf6552158cc3857bb69868d8407f1fde80762"} +{"type":"checkpoint","at":"2026-08-04T03:01:16.040Z","tick":22929,"reason":"interval","actorCount":2,"stateHash":"bd8b5474c9a6cb8c13588a5f98689dcfd12bd5a10853f7f016a54df0e6634fd4"} +{"type":"checkpoint","at":"2026-08-04T03:01:21.063Z","tick":23080,"reason":"interval","actorCount":2,"stateHash":"52cb6bf1977e7e4f0c72dc196b0ae1a2972266a83a864e2424c3f39b1dd1d68b"} +{"type":"checkpoint","at":"2026-08-04T03:01:26.069Z","tick":23230,"reason":"interval","actorCount":2,"stateHash":"f966d95233cd5c4296246580bd5ad8e371dc30ee78f831304f79bd9c832c6b7a"} +{"type":"checkpoint","at":"2026-08-04T03:01:31.080Z","tick":23380,"reason":"interval","actorCount":2,"stateHash":"1102af595d80f3bbf0e01d74b8a1f4b1a7b2dba5e12eac7ef7b06cf2d1baeceb"} +{"type":"checkpoint","at":"2026-08-04T03:01:36.081Z","tick":23530,"reason":"interval","actorCount":2,"stateHash":"1501a0eba75d3a1fd0c9a876db41fbd88a083a0f01d36ee555367fef0e4cbd36"} +{"type":"checkpoint","at":"2026-08-04T03:01:41.089Z","tick":23680,"reason":"interval","actorCount":2,"stateHash":"93d642cc043d7eee8cd8e411b202acd955a820d3976f5f37189979c4a5cd1f14"} +{"type":"checkpoint","at":"2026-08-04T03:01:46.096Z","tick":23831,"reason":"interval","actorCount":2,"stateHash":"23d05e7b640e3dded017348ed453e7003bf233e96406a76b3b87fe95a980f6f4"} +{"type":"checkpoint","at":"2026-08-04T03:01:51.116Z","tick":23981,"reason":"interval","actorCount":2,"stateHash":"a3c8293b6fd97cbb3089e8e3a06b1e12e03316c1e372333902d893485a0af65d"} +{"type":"checkpoint","at":"2026-08-04T03:01:56.130Z","tick":24132,"reason":"interval","actorCount":2,"stateHash":"10061e5b403d1f52b95a6bd4e699cb15d0e7148a613f40041e6671d6306d2ca8"} +{"type":"checkpoint","at":"2026-08-04T03:02:01.133Z","tick":24282,"reason":"interval","actorCount":2,"stateHash":"b8a7cd1a65c90f32a90c26c6fa2dc6d79e845462f466fc3f7ecf2aac04c69a47"} +{"type":"checkpoint","at":"2026-08-04T03:02:06.135Z","tick":24432,"reason":"interval","actorCount":2,"stateHash":"9c1fadbe5ee104c94437344e7d56c5ce0ac350318779a7d5d6df754f56931bf5"} +{"type":"checkpoint","at":"2026-08-04T03:02:11.144Z","tick":24582,"reason":"interval","actorCount":2,"stateHash":"17cc4d27470382c4f0bad4a47b26762a5142fac4b2a2f7231b127e28980e687e"} +{"type":"checkpoint","at":"2026-08-04T03:02:16.146Z","tick":24732,"reason":"interval","actorCount":2,"stateHash":"a40fd921ecc541129f9d6e4743fd19cc23ecb169078a9465509ad4059f316419"} +{"type":"checkpoint","at":"2026-08-04T03:02:21.164Z","tick":24883,"reason":"interval","actorCount":2,"stateHash":"2f0aaa1eda8211f8e593bf1532b2487e3f24faab8375c3815a69bdf7bc44027e"} +{"type":"checkpoint","at":"2026-08-04T03:02:26.169Z","tick":25033,"reason":"interval","actorCount":2,"stateHash":"a716597882b5643affc926d803836840c236d6035d07623c6107f49873ce9041"} +{"type":"checkpoint","at":"2026-08-04T03:02:31.180Z","tick":25183,"reason":"interval","actorCount":2,"stateHash":"5668be39376b57d5803cd37d35ea12ff2115120cf5a4556e6bb95fcbc8d87602"} +{"type":"session.disconnect","at":"2026-08-04T03:02:33.807Z","tick":25262,"sessionId":"g_1","actorId":"play-1","removedTransientActor":true} +{"type":"session.connect","at":"2026-08-04T03:02:36.040Z","tick":25262,"sessionId":"g_2","actorId":"play-1","playerId":"play-1","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T03:02:36.183Z","tick":25266,"reason":"interval","actorCount":2,"stateHash":"1d85aad3856e262cd96a091ef58e42204f281ec9fb670f8324bd844fa868a7d2"} +{"type":"checkpoint","at":"2026-08-04T03:02:41.197Z","tick":25417,"reason":"interval","actorCount":2,"stateHash":"09dfa081a44b3fdfb638c4e74f106bdac4989dcb753c9201072d0b39debd3fa0"} +{"type":"checkpoint","at":"2026-08-04T03:02:46.217Z","tick":25567,"reason":"interval","actorCount":2,"stateHash":"f02aec23ce16809fafb09b0614d4dd131a93d84ae4dae6654cabe3213c5bbd7e"} +{"type":"checkpoint","at":"2026-08-04T03:02:51.219Z","tick":25717,"reason":"interval","actorCount":2,"stateHash":"a85dab568fcd7023e982d70c52262d4c58a6ef43c757116bead98d732b140e83"} +{"type":"checkpoint","at":"2026-08-04T03:02:56.223Z","tick":25867,"reason":"interval","actorCount":2,"stateHash":"4a3e73de8de594ea048d38dbd408e5dc9b06c23e12c3a9109060f6271d0c64f9"} +{"type":"checkpoint","at":"2026-08-04T03:03:01.228Z","tick":26018,"reason":"interval","actorCount":2,"stateHash":"f3c6f01b318af42476e7ea18dbb1f669d546ccc76c606b5218152b4679913167"} +{"type":"checkpoint","at":"2026-08-04T03:03:06.249Z","tick":26168,"reason":"interval","actorCount":2,"stateHash":"de429dcc02c831580e468497d35c3f258f516cbf30aec1d403301800d4edb719"} +{"type":"checkpoint","at":"2026-08-04T03:03:11.263Z","tick":26319,"reason":"interval","actorCount":2,"stateHash":"399e74b496d0fd28da6065acf8170f28a50d6bb626040b7327f319c8aebd0964"} +{"type":"checkpoint","at":"2026-08-04T03:03:16.280Z","tick":26469,"reason":"interval","actorCount":2,"stateHash":"461f15b4c28f10e661bc32c5c79486eeab2802df6730dd933913df36a7b9b209"} +{"type":"checkpoint","at":"2026-08-04T03:03:21.293Z","tick":26619,"reason":"interval","actorCount":2,"stateHash":"7d717080bcd7edfdfdcca40c8d139695d9e7e1ced96899e92a5df5e7625409e4"} +{"type":"checkpoint","at":"2026-08-04T03:03:26.296Z","tick":26770,"reason":"interval","actorCount":2,"stateHash":"90e4451dc2356f9625728e20498800ae146adbd61d07fbdad1723bc016956ab5"} +{"type":"checkpoint","at":"2026-08-04T03:03:31.298Z","tick":26920,"reason":"interval","actorCount":2,"stateHash":"075307342cdaf974e523b63d3f3e598c39594372014f8ddcff6c4176e69b453b"} +{"type":"checkpoint","at":"2026-08-04T03:03:36.303Z","tick":27070,"reason":"interval","actorCount":2,"stateHash":"7e978fafe00d6557a0ccb3193de8e5de6e63db82b55826b46629b9ff39028dcf"} +{"type":"checkpoint","at":"2026-08-04T03:03:41.323Z","tick":27220,"reason":"interval","actorCount":2,"stateHash":"db5074f806c2af70557edb6d8d598510ea4b03195b57c4aa9bc78466a7fca14f"} +{"type":"checkpoint","at":"2026-08-04T03:03:46.329Z","tick":27371,"reason":"interval","actorCount":2,"stateHash":"8b02b1557c1bdb7ec9bbcc6a45fd72445767376442ee3ffac425ac4db268388a"} +{"type":"checkpoint","at":"2026-08-04T03:03:51.349Z","tick":27521,"reason":"interval","actorCount":2,"stateHash":"4d62b5fda529f2d0c0aa67d42cd4519f00cbae7d57be43a8dc914cb0b3ca8902"} +{"type":"session.disconnect","at":"2026-08-04T03:03:56.221Z","tick":27667,"sessionId":"g_2","actorId":"play-1","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T03:03:56.355Z","tick":27667,"reason":"interval","actorCount":2,"stateHash":"95d546ad06265a768ea37b4af4144123aa1f92b4380f0e0b07c1e5dfa5777c34"} +{"type":"session.connect","at":"2026-08-04T03:03:58.092Z","tick":27667,"sessionId":"g_3","actorId":"play-1","playerId":"play-1","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T03:04:01.363Z","tick":27766,"reason":"interval","actorCount":2,"stateHash":"cfb4febc2d2cc3d2aeab8be632eb2e16def1ea63ba751ace117ac417676eccd7"} +{"type":"checkpoint","at":"2026-08-04T03:04:06.378Z","tick":27916,"reason":"interval","actorCount":2,"stateHash":"07d3f4f1aeac9cfd5a2630a914c59811a44065eee33aaac8d1fa3642e7634d31"} +{"type":"checkpoint","at":"2026-08-04T03:04:11.390Z","tick":28066,"reason":"interval","actorCount":2,"stateHash":"39b41582972d8609246f4a401032ea13517543cebda01ab4fc536084223ea4ec"} +{"type":"checkpoint","at":"2026-08-04T03:04:16.397Z","tick":28217,"reason":"interval","actorCount":2,"stateHash":"98ddac95068485e0b6d7dadc396f0a9a37a92621fce8f547ce41739acf71bf21"} +{"type":"checkpoint","at":"2026-08-04T03:04:21.399Z","tick":28367,"reason":"interval","actorCount":2,"stateHash":"b52ccce8e03fab33c690b45be50e0cecffcb0ec7ca469dff1674fe602c619ed7"} +{"type":"checkpoint","at":"2026-08-04T03:04:26.413Z","tick":28517,"reason":"interval","actorCount":2,"stateHash":"ae1f54de6fda60090b456593bc823c43003fca14e8cc706fd5ccda9a12bc34bd"} +{"type":"checkpoint","at":"2026-08-04T03:04:31.420Z","tick":28667,"reason":"interval","actorCount":2,"stateHash":"a04f5781a3cb1af167e029d8364ac77bd14fbb1443c3cb17525147365f7bfcd3"} +{"type":"checkpoint","at":"2026-08-04T03:04:36.424Z","tick":28817,"reason":"interval","actorCount":2,"stateHash":"fb78d26c52dd2ee4d16536c83640e5d7d0c3ade226af0481abd2c3746ac27949"} +{"type":"checkpoint","at":"2026-08-04T03:04:41.431Z","tick":28968,"reason":"interval","actorCount":2,"stateHash":"3df0027ecbc06781d52654d76bbf5aec0b45f6a32931e0d6cc0bcd7e7d3e0c3c"} +{"type":"checkpoint","at":"2026-08-04T03:04:46.449Z","tick":29118,"reason":"interval","actorCount":2,"stateHash":"a616dfe22315f404cac5749dbc701b6b3bd32bb8b2319a1991552362a0c40776"} +{"type":"checkpoint","at":"2026-08-04T03:04:51.457Z","tick":29268,"reason":"interval","actorCount":2,"stateHash":"81c9e343fbb3b4f973f97c8d397aa37d04cf766be3989ec2be0ddc6e6e195c9a"} +{"type":"checkpoint","at":"2026-08-04T03:04:56.465Z","tick":29419,"reason":"interval","actorCount":2,"stateHash":"f5d848189e02c762e69ea392bc981892c394d93d5f400d74e3fb61be49349b17"} +{"type":"checkpoint","at":"2026-08-04T03:05:01.483Z","tick":29569,"reason":"interval","actorCount":2,"stateHash":"8bb51574ca5b63245afa89124005cc1fa15bd8036729f71355a11319c5461967"} +{"type":"checkpoint","at":"2026-08-04T03:05:06.498Z","tick":29720,"reason":"interval","actorCount":2,"stateHash":"463dcfc806a2eb5b14095c51f8d884cc4485eae4ea28285d749c65ca9a4f5c4e"} +{"type":"checkpoint","at":"2026-08-04T03:05:11.501Z","tick":29870,"reason":"interval","actorCount":2,"stateHash":"e01acd147a6b7d54c63f053eda916a95cbde34227c5c9be72bd19bacac687ed8"} +{"type":"checkpoint","at":"2026-08-04T03:05:16.505Z","tick":30020,"reason":"interval","actorCount":2,"stateHash":"bfa5365ea1131fa07a550e45237801bd5b2faf95a33fbdf3389d2985cd0e0aa5"} +{"type":"checkpoint","at":"2026-08-04T03:05:21.517Z","tick":30170,"reason":"interval","actorCount":2,"stateHash":"0bce7883a884ae8e126564b45779d69ba7f81f87f516784e59488bc4c5b4e237"} +{"type":"checkpoint","at":"2026-08-04T03:05:26.524Z","tick":30320,"reason":"interval","actorCount":2,"stateHash":"9da378650bf1757cc1c6ee79d1af771a959542fa320c59e2c9c46a92ff89ff40"} +{"type":"checkpoint","at":"2026-08-04T03:05:31.531Z","tick":30471,"reason":"interval","actorCount":2,"stateHash":"fad2f50c6753207f81d87e25685b589847d7ce6723d4efae1e1db42a857b75a1"} +{"type":"checkpoint","at":"2026-08-04T03:05:36.542Z","tick":30621,"reason":"interval","actorCount":2,"stateHash":"b51af8373475384b76e166891dbf0432c07bdfd7a1cdd220739125faa46acdaf"} +{"type":"checkpoint","at":"2026-08-04T03:05:41.543Z","tick":30771,"reason":"interval","actorCount":2,"stateHash":"77581d983b537e51ac94b29d5687f281ce7bdf1cd32170c4daa198e586af2ae6"} +{"type":"checkpoint","at":"2026-08-04T03:05:46.549Z","tick":30921,"reason":"interval","actorCount":2,"stateHash":"7029d52dec961c104a4bf568ff300a396a087bfffcac3500c41b724ff3f78b51"} +{"type":"checkpoint","at":"2026-08-04T03:05:51.558Z","tick":31071,"reason":"interval","actorCount":2,"stateHash":"8a90fb566a79a3778686c64c3fce2305569d1d0703f6575d735898b0c5a99861"} +{"type":"checkpoint","at":"2026-08-04T03:05:56.561Z","tick":31221,"reason":"interval","actorCount":2,"stateHash":"07b4dc302b826ce319920a60a9cbacd312f3b3cd6cc63037299e60eb1943468c"} +{"type":"checkpoint","at":"2026-08-04T03:06:01.564Z","tick":31372,"reason":"interval","actorCount":2,"stateHash":"b57dba1306bc389e329c777e2940b24a9dd32ecc648b9f98440d5cdcc48b5d30"} +{"type":"checkpoint","at":"2026-08-04T03:06:06.577Z","tick":31522,"reason":"interval","actorCount":2,"stateHash":"de22b8dda5d552422f3822ba8e8faa6c2461fccee86330dd5474de935aaed141"} +{"type":"checkpoint","at":"2026-08-04T03:06:11.596Z","tick":31672,"reason":"interval","actorCount":2,"stateHash":"8db96d7de12efdd0cd7ebd179a898cdc758061753b3231a4b565b34bc6f5607a"} +{"type":"checkpoint","at":"2026-08-04T03:06:16.599Z","tick":31823,"reason":"interval","actorCount":2,"stateHash":"a3d5c67a5923d8e8017b7e5177ab62cc7a4e7715927cce326a36fbf08a9239bf"} +{"type":"checkpoint","at":"2026-08-04T03:06:21.614Z","tick":31973,"reason":"interval","actorCount":2,"stateHash":"e37295a49ef69de14906d032d25cf7c87d08326196d57520f204eeeef226a0e5"} +{"type":"checkpoint","at":"2026-08-04T03:06:26.624Z","tick":32123,"reason":"interval","actorCount":2,"stateHash":"c56bc3f761ca19e414bc0b7d0effb748bbf9b34f5f23158ee05b676858a8264d"} +{"type":"checkpoint","at":"2026-08-04T03:06:31.631Z","tick":32274,"reason":"interval","actorCount":2,"stateHash":"8998e4d4faf916a72876e25702c6c45913cc1aca6ad7367b8b65b4111bf39351"} +{"type":"checkpoint","at":"2026-08-04T03:06:36.654Z","tick":32424,"reason":"interval","actorCount":2,"stateHash":"b01850592c48aaac10f0628f8eb390d18e5b0ed3e4f01ef3a8ca7f51029eda7d"} +{"type":"checkpoint","at":"2026-08-04T03:06:41.666Z","tick":32575,"reason":"interval","actorCount":2,"stateHash":"df60aaae9ee90e4d0923c64f4b6223917690321e8ade3197fd86be6ac095398a"} +{"type":"checkpoint","at":"2026-08-04T03:06:46.668Z","tick":32725,"reason":"interval","actorCount":2,"stateHash":"1d11701db1ab24db31cd6c87fe9122022416b720a02cae2d66e23d2ffb09424f"} +{"type":"checkpoint","at":"2026-08-04T03:06:51.688Z","tick":32875,"reason":"interval","actorCount":2,"stateHash":"9195c2556ce9ded0d52fc9490e9aa31c5e515e120ef1e6a5217e82d6bbbff37e"} +{"type":"checkpoint","at":"2026-08-04T03:06:56.698Z","tick":33026,"reason":"interval","actorCount":2,"stateHash":"e0a64af406dfbc41d2908af6b5b7de1cde619bb3ae34b7f6a3ae9c583c869bd2"} +{"type":"checkpoint","at":"2026-08-04T03:07:01.718Z","tick":33176,"reason":"interval","actorCount":2,"stateHash":"0c8a34609f4f894d931575d107226af13b9350cf88facdad2db9eff123187e0b"} +{"type":"checkpoint","at":"2026-08-04T03:07:06.725Z","tick":33326,"reason":"interval","actorCount":2,"stateHash":"af5efe13dad34c35816bf7cdcd7038021b639f098b7ac7c73bf64203f9826155"} +{"type":"checkpoint","at":"2026-08-04T03:07:11.729Z","tick":33477,"reason":"interval","actorCount":2,"stateHash":"b407916e6bfb177e3a395429b70b4c1e398bea4af4a027ae5be5e5d59cca8682"} +{"type":"checkpoint","at":"2026-08-04T03:07:16.735Z","tick":33627,"reason":"interval","actorCount":2,"stateHash":"abf1364bcf9814b732be148447fdf9d9b7e3e87c69412f5626cbac4eaeaf1c36"} +{"type":"checkpoint","at":"2026-08-04T03:07:21.756Z","tick":33777,"reason":"interval","actorCount":2,"stateHash":"1f5bcd0ab67f15076f8167aea94915f37613eef1bfe58c5dda4f220ad0b11bb9"} +{"type":"checkpoint","at":"2026-08-04T03:07:26.763Z","tick":33928,"reason":"interval","actorCount":2,"stateHash":"37958e47d380964d9031ced408b2cc6747ec47d117eb7f312cf730d54b1322d6"} +{"type":"checkpoint","at":"2026-08-04T03:07:31.768Z","tick":34078,"reason":"interval","actorCount":2,"stateHash":"17585b57d340ed08f31adeb8f7fa74e7c400f5397fb84106f5d97ecf25189406"} +{"type":"checkpoint","at":"2026-08-04T03:07:36.781Z","tick":34228,"reason":"interval","actorCount":2,"stateHash":"5e5cc783cc1e5d24bc3a819a5b46e6397876b40bdd0285c52b41e1da5fa69a37"} +{"type":"checkpoint","at":"2026-08-04T03:07:41.795Z","tick":34379,"reason":"interval","actorCount":2,"stateHash":"819e526b9f1282b757f435702a6d9788dc870fb1a261cd2654ff65e38473d709"} +{"type":"checkpoint","at":"2026-08-04T03:07:46.811Z","tick":34529,"reason":"interval","actorCount":2,"stateHash":"81218156e8817b2df327d92c221a7837a6b77984779ba6479097cf9c1a083af4"} +{"type":"checkpoint","at":"2026-08-04T03:07:51.815Z","tick":34679,"reason":"interval","actorCount":2,"stateHash":"f7101271a6d52b48dffc7cfe46d3f24b8dc566ec98147314374837026470ec00"} +{"type":"checkpoint","at":"2026-08-04T03:07:56.819Z","tick":34829,"reason":"interval","actorCount":2,"stateHash":"b6e08efedcf479ab658a3b2f93ae2f83e7b2da7bca41d971f499d23d64c55cc1"} +{"type":"checkpoint","at":"2026-08-04T03:08:01.824Z","tick":34979,"reason":"interval","actorCount":2,"stateHash":"773ffbb9340840ff1076a3aba02776fe82579a6c5ef8f3b1b606049c13352415"} +{"type":"checkpoint","at":"2026-08-04T03:08:06.827Z","tick":35130,"reason":"interval","actorCount":2,"stateHash":"516014c6df94bbc9c2c1a71cd90c01779257d7d04c392d651ff57261b493f1de"} +{"type":"checkpoint","at":"2026-08-04T03:08:11.839Z","tick":35280,"reason":"interval","actorCount":2,"stateHash":"55bd9095ec5ea355e46198531bb9f0c8b3f4b786fa6bad772ca04f9f01b6ca06"} +{"type":"checkpoint","at":"2026-08-04T03:08:16.845Z","tick":35430,"reason":"interval","actorCount":2,"stateHash":"d34c286913c976c7a281ebbf159af419a333660fa6e6387ea33100d31fe54cac"} +{"type":"checkpoint","at":"2026-08-04T03:08:21.851Z","tick":35580,"reason":"interval","actorCount":2,"stateHash":"5f7def22402b21beb5db480fb6a425e2ddbc3227ea361292b16e311b9c328d0d"} +{"type":"checkpoint","at":"2026-08-04T03:08:26.852Z","tick":35730,"reason":"interval","actorCount":2,"stateHash":"e62e2e8a1fe2aaa07cc02322856f33e235edf14dd8d32b61c48ad4bcbf63692d"} +{"type":"checkpoint","at":"2026-08-04T03:08:31.855Z","tick":35880,"reason":"interval","actorCount":2,"stateHash":"b98c4f8a48f48ec432e4a514b6fc57148e3e3a30a6cbb309790e601e2e9308dc"} +{"type":"checkpoint","at":"2026-08-04T03:08:36.859Z","tick":36030,"reason":"interval","actorCount":2,"stateHash":"370187d82c7599d5d1d6ec595a6deee36011250eb95500062f676b388af29596"} +{"type":"checkpoint","at":"2026-08-04T03:08:41.861Z","tick":36181,"reason":"interval","actorCount":2,"stateHash":"b11b5bc9737bd3db714c27585495d8dd394a91fb6254419eeebd1252ad9a8a42"} +{"type":"checkpoint","at":"2026-08-04T03:08:46.878Z","tick":36331,"reason":"interval","actorCount":2,"stateHash":"c5d35ef4384888c84a7c2c7b27ceaa81796f352bc0f972bc3f040f8c02ba5370"} +{"type":"checkpoint","at":"2026-08-04T03:08:51.890Z","tick":36481,"reason":"interval","actorCount":2,"stateHash":"dd0ce0e15407f39b992b5b8281e53867bce41ac7530786a6238eb62f0bba4c6b"} +{"type":"checkpoint","at":"2026-08-04T03:08:56.894Z","tick":36632,"reason":"interval","actorCount":2,"stateHash":"763f936f167d67b4a30e0f5a452b3ad7f8084b338566a6e0963b94dfc7abcc99"} +{"type":"checkpoint","at":"2026-08-04T03:09:01.914Z","tick":36782,"reason":"interval","actorCount":2,"stateHash":"a14409a77a27e036bbd92de7fd5d3556a54b60aaaf179833e947a178cb08fb43"} +{"type":"checkpoint","at":"2026-08-04T03:09:06.918Z","tick":36932,"reason":"interval","actorCount":2,"stateHash":"7be39deed592d253970da458fae24318589c357656757dd7082b91c70e0e350a"} +{"type":"checkpoint","at":"2026-08-04T03:09:11.929Z","tick":37083,"reason":"interval","actorCount":2,"stateHash":"f0b7b2a6a945da5b82288b87544b8755978e0132ebf3992821d88f7985a3474a"} +{"type":"checkpoint","at":"2026-08-04T03:09:16.939Z","tick":37233,"reason":"interval","actorCount":2,"stateHash":"d671b3b53573ffe9771c876e65cae42b86712bfb91822d42762185cd70c541cc"} +{"type":"checkpoint","at":"2026-08-04T03:09:21.958Z","tick":37383,"reason":"interval","actorCount":2,"stateHash":"c4068d14d0239de56d89d4cb0ff00f176b9b11134b5c559c90b3a6cefac67cf3"} +{"type":"checkpoint","at":"2026-08-04T03:09:26.960Z","tick":37533,"reason":"interval","actorCount":2,"stateHash":"db8bc833053fc985ee3b34f29e4f57237727db5529b0221406053f86a5788773"} +{"type":"checkpoint","at":"2026-08-04T03:09:31.979Z","tick":37684,"reason":"interval","actorCount":2,"stateHash":"d3cdaf80276ccd71556269353a964dd461c7f79bdc9ae5534795474dcd90394a"} +{"type":"command.receipt","at":"2026-08-04T03:09:32.812Z","tick":37709,"actorId":"play-1","commandId":1640000,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1640,"player":2152542497,"command_id":1640000,"issued_at_tick":37709,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":3,"player":1,"stateHash":"d99360c682cc59893f7c3938dc014914e8ea6f50ebb1d3d70581a4ee011761b2"}} +{"type":"command.receipt","at":"2026-08-04T03:09:32.861Z","tick":37711,"actorId":"play-1","commandId":1640001,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"play-1","rustActorId":"player","envelope":{"session":1640,"player":2152542497,"command_id":1640001,"issued_at_tick":37710,"command":{"SetMoveIntent":{"dx":0,"dy":0,"sprint":false}}},"session":3,"player":1,"stateHash":"9531364668a3657b68f3812c09842fad4af7a09fea478d3f8d67d8521de24f07"}} +{"type":"checkpoint","at":"2026-08-04T03:09:36.995Z","tick":37835,"reason":"interval","actorCount":2,"stateHash":"5221b32ad0eefbcbf458097957d7c4c796b65a51599e077531de05b401c9c204"} +{"type":"checkpoint","at":"2026-08-04T03:09:42.015Z","tick":37985,"reason":"interval","actorCount":2,"stateHash":"3de8914fedd2a82bff8864f24737c276dd6d28a93ad012c02aa98b96467b5684"} +{"type":"checkpoint","at":"2026-08-04T03:09:47.029Z","tick":38136,"reason":"interval","actorCount":2,"stateHash":"6999e44ef91a3a04d6aabda82f4db3652ecf706b725af9631b0fd788525592a6"} +{"type":"checkpoint","at":"2026-08-04T03:09:52.049Z","tick":38286,"reason":"interval","actorCount":2,"stateHash":"66fc6049450b4f46cbe94189699cbda61bcd5fa9541c62d2a1c73f1977e0f07a"} +{"type":"checkpoint","at":"2026-08-04T03:09:57.062Z","tick":38437,"reason":"interval","actorCount":2,"stateHash":"a6ef217b8d7147f95781c17bfe8a6fbedb62c86aa17fb5556387c00686913ef7"} +{"type":"checkpoint","at":"2026-08-04T03:10:02.068Z","tick":38587,"reason":"interval","actorCount":2,"stateHash":"70a5a309f8b6bf5f24fd9fd0d7284bad084e86461a32f701039c8e07e4b067b1"} +{"type":"checkpoint","at":"2026-08-04T03:10:07.088Z","tick":38737,"reason":"interval","actorCount":2,"stateHash":"afa03bb815a069f7732a71f499c0ae9fa67c8918f732995ae6c4d712d8dda5d5"} +{"type":"checkpoint","at":"2026-08-04T03:10:12.096Z","tick":38888,"reason":"interval","actorCount":2,"stateHash":"d046787fab138f3b8adce076447c628f820deab7a7a7c87dcd7dd5ee87fe902a"} +{"type":"checkpoint","at":"2026-08-04T03:10:17.103Z","tick":39038,"reason":"interval","actorCount":2,"stateHash":"59a3f4991a010c126dff98b0fa2800dbe7573002cdb68d0f3a2cb7836e99b750"} +{"type":"checkpoint","at":"2026-08-04T03:10:22.121Z","tick":39188,"reason":"interval","actorCount":2,"stateHash":"8bb2f6d0b6f10208cdd17695bf9d63b2ca2b4c9c36b0bd943fd20af29fc6b830"} +{"type":"checkpoint","at":"2026-08-04T03:10:27.129Z","tick":39339,"reason":"interval","actorCount":2,"stateHash":"51e98fe181f859cdb8ea9dbaeaabd122ac33bca651b4d2f74c82f971cf586569"} +{"type":"checkpoint","at":"2026-08-04T03:10:32.140Z","tick":39489,"reason":"interval","actorCount":2,"stateHash":"e85b3e5d69ea795d7a5f777c4bdc4a3e73db9a53e4bf1302a841d62707d8d9a9"} +{"type":"checkpoint","at":"2026-08-04T03:10:37.145Z","tick":39639,"reason":"interval","actorCount":2,"stateHash":"a408cc8dff310171bd03542dac663b5c25f74a70bab915f62cf3f06404f974da"} +{"type":"checkpoint","at":"2026-08-04T03:10:42.159Z","tick":39789,"reason":"interval","actorCount":2,"stateHash":"53a7e3247fbc2f93ed453c08553064afeb724c5166aaf0d3117e354a0e2d242a"} +{"type":"checkpoint","at":"2026-08-04T03:10:47.164Z","tick":39940,"reason":"interval","actorCount":2,"stateHash":"bdb85e2074cdb200819b4a13ac2a4c6404dd2fd22018cbeaf16cfbd6d2769fed"} +{"type":"checkpoint","at":"2026-08-04T03:10:52.173Z","tick":40090,"reason":"interval","actorCount":2,"stateHash":"c19d82d607d56ef88cb6f389ae9cfa4d4790e20a0364c2a6e6266f186d0c184f"} +{"type":"checkpoint","at":"2026-08-04T03:10:57.180Z","tick":40240,"reason":"interval","actorCount":2,"stateHash":"12a8a8658e7d26afcada15f3ac1fc2501e858457772e16b4db32538672627c1f"} +{"type":"checkpoint","at":"2026-08-04T03:11:02.197Z","tick":40391,"reason":"interval","actorCount":2,"stateHash":"b8b8808abb28b277bb6b64134cb8f3889b730871f2b6cc5d28ec61af5f5cfe67"} +{"type":"checkpoint","at":"2026-08-04T03:11:07.217Z","tick":40541,"reason":"interval","actorCount":2,"stateHash":"4dc5085accbc3760ffbc521cc6a14e3676aa59d296b627e86afd9fd28dd7ee7b"} +{"type":"checkpoint","at":"2026-08-04T03:11:12.219Z","tick":40691,"reason":"interval","actorCount":2,"stateHash":"b3cbb9064aba003a71c0513a6dc64e96895e5997220e18076ea1c06a44f79858"} +{"type":"checkpoint","at":"2026-08-04T03:11:17.229Z","tick":40841,"reason":"interval","actorCount":2,"stateHash":"c58f5357ae8f56b45f904c9fee4d7164bfeeb0819ab9afe230f4908d8bc50059"} +{"type":"checkpoint","at":"2026-08-04T03:11:22.238Z","tick":40992,"reason":"interval","actorCount":2,"stateHash":"b9213c6fad4f16d90b048bd69b08805d8272fc3b0d1db942612dff9da242141d"} +{"type":"checkpoint","at":"2026-08-04T03:11:27.243Z","tick":41142,"reason":"interval","actorCount":2,"stateHash":"bec4cfd851a4c61086491ef0c75fde79a0468d07ff0db2665c4726d37b242968"} +{"type":"checkpoint","at":"2026-08-04T03:11:32.244Z","tick":41292,"reason":"interval","actorCount":2,"stateHash":"89f48ccfe154908c47df8b1cd85e0e94e228298ddda3f3bb27d0ed0fac4835d7"} +{"type":"checkpoint","at":"2026-08-04T03:11:37.248Z","tick":41442,"reason":"interval","actorCount":2,"stateHash":"c1625214050ec08201933fd853c29cdf9ff99cad827991094201c22350b53e2c"} +{"type":"checkpoint","at":"2026-08-04T03:11:42.258Z","tick":41592,"reason":"interval","actorCount":2,"stateHash":"ebe01fbb89b0921910e0e2912abb4b859a0f4f352f9da4212d4da33667c1e0a8"} +{"type":"checkpoint","at":"2026-08-04T03:11:47.266Z","tick":41743,"reason":"interval","actorCount":2,"stateHash":"f0972b730ddcccb45129cda0d73262dd7dac34829b64908f9a599868f2ab1129"} +{"type":"checkpoint","at":"2026-08-04T03:11:52.286Z","tick":41893,"reason":"interval","actorCount":2,"stateHash":"98d0c6407a0b743aaa3074f8aef54a8965802478e0e41d52ef6f4b29f1bd947a"} +{"type":"checkpoint","at":"2026-08-04T03:11:57.294Z","tick":42043,"reason":"interval","actorCount":2,"stateHash":"89e0ddc05df72a0c549ebafb2a91c1762b2c32c930e14ce96007b6e12947c938"} +{"type":"checkpoint","at":"2026-08-04T03:12:02.296Z","tick":42193,"reason":"interval","actorCount":2,"stateHash":"ac29a1ad480d07293d7ede161fbcb0b3aa53761390090b4a533c49df6fd267cf"} +{"type":"checkpoint","at":"2026-08-04T03:12:07.313Z","tick":42344,"reason":"interval","actorCount":2,"stateHash":"96a767553e7d6c87bc1062bf4b6f40a688f90e960ad7a55f2abb9aab82104aa9"} +{"type":"checkpoint","at":"2026-08-04T03:12:12.331Z","tick":42495,"reason":"interval","actorCount":2,"stateHash":"52c84905809161936818f2467c196df4bc8e94ae834ebdd49f55e34d4c210789"} +{"type":"checkpoint","at":"2026-08-04T03:12:17.341Z","tick":42645,"reason":"interval","actorCount":2,"stateHash":"75a8381b797c4fab424a816c47a7a265b1c9e408f6143015bac127fd258ec08e"} +{"type":"checkpoint","at":"2026-08-04T03:12:22.352Z","tick":42795,"reason":"interval","actorCount":2,"stateHash":"ddf22eb4e9563fb64cbb6c72c08335ae9cee6a56bd67c3e1a89974a79e10e2d7"} +{"type":"checkpoint","at":"2026-08-04T03:12:27.364Z","tick":42946,"reason":"interval","actorCount":2,"stateHash":"e545a4eb2d544780aa068cbe69c3cf52fb12c6ef218247acf51a230bd6f55924"} +{"type":"checkpoint","at":"2026-08-04T03:12:32.380Z","tick":43096,"reason":"interval","actorCount":2,"stateHash":"7d02abff2f43d1782f3a77732d33a296fc12acf87a8376a7692db52d1d245e7f"} +{"type":"checkpoint","at":"2026-08-04T03:12:37.398Z","tick":43247,"reason":"interval","actorCount":2,"stateHash":"9ac869841f69388d7153b1a12313001c269f43838733ef39e62bf88674ee1cd2"} +{"type":"checkpoint","at":"2026-08-04T03:12:42.407Z","tick":43397,"reason":"interval","actorCount":2,"stateHash":"78d490b64bfe58f2d3bfdef44843d0ead46643f142bcc9388c010b3484d7c60f"} +{"type":"checkpoint","at":"2026-08-04T03:12:47.422Z","tick":43547,"reason":"interval","actorCount":2,"stateHash":"aea656caf5e1f35ec8295b54e7dbaffb8008549df67335db61d5bfc77703f104"} +{"type":"checkpoint","at":"2026-08-04T03:12:52.431Z","tick":43698,"reason":"interval","actorCount":2,"stateHash":"4961af239c25c71f6db0ceb341e8f5f6480200b71a49c07e8ab1c4c850e8e004"} +{"type":"checkpoint","at":"2026-08-04T03:12:57.448Z","tick":43848,"reason":"interval","actorCount":2,"stateHash":"4908afdf32f5531d2d9da1bbe7a88f9e57db03c4aea57b2a0b5be87a0f316904"} +{"type":"checkpoint","at":"2026-08-04T03:13:02.455Z","tick":43998,"reason":"interval","actorCount":2,"stateHash":"7616745c9f6863309cae479c7f17311fa0934b03760ba9663bc782da9496bd97"} +{"type":"checkpoint","at":"2026-08-04T03:13:07.457Z","tick":44148,"reason":"interval","actorCount":2,"stateHash":"b4b4b10f390f9a3d9f40dd60fd4fb7c972aad5a2a8e6b350d8f262625111fee0"} +{"type":"checkpoint","at":"2026-08-04T03:13:12.463Z","tick":44299,"reason":"interval","actorCount":2,"stateHash":"82dd0113e4350b8884282fce87f35ee7e029c137e2cb254e373f2dbc10108da2"} +{"type":"checkpoint","at":"2026-08-04T03:13:17.485Z","tick":44449,"reason":"interval","actorCount":2,"stateHash":"429e9f2584e70b8b6aac47c02649fdf67e98466df8f00bf1974f93789644ef1d"} +{"type":"checkpoint","at":"2026-08-04T03:13:22.497Z","tick":44600,"reason":"interval","actorCount":2,"stateHash":"fa1425c6206fe925ea30f51658c6b0a703c4609a4152286b8ceadf8fab4619c9"} +{"type":"checkpoint","at":"2026-08-04T03:13:27.513Z","tick":44750,"reason":"interval","actorCount":2,"stateHash":"6e57fcaf7f95cd4809c203f36ac86f928b422df31f5878de4a196eb4651c72b9"} +{"type":"checkpoint","at":"2026-08-04T03:13:32.525Z","tick":44900,"reason":"interval","actorCount":2,"stateHash":"6e2f4c9257b816c33dad0a9413f7ea49175210a3a7ea6e85b4c2e1feb630f14b"} +{"type":"checkpoint","at":"2026-08-04T03:13:37.530Z","tick":45051,"reason":"interval","actorCount":2,"stateHash":"33e7fbee7509985c48537a9a320ef59d4cd09dbb1f3921933bdc62337134dacf"} +{"type":"checkpoint","at":"2026-08-04T03:13:42.548Z","tick":45201,"reason":"interval","actorCount":2,"stateHash":"d7136db7938c90802899d30d4f851d679ebb6bccbe170fd96a1b47c8309c572d"} +{"type":"checkpoint","at":"2026-08-04T03:13:47.557Z","tick":45351,"reason":"interval","actorCount":2,"stateHash":"40a57086dfb4241d08a4666044cf556a1808b92757a92a92338d80d0a5f1dbc0"} +{"type":"checkpoint","at":"2026-08-04T03:13:52.563Z","tick":45502,"reason":"interval","actorCount":2,"stateHash":"347aa69f3273d5dda5c093df640db5e5e4bb22f6854d4f92b3678a580bb45592"} +{"type":"checkpoint","at":"2026-08-04T03:13:57.580Z","tick":45652,"reason":"interval","actorCount":2,"stateHash":"7c8a7b4b5ad8a965a030566a95073fdb89a4ad614b65d8006e240bb2378e632b"} +{"type":"checkpoint","at":"2026-08-04T03:14:02.598Z","tick":45803,"reason":"interval","actorCount":2,"stateHash":"307104969793897d06f02bcc30933514d85aca86989ae493787c83d21287063f"} +{"type":"checkpoint","at":"2026-08-04T03:14:07.603Z","tick":45953,"reason":"interval","actorCount":2,"stateHash":"2d69955b8f47a8ac4db84fb43f098ef344af05fb4d07d69bdc0b2997d269a3d1"} +{"type":"checkpoint","at":"2026-08-04T03:14:12.610Z","tick":46103,"reason":"interval","actorCount":2,"stateHash":"c97a6a288ce690fd46400824584d19e1684ec03bda4d454afe276abc3b57d140"} +{"type":"checkpoint","at":"2026-08-04T03:14:17.627Z","tick":46253,"reason":"interval","actorCount":2,"stateHash":"5492fe87ced8fbbc176d3a7916fbf83981ec6f0b6bf66a13bd6fc11c26af701b"} +{"type":"checkpoint","at":"2026-08-04T03:14:22.631Z","tick":46404,"reason":"interval","actorCount":2,"stateHash":"57a55db8f6e85249d5758e6efe4779d6659d93f2aa23958395e611fefb2cc149"} +{"type":"checkpoint","at":"2026-08-04T03:14:27.639Z","tick":46554,"reason":"interval","actorCount":2,"stateHash":"8ac118bb43d1cbec3164dc8ee0544655223ba40c7f91b7d5bcd3e2d536a40d1d"} +{"type":"checkpoint","at":"2026-08-04T03:14:32.657Z","tick":46704,"reason":"interval","actorCount":2,"stateHash":"850b47b2c269f321cf5a805f9d57f54a137dc1040b1929e43ff912662a11a271"} +{"type":"checkpoint","at":"2026-08-04T03:14:37.663Z","tick":46854,"reason":"interval","actorCount":2,"stateHash":"baae182475d0f01b6ff7311853c2471ab81ada18202fa147d516e185fc816c5b"} +{"type":"checkpoint","at":"2026-08-04T03:14:42.670Z","tick":47005,"reason":"interval","actorCount":2,"stateHash":"795b5cfde5dfbc159bfa9a3ba1f340a80c3c5230e76c9c1b80b9c72179c151ee"} +{"type":"checkpoint","at":"2026-08-04T03:14:47.675Z","tick":47155,"reason":"interval","actorCount":2,"stateHash":"fd318e73f2a4afaccd084a3c731107cf64a573c76eb14bf5a2e322213485a01d"} +{"type":"checkpoint","at":"2026-08-04T03:14:52.691Z","tick":47305,"reason":"interval","actorCount":2,"stateHash":"3334efcfccb1caf913455f31637585b0cd3c77d123a9e36d3524bf53cd5b4adb"} +{"type":"checkpoint","at":"2026-08-04T03:14:57.694Z","tick":47455,"reason":"interval","actorCount":2,"stateHash":"ef968658b8b7b00640babc74a3eede5ccfda3c0998966066edc480df9483f8ff"} +{"type":"checkpoint","at":"2026-08-04T03:15:02.698Z","tick":47606,"reason":"interval","actorCount":2,"stateHash":"281ed9f4df9cba684d16a5ce36746bba75a662f9d4a64c482fa39d532c1573ce"} +{"type":"checkpoint","at":"2026-08-04T03:15:07.703Z","tick":47756,"reason":"interval","actorCount":2,"stateHash":"e994aa19f88a01b3d260a0187240541b41ab53075eae4a017a56618b43451a42"} +{"type":"checkpoint","at":"2026-08-04T03:15:12.721Z","tick":47906,"reason":"interval","actorCount":2,"stateHash":"dd4a101621618381d19dc8365adfa983b056921df7965a0dfaf9543b160216b3"} +{"type":"checkpoint","at":"2026-08-04T03:15:17.732Z","tick":48057,"reason":"interval","actorCount":2,"stateHash":"3348b332d36ee27064d8c127b61182e0eae7b6521b5950375d8f840052f7a2a8"} +{"type":"checkpoint","at":"2026-08-04T03:15:22.737Z","tick":48207,"reason":"interval","actorCount":2,"stateHash":"00fe64afdee61231976a549f15e4a5dc8910389292a9d672ce7dd47b6a944505"} +{"type":"checkpoint","at":"2026-08-04T03:15:27.744Z","tick":48357,"reason":"interval","actorCount":2,"stateHash":"4e32c735487feb63ff0ddf674606ea97776da585fca4735b32126ef4866e828a"} +{"type":"checkpoint","at":"2026-08-04T03:15:32.748Z","tick":48507,"reason":"interval","actorCount":2,"stateHash":"f170376b02c7e9c981ccbb9d4172fcc46ae69c2d9caffec4224077bf6e403e38"} +{"type":"checkpoint","at":"2026-08-04T03:15:37.765Z","tick":48658,"reason":"interval","actorCount":2,"stateHash":"76e889333794bbc4f486e26ede16a6a89d4089f9029bad5c09e9feb086f155cc"} +{"type":"checkpoint","at":"2026-08-04T03:15:42.767Z","tick":48808,"reason":"interval","actorCount":2,"stateHash":"e291b3340a5f4a5619d35ac3ea79477fb4628e39f0ace7d0d444bb90b596d4d5"} +{"type":"checkpoint","at":"2026-08-04T03:15:47.788Z","tick":48958,"reason":"interval","actorCount":2,"stateHash":"ed1a82be7498dc4493f968538d09f30ca16b702c0d3d10fdeb2148f7162e90e2"} +{"type":"checkpoint","at":"2026-08-04T03:15:52.798Z","tick":49109,"reason":"interval","actorCount":2,"stateHash":"2fa271fb7da6ef402c17b8dccb785c4eb00a77d6261598d0e3b2059c4b3da135"} +{"type":"checkpoint","at":"2026-08-04T03:15:57.804Z","tick":49259,"reason":"interval","actorCount":2,"stateHash":"1ce69e4fb629a0220bec28d7657bf28d2b208ad6e0df8a6acc8fac3d7528ba00"} +{"type":"checkpoint","at":"2026-08-04T03:16:02.818Z","tick":49409,"reason":"interval","actorCount":2,"stateHash":"e65b1028ed9f153f43dd2786f2bdb920e9fb14c53a8eb6115c6b9967da2ac4d3"} +{"type":"checkpoint","at":"2026-08-04T03:16:07.826Z","tick":49559,"reason":"interval","actorCount":2,"stateHash":"92c0da42ed03e4c6c65667721700279aef1251ea8cc6fa1c112b5c0416ba610e"} +{"type":"checkpoint","at":"2026-08-04T03:16:12.827Z","tick":49709,"reason":"interval","actorCount":2,"stateHash":"da48266c52d4da380e7e7990fb7cc91930a20e155eb2e3a014807cc736a28fe5"} +{"type":"checkpoint","at":"2026-08-04T03:16:17.831Z","tick":49860,"reason":"interval","actorCount":2,"stateHash":"d53b0c2ab2754cd8e3286e8ee60990a303e07854dad9e9bb8a7cd56417e06d9f"} +{"type":"checkpoint","at":"2026-08-04T03:16:22.832Z","tick":50010,"reason":"interval","actorCount":2,"stateHash":"6dfe087ecf5c3bba35daa7968219cd6fdb54760d1fd175e061807a1232a400dc"} +{"type":"checkpoint","at":"2026-08-04T03:16:27.849Z","tick":50160,"reason":"interval","actorCount":2,"stateHash":"2e2497e1440c70e1864d4ea05e9d4b44ddce68c41a9c41d437246b516d88d4e9"} +{"type":"checkpoint","at":"2026-08-04T03:16:32.866Z","tick":50311,"reason":"interval","actorCount":2,"stateHash":"fc9a12463f3a323403e821eccb344abda8e58d5b453c8eb3fdf0fa7539140869"} +{"type":"checkpoint","at":"2026-08-04T03:16:37.870Z","tick":50461,"reason":"interval","actorCount":2,"stateHash":"004f4f22bd83307413138d0c316525a67f399ff6b7d4c8bdfd3e5a1af2c5249a"} +{"type":"checkpoint","at":"2026-08-04T03:16:42.883Z","tick":50611,"reason":"interval","actorCount":2,"stateHash":"cf039973f9087a3f27d263f357bfda21054cb44c05db7871218e34217480f4b0"} +{"type":"checkpoint","at":"2026-08-04T03:16:47.898Z","tick":50762,"reason":"interval","actorCount":2,"stateHash":"76853aead1eb369a07b1b7ee58e9518afcaaf1686a8f737d40d3f21c5b2bcb44"} +{"type":"checkpoint","at":"2026-08-04T03:16:52.907Z","tick":50912,"reason":"interval","actorCount":2,"stateHash":"175464f94ef0d8877ab27e536e1644c8b3e4153e6e4f7490772a90b0df6b2027"} +{"type":"checkpoint","at":"2026-08-04T03:16:57.925Z","tick":51062,"reason":"interval","actorCount":2,"stateHash":"a6f1e640925e83f9fd0a3c0d3a2ddf29883be01f3b74df391f1852e853f7918e"} +{"type":"checkpoint","at":"2026-08-04T03:17:02.932Z","tick":51213,"reason":"interval","actorCount":2,"stateHash":"7254add4769b03a2bbbb92555f7ad2a79d74128c046089a323388122543255b7"} +{"type":"checkpoint","at":"2026-08-04T03:17:07.945Z","tick":51363,"reason":"interval","actorCount":2,"stateHash":"67f0d47364c2605452a985433a59a4ae72b8e5b698bf19a9f979ccaa2c472f52"} +{"type":"checkpoint","at":"2026-08-04T03:17:12.946Z","tick":51513,"reason":"interval","actorCount":2,"stateHash":"95bfb80e0ebf7055e02be191042fe82bdcd601edfcfd6fe0db7988f22d5837bf"} +{"type":"checkpoint","at":"2026-08-04T03:17:17.949Z","tick":51663,"reason":"interval","actorCount":2,"stateHash":"8e03c47030e8e87705202065fb9cff1583b7ccefbb47e45c25b3932ff98b32d1"} +{"type":"checkpoint","at":"2026-08-04T03:17:22.965Z","tick":51814,"reason":"interval","actorCount":2,"stateHash":"4f604a173313d764ddd832442a0473ca16fc5340a959fb882359a6123bc9bbd9"} +{"type":"checkpoint","at":"2026-08-04T03:17:27.976Z","tick":51964,"reason":"interval","actorCount":2,"stateHash":"47f6968d58a8704d7dde93d76b5a9f26b5543bb58b78229f695a3318c49e1955"} +{"type":"checkpoint","at":"2026-08-04T03:17:32.978Z","tick":52114,"reason":"interval","actorCount":2,"stateHash":"53dc04085a16fff102579a0eb9f1bcd445a070c4e91c7a217714e22da6f5a6ba"} +{"type":"checkpoint","at":"2026-08-04T03:17:37.999Z","tick":52265,"reason":"interval","actorCount":2,"stateHash":"44591292d457e37475e805cff361ec45944637ea6176198a62faa25812b2d653"} +{"type":"checkpoint","at":"2026-08-04T03:17:43.002Z","tick":52415,"reason":"interval","actorCount":2,"stateHash":"eeb0a415d443a09374ededee46bc781f1237f9c2446d51ee59b311a8257a7f5a"} +{"type":"checkpoint","at":"2026-08-04T03:17:48.013Z","tick":52565,"reason":"interval","actorCount":2,"stateHash":"b5ecd3ec7a95f15023da9924a4f2cf51993d24fee6453f13652a2651ca300077"} +{"type":"checkpoint","at":"2026-08-04T03:17:53.025Z","tick":52715,"reason":"interval","actorCount":2,"stateHash":"e158f38365af367ee2304681b186faa8f4a4856e70483c13628c8b0219626b33"} +{"type":"checkpoint","at":"2026-08-04T03:17:58.029Z","tick":52865,"reason":"interval","actorCount":2,"stateHash":"ddd7b82080a0b83e824ddce781e879d48a95f85666071c74c3e82ebd9c9b479f"} +{"type":"checkpoint","at":"2026-08-04T03:18:03.032Z","tick":53016,"reason":"interval","actorCount":2,"stateHash":"86b3876dd3cf6c7860ec34278ef03cb45dd04f655aeb89d34eb4b3b3cda27930"} +{"type":"checkpoint","at":"2026-08-04T03:18:08.044Z","tick":53166,"reason":"interval","actorCount":2,"stateHash":"9100785b2713830817917e50fab0718434c4d3a242c4cd4d96ea62ddcef254ae"} +{"type":"checkpoint","at":"2026-08-04T03:18:13.049Z","tick":53316,"reason":"interval","actorCount":2,"stateHash":"e0ec750b3ee0894af96440a652d528f2265592445e0f2dc1a0c63d8882701262"} +{"type":"checkpoint","at":"2026-08-04T03:18:18.066Z","tick":53467,"reason":"interval","actorCount":2,"stateHash":"0c673661f76b402713f9b2e411a214f77d241b62b6e91700dfe7c47bd8f1a536"} +{"type":"checkpoint","at":"2026-08-04T03:18:23.074Z","tick":53617,"reason":"interval","actorCount":2,"stateHash":"6ced9c70d9a95c0d89e174db5a340a612924cb576ce41ae5108a23d9c1e1b43f"} +{"type":"checkpoint","at":"2026-08-04T03:18:28.075Z","tick":53767,"reason":"interval","actorCount":2,"stateHash":"f48b717702c1cc8bc62f5fa9567302b4f80834500038627a2b229f80f799b4be"} +{"type":"checkpoint","at":"2026-08-04T03:18:33.087Z","tick":53917,"reason":"interval","actorCount":2,"stateHash":"cb3720f8921ec46d3ac88be1f1063fad4259dc0141f2cee85e00658012f99bbb"} +{"type":"checkpoint","at":"2026-08-04T03:18:38.099Z","tick":54068,"reason":"interval","actorCount":2,"stateHash":"8640eeffd922ee14b28c5a63d353b1b98e795a147246ea927b4d573b96bb3530"} +{"type":"checkpoint","at":"2026-08-04T03:18:43.100Z","tick":54218,"reason":"interval","actorCount":2,"stateHash":"655c4dc13673d1ffa08d22491c630021ba997497256b0b1b18e0768ed9e6642e"} +{"type":"checkpoint","at":"2026-08-04T03:18:48.119Z","tick":54368,"reason":"interval","actorCount":2,"stateHash":"375964563ebcd2ada2889bb71ef6c21427741b2565e5f8aeec9f4c0171d2c4ac"} +{"type":"checkpoint","at":"2026-08-04T03:18:53.133Z","tick":54519,"reason":"interval","actorCount":2,"stateHash":"42968e1f60d05472b7ac2c0125cb945786ced6bbd4aed23f0ac15328bb428f5a"} +{"type":"checkpoint","at":"2026-08-04T03:18:58.152Z","tick":54669,"reason":"interval","actorCount":2,"stateHash":"19b166cd12783da1ff12a87a41eeda1fb512824737f0606a4c407a6e91b90bf6"} +{"type":"checkpoint","at":"2026-08-04T03:19:03.161Z","tick":54819,"reason":"interval","actorCount":2,"stateHash":"04d1fe957bce198933c6b6acb140b6951b418dc82f69403286c4f100c5d1a0ed"} +{"type":"checkpoint","at":"2026-08-04T03:19:08.166Z","tick":54970,"reason":"interval","actorCount":2,"stateHash":"17cde659824c8c3c7fcab09a4dba4bce20018b6ecb7268792065e1a274bbce70"} +{"type":"checkpoint","at":"2026-08-04T03:19:13.185Z","tick":55120,"reason":"interval","actorCount":2,"stateHash":"dc27ac46c548fd38796157bd8a8bd25bda8eb9c539e09296a67108b48172fe60"} +{"type":"checkpoint","at":"2026-08-04T03:19:18.198Z","tick":55270,"reason":"interval","actorCount":2,"stateHash":"4c389863c6940878796de714fdda9d302b335beec1c8a9f19f0cf410ddfc876e"} +{"type":"checkpoint","at":"2026-08-04T03:19:23.207Z","tick":55421,"reason":"interval","actorCount":2,"stateHash":"7a7e6a86bb0bc236546146acfd39b3b1a54f5b86df8612c01e132fe30226c447"} +{"type":"checkpoint","at":"2026-08-04T03:19:28.212Z","tick":55571,"reason":"interval","actorCount":2,"stateHash":"b61d39b3f1aa421d1210b24a9533cfb77017565a8c970e31476ffa21d41d0ba0"} +{"type":"checkpoint","at":"2026-08-04T03:19:33.226Z","tick":55721,"reason":"interval","actorCount":2,"stateHash":"2c1bdb74863435ad0fa13bcb7dea4c6e743c1b208321d974dc390c3b34286aa9"} +{"type":"checkpoint","at":"2026-08-04T03:19:38.229Z","tick":55871,"reason":"interval","actorCount":2,"stateHash":"131487f71384d0d3b9826a3f8ecaa34113238f21b5a5609105ef508244826319"} +{"type":"checkpoint","at":"2026-08-04T03:19:43.233Z","tick":56022,"reason":"interval","actorCount":2,"stateHash":"b170ae59d3e0a087a3752eb29b6e42f27caa5187e6f32b22be031f0228a38bd6"} +{"type":"checkpoint","at":"2026-08-04T03:19:48.250Z","tick":56172,"reason":"interval","actorCount":2,"stateHash":"321257e2b1b83aa7e0f6828cb00271390b82fdd04ed706607d1f836e0186a533"} +{"type":"checkpoint","at":"2026-08-04T03:19:53.262Z","tick":56322,"reason":"interval","actorCount":2,"stateHash":"553b36fde20ff4156e909a6c81f08d623c455a1b1af94bfbaba0085e9dafc8b6"} +{"type":"checkpoint","at":"2026-08-04T03:19:58.266Z","tick":56473,"reason":"interval","actorCount":2,"stateHash":"cd6c9fd7e7bc6aad541c5d3681f1fd27af9419062a6151592582f08b348cdace"} +{"type":"checkpoint","at":"2026-08-04T03:20:03.283Z","tick":56623,"reason":"interval","actorCount":2,"stateHash":"11ef5500cd8f88e78b997f69d47efa11571688f58a3e581b5c31ae77045bd455"} +{"type":"checkpoint","at":"2026-08-04T03:20:08.296Z","tick":56773,"reason":"interval","actorCount":2,"stateHash":"21ccf91202c8b98156298014ad2bca2d49658796ca2546f3f4f6db4fb6fa75f3"} +{"type":"checkpoint","at":"2026-08-04T03:20:13.299Z","tick":56924,"reason":"interval","actorCount":2,"stateHash":"948b7945b417c4dc1a91759b584d3901a2ddef735000d04b724452e3924fc364"} +{"type":"checkpoint","at":"2026-08-04T03:20:18.317Z","tick":57074,"reason":"interval","actorCount":2,"stateHash":"4e03738619e5d1cf68fbd38d175be18f8015c15665326a78b065313816db24b1"} +{"type":"checkpoint","at":"2026-08-04T03:20:23.331Z","tick":57224,"reason":"interval","actorCount":2,"stateHash":"54dc1c9e770f6fedc6e307279e47b206bfa9261e3fa5c943a8ac4c0a0276d848"} +{"type":"checkpoint","at":"2026-08-04T03:20:28.334Z","tick":57375,"reason":"interval","actorCount":2,"stateHash":"1c5fd7f12036ebe8829b7695018c707bf8749937abf35ba9b9222073f56d4b62"} +{"type":"checkpoint","at":"2026-08-04T03:20:33.352Z","tick":57525,"reason":"interval","actorCount":2,"stateHash":"db54a14c412e0291195ad29808e0157c010305efad3d6820ea2c3270ba3c76c7"} +{"type":"checkpoint","at":"2026-08-04T03:20:38.354Z","tick":57675,"reason":"interval","actorCount":2,"stateHash":"328a3f3336c18852a9c15807dcc5b7e1c017affa259763e64a2e04d1286b9960"} +{"type":"checkpoint","at":"2026-08-04T03:20:43.366Z","tick":57826,"reason":"interval","actorCount":2,"stateHash":"cea046666a43255183babab0b9b4bbecdccbf87fba62595d194865a2ad30180f"} +{"type":"checkpoint","at":"2026-08-04T03:20:48.374Z","tick":57976,"reason":"interval","actorCount":2,"stateHash":"bb6c8fc86c87bc770b35edd1fee326b50c779ce8479200604f6fcbd3692b658e"} +{"type":"checkpoint","at":"2026-08-04T03:20:53.383Z","tick":58126,"reason":"interval","actorCount":2,"stateHash":"03b45274727c059bc3112a4991d93b4c381f69485a3bc12d9d954c2d8972e036"} +{"type":"checkpoint","at":"2026-08-04T03:20:58.388Z","tick":58276,"reason":"interval","actorCount":2,"stateHash":"1cfe4654957a2ba5ecc7ba4a87c9d63064496e50fdda4478b0628123b4a36a0b"} +{"type":"checkpoint","at":"2026-08-04T03:21:03.397Z","tick":58426,"reason":"interval","actorCount":2,"stateHash":"2f7e1672c60080025cc75e336bacdb4e875cb52feef66cc376ee9e50c05e191f"} +{"type":"checkpoint","at":"2026-08-04T03:21:08.400Z","tick":58577,"reason":"interval","actorCount":2,"stateHash":"24655443404d9895faa6298f72e4b8048ce44ae0b30f4002e218e09ead9ad175"} +{"type":"checkpoint","at":"2026-08-04T03:21:13.401Z","tick":58727,"reason":"interval","actorCount":2,"stateHash":"0ac56ab570d9e70fe3742cc1c77d623dc0f810bd47fb31fdc746acdddb965040"} +{"type":"checkpoint","at":"2026-08-04T03:21:18.403Z","tick":58877,"reason":"interval","actorCount":2,"stateHash":"dbb450768e22bb2120743c48ca3bd85b2de2a3af0b280ba4bfd459cb22424fca"} +{"type":"checkpoint","at":"2026-08-04T03:21:23.409Z","tick":59027,"reason":"interval","actorCount":2,"stateHash":"c6b05a65a3376deaea63d3405c7af173ef7a1f18839a5517f0569cc4516d03db"} +{"type":"checkpoint","at":"2026-08-04T03:21:28.418Z","tick":59177,"reason":"interval","actorCount":2,"stateHash":"fae12198f4deabff2dcfe463f1df071b1e699fae1dec3677a674186170521087"} +{"type":"checkpoint","at":"2026-08-04T03:21:33.428Z","tick":59327,"reason":"interval","actorCount":2,"stateHash":"4f2c4cbe2a5369b13fb39f4adb2cb7256d5bd1bbe7317415603f5c6d368161d3"} +{"type":"checkpoint","at":"2026-08-04T03:21:38.434Z","tick":59478,"reason":"interval","actorCount":2,"stateHash":"bf72a5454e2b0f6ba9597a5593d5a35624ee0dc771ae48f96125cf8b0d628f0c"} +{"type":"checkpoint","at":"2026-08-04T03:21:43.454Z","tick":59628,"reason":"interval","actorCount":2,"stateHash":"6094a6e3bd40a912d93f0acf76d44c0f71e5b482d46e8b55e4cf830f25723910"} +{"type":"checkpoint","at":"2026-08-04T03:21:48.467Z","tick":59779,"reason":"interval","actorCount":2,"stateHash":"8c813ce95c4ecc4ffea7bf62242c41c5d76b04f5b51cb2d9b00e6889717d012a"} +{"type":"checkpoint","at":"2026-08-04T03:21:53.476Z","tick":59929,"reason":"interval","actorCount":2,"stateHash":"315626f51601a8f20e106fc4d636cf36a0d6272f6d4ff9e4a6fd7672777ae3ec"} +{"type":"checkpoint","at":"2026-08-04T03:21:58.486Z","tick":60079,"reason":"interval","actorCount":2,"stateHash":"e5f7a1179649f436ea221b58c4d7e10879ddccaa22497fd635f6898125e408a0"} +{"type":"checkpoint","at":"2026-08-04T03:22:03.495Z","tick":60229,"reason":"interval","actorCount":2,"stateHash":"6317b668b60222cce14609ccf8abfb5d9a800999a4cf08d2e821b2dd291b9318"} +{"type":"checkpoint","at":"2026-08-04T03:22:08.501Z","tick":60380,"reason":"interval","actorCount":2,"stateHash":"64d46c42bfaaf6628febec3842fcb25ca27ac3d4d184fe735f488b883fb47349"} +{"type":"checkpoint","at":"2026-08-04T03:22:13.513Z","tick":60530,"reason":"interval","actorCount":2,"stateHash":"84a6fe0dc75df93d549f774735a5f12076354cf8513a31e8249bf9a757835b1d"} +{"type":"checkpoint","at":"2026-08-04T03:22:18.530Z","tick":60680,"reason":"interval","actorCount":2,"stateHash":"c49623f48104fe70768c3d8fae93509f4805cd06034fa7f43862ccad538c7517"} +{"type":"checkpoint","at":"2026-08-04T03:22:23.535Z","tick":60831,"reason":"interval","actorCount":2,"stateHash":"d34608d14ab395dfc4588dba196c534d56244f5e3cf702e31da913f9be093f49"} +{"type":"checkpoint","at":"2026-08-04T03:22:28.545Z","tick":60981,"reason":"interval","actorCount":2,"stateHash":"1d1d0ff7c6964629ee00e32aa33bdc0840d6f89e6d3ecf29b99b3f06a12f1760"} +{"type":"checkpoint","at":"2026-08-04T03:22:33.557Z","tick":61131,"reason":"interval","actorCount":2,"stateHash":"35185be74634e5464569e39a41c64d52511e73496697f5fd899f4844ca7ee1f1"} +{"type":"checkpoint","at":"2026-08-04T03:22:38.565Z","tick":61281,"reason":"interval","actorCount":2,"stateHash":"b7a8a84501f5ca5b0498ae988f7a44de1a291ce5fad2e6db22c821292e350a32"} +{"type":"checkpoint","at":"2026-08-04T03:22:43.567Z","tick":61432,"reason":"interval","actorCount":2,"stateHash":"735af0d79c2e2f2de40cf63dbf1b46c3baf0d15719eea2f0c709eb7a3e9b4dba"} +{"type":"checkpoint","at":"2026-08-04T03:22:48.569Z","tick":61582,"reason":"interval","actorCount":2,"stateHash":"651d2cc14689a430ad6d4d5ecf7a68d2a296d47beab86116fc542b523cea6c2e"} +{"type":"checkpoint","at":"2026-08-04T03:22:53.583Z","tick":61732,"reason":"interval","actorCount":2,"stateHash":"371cd0405c04191e65d6a26920a19649a2975aed4f4e71c5e24048e738f526e2"} +{"type":"checkpoint","at":"2026-08-04T03:22:58.593Z","tick":61882,"reason":"interval","actorCount":2,"stateHash":"72f795261053e8d4424d8dc335d1c158ea28811b8570874ed4499a3e3254765e"} +{"type":"checkpoint","at":"2026-08-04T03:23:03.599Z","tick":62032,"reason":"interval","actorCount":2,"stateHash":"82f82ccc5e7d0f52ffe750b3f7d8441ea03f23474b1a04c50b82eed7a1f62bba"} +{"type":"checkpoint","at":"2026-08-04T03:23:08.602Z","tick":62183,"reason":"interval","actorCount":2,"stateHash":"72b7389f630e46497e177a141b8e39a0fb33f63c2e83b6910d7d92c57665a050"} +{"type":"checkpoint","at":"2026-08-04T03:23:13.616Z","tick":62333,"reason":"interval","actorCount":2,"stateHash":"36e51d30a677b49046103295d25cdbfda2ef3e150ad77c4729d149b3d5a39b02"} +{"type":"checkpoint","at":"2026-08-04T03:23:18.624Z","tick":62483,"reason":"interval","actorCount":2,"stateHash":"be581efad10b1c7b7b6ee13a0086976b7a75487c5fecf6f123b177050824479d"} +{"type":"checkpoint","at":"2026-08-04T03:23:23.636Z","tick":62634,"reason":"interval","actorCount":2,"stateHash":"16d0fcc0d4370084c1ce2b48c3dbba12105a49258902aa8fc3ca410c0142b2f6"} +{"type":"checkpoint","at":"2026-08-04T03:23:28.653Z","tick":62784,"reason":"interval","actorCount":2,"stateHash":"0d4a14c3fc6e07983f8c436059a203e19c517d13a3b9663cf78b79dccd61b9df"} +{"type":"checkpoint","at":"2026-08-04T03:23:33.659Z","tick":62934,"reason":"interval","actorCount":2,"stateHash":"66b485cf4a26a901d92e485189ea44b146c76b0fc047665104c48b56ac038f93"} +{"type":"checkpoint","at":"2026-08-04T03:23:38.667Z","tick":63085,"reason":"interval","actorCount":2,"stateHash":"06dce11e263c1f95da73968f7e65dc82d6e9f4b723e5f0789b4e1006bf02f55b"} +{"type":"checkpoint","at":"2026-08-04T03:23:43.673Z","tick":63235,"reason":"interval","actorCount":2,"stateHash":"c907f8c69bd0fa0616f426598829cda7faca37bccdb9e3dc8bd8564dc84e9c02"} +{"type":"checkpoint","at":"2026-08-04T03:23:48.689Z","tick":63385,"reason":"interval","actorCount":2,"stateHash":"81819d37c504880345a48534eea82264b039815f42818970e5a717df26e65362"} +{"type":"checkpoint","at":"2026-08-04T03:23:53.701Z","tick":63536,"reason":"interval","actorCount":2,"stateHash":"640ed711ab766ae953d871a54cf78c748e39bd6b13acd212ed86f6770f325f98"} +{"type":"checkpoint","at":"2026-08-04T03:23:58.714Z","tick":63686,"reason":"interval","actorCount":2,"stateHash":"3f67ecace52619e4d4b6b562714bdf543388e861a6c1f29f9d7504dfd05356dd"} +{"type":"checkpoint","at":"2026-08-04T03:24:03.735Z","tick":63837,"reason":"interval","actorCount":2,"stateHash":"75ba6c8bd431a6f8bff081892c41b3c43e2b072aa7d7fbfbdf4c39e22e4047c1"} +{"type":"checkpoint","at":"2026-08-04T03:24:08.741Z","tick":63987,"reason":"interval","actorCount":2,"stateHash":"d665f6b9a3446f88c0003a572ca05528851ced9465d04ae76ae23381714fac72"} +{"type":"checkpoint","at":"2026-08-04T03:24:13.745Z","tick":64137,"reason":"interval","actorCount":2,"stateHash":"cb9e4d899972eeb3915c6f19b06c9121389456f377bb5db9f96ae8c0c6cc5238"} +{"type":"checkpoint","at":"2026-08-04T03:24:18.747Z","tick":64287,"reason":"interval","actorCount":2,"stateHash":"4b4328031148f0d7725eda108d5b843221a732a41d63a4c4b718fb5c872b5fa2"} +{"type":"checkpoint","at":"2026-08-04T03:24:23.762Z","tick":64437,"reason":"interval","actorCount":2,"stateHash":"7968aafde059d2e56582fd5c3fd20ceefadc26a468d99313edd17b03bd35b87b"} +{"type":"checkpoint","at":"2026-08-04T03:24:28.770Z","tick":64588,"reason":"interval","actorCount":2,"stateHash":"2fefbb251e3cdec9fd9f3cc3f270e4f3b05653269fe321edefbcd1821565743a"} +{"type":"checkpoint","at":"2026-08-04T03:24:33.788Z","tick":64738,"reason":"interval","actorCount":2,"stateHash":"e49e3954846c1873826fae0063ae894080a7a40c5408a0ad9f73e4ad7d439143"} +{"type":"checkpoint","at":"2026-08-04T03:24:38.790Z","tick":64888,"reason":"interval","actorCount":2,"stateHash":"8e601d51bd8af2108b9cce978942d137b58552d3e0a96c9c62a586306211a287"} +{"type":"checkpoint","at":"2026-08-04T03:24:43.802Z","tick":65039,"reason":"interval","actorCount":2,"stateHash":"d9127d10f3e6cc01f9e4f72c4006ae675eaeee833eab8cddea8d529306e17ae8"} +{"type":"checkpoint","at":"2026-08-04T03:24:48.805Z","tick":65189,"reason":"interval","actorCount":2,"stateHash":"c6ab3d48a518a0041862a408dd5c70005466a29e99b75fa83804c41e087e666b"} +{"type":"checkpoint","at":"2026-08-04T03:24:53.810Z","tick":65339,"reason":"interval","actorCount":2,"stateHash":"35f40e054185fbb6452658cc34d995a3082448347ef9c8406b2d5d852923419d"} +{"type":"checkpoint","at":"2026-08-04T03:24:58.823Z","tick":65489,"reason":"interval","actorCount":2,"stateHash":"c7e4e7a4df40a6a4541d325eafe052b414f1dfe947947e97c06b8c2750b894e2"} +{"type":"checkpoint","at":"2026-08-04T03:25:03.827Z","tick":65639,"reason":"interval","actorCount":2,"stateHash":"2ebcd497521b302cfe67d658dd37a4c401107f8d7d6a40f5116aacce160a30c1"} +{"type":"checkpoint","at":"2026-08-04T03:25:08.836Z","tick":65790,"reason":"interval","actorCount":2,"stateHash":"e17a8c6ae5d50c09ce6ca858ef1e42da89531b4998407abb92517d93c1e8269a"} +{"type":"checkpoint","at":"2026-08-04T03:25:13.842Z","tick":65940,"reason":"interval","actorCount":2,"stateHash":"112a2585048a8f43b61c8420333509771ab0cf5bfb671cb10dc5028389c5c54d"} +{"type":"checkpoint","at":"2026-08-04T03:25:18.861Z","tick":66090,"reason":"interval","actorCount":2,"stateHash":"dcb685d5454c9714d90386248e15e17e9667f6f081b937d7f5cd0dad572223b7"} +{"type":"checkpoint","at":"2026-08-04T03:25:23.869Z","tick":66241,"reason":"interval","actorCount":2,"stateHash":"4f71e67740367ed54fa8c737ef6b7342701d2ed8ff8c4e7168d5c07060152332"} +{"type":"checkpoint","at":"2026-08-04T03:25:28.883Z","tick":66391,"reason":"interval","actorCount":2,"stateHash":"c18fee3830febd1b45754a91e1d8ede4a61288402d65791510d9032fb55d5606"} +{"type":"checkpoint","at":"2026-08-04T03:25:33.890Z","tick":66541,"reason":"interval","actorCount":2,"stateHash":"141ec90736df6755333b311d4ab65d9b0573dbff2d9df2716b04cd165d081a8b"} +{"type":"checkpoint","at":"2026-08-04T03:25:38.906Z","tick":66692,"reason":"interval","actorCount":2,"stateHash":"3eec72f325c391fde2e4d04eb8a46011997c2e37b9389ff98311bc491dfc7f78"} +{"type":"checkpoint","at":"2026-08-04T03:25:43.918Z","tick":66842,"reason":"interval","actorCount":2,"stateHash":"efefcbc1e033a9d7711c7f9b155fc532bba9bbad40ec420a61bfb874ce618561"} +{"type":"checkpoint","at":"2026-08-04T03:25:48.935Z","tick":66992,"reason":"interval","actorCount":2,"stateHash":"0ed5b0434c5a4550ea4cc6625feabea9d142ca4955cc6671c6109219314ea576"} +{"type":"checkpoint","at":"2026-08-04T03:25:53.938Z","tick":67143,"reason":"interval","actorCount":2,"stateHash":"9b0b973349f054f56ba02b6b55224bece07ac08afbdc03a525c5fe4f6bcb3a96"} +{"type":"checkpoint","at":"2026-08-04T03:25:58.942Z","tick":67293,"reason":"interval","actorCount":2,"stateHash":"d352209c2f3024ea9b78438129f1e7f3bd56da8adfac2e3125ca62ad8fd83caf"} +{"type":"checkpoint","at":"2026-08-04T03:26:03.962Z","tick":67443,"reason":"interval","actorCount":2,"stateHash":"bb100eb21164dfa3598800db87d65c3441e89cd1fe699a78407aacd2f5bad808"} +{"type":"checkpoint","at":"2026-08-04T03:26:08.970Z","tick":67594,"reason":"interval","actorCount":2,"stateHash":"3465290c3af90ef04220e8b5969b5b36e757e6012b53611da406a29ec7209b62"} +{"type":"checkpoint","at":"2026-08-04T03:26:13.977Z","tick":67744,"reason":"interval","actorCount":2,"stateHash":"5ad924611fed3007a302c3df3021ef196ab53c2afb791963660356a7000bc17f"} +{"type":"checkpoint","at":"2026-08-04T03:26:18.994Z","tick":67894,"reason":"interval","actorCount":2,"stateHash":"e236e4a0e9819139c332b86ab7f3339534d08e750790b2858f3986a36c328aec"} +{"type":"checkpoint","at":"2026-08-04T03:26:24.000Z","tick":68044,"reason":"interval","actorCount":2,"stateHash":"f15529270fb630739ea4c33113e918664f183282723f7e4a793df72e98b9c6e8"} +{"type":"checkpoint","at":"2026-08-04T03:26:29.004Z","tick":68195,"reason":"interval","actorCount":2,"stateHash":"cc9c1dde4df3c39ae52574ba90b3420587d773873c7114b982acff899a187b4d"} +{"type":"checkpoint","at":"2026-08-04T03:26:34.024Z","tick":68345,"reason":"interval","actorCount":2,"stateHash":"aa04af14d6151f12dcdce68db11f654c3fd1d3b081d7b45c528bb73f3b4ce1c3"} +{"type":"checkpoint","at":"2026-08-04T03:26:39.028Z","tick":68495,"reason":"interval","actorCount":2,"stateHash":"ad3c4b9fb27130f0a41c741b183820282b7753f66fbae1d0a345541946ade54a"} +{"type":"checkpoint","at":"2026-08-04T03:26:44.036Z","tick":68646,"reason":"interval","actorCount":2,"stateHash":"fb15da4c6af3cf320f07c276ef58529b60073e89710f89d6b337d4b8ab1a0dcd"} +{"type":"checkpoint","at":"2026-08-04T03:26:49.052Z","tick":68796,"reason":"interval","actorCount":2,"stateHash":"b3ce54da8fa552addbc14448f19a403095ebe3036d57297639942162d480a8b8"} +{"type":"checkpoint","at":"2026-08-04T03:26:54.060Z","tick":68946,"reason":"interval","actorCount":2,"stateHash":"1d088effe154b9249c460cc2e9a0711ba79dd9e0f79bbbc5f2a0f2a408478fd0"} +{"type":"checkpoint","at":"2026-08-04T03:26:59.070Z","tick":69097,"reason":"interval","actorCount":2,"stateHash":"51611e4bf4f87d809edc6d9a3d301a29865bb0fa7f52c9c3c6655d61e24a871c"} +{"type":"session.disconnect","at":"2026-08-04T03:27:03.531Z","tick":69230,"sessionId":"g_3","actorId":"play-1","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T03:27:04.075Z","tick":69230,"reason":"interval","actorCount":2,"stateHash":"c092eab75b9d31f8e98813f68167a7fa9bd633b0991bebb3b43e8bdc4485718b"} +{"type":"session.connect","at":"2026-08-04T03:27:19.860Z","tick":69230,"sessionId":"g_4","actorId":"probe-1","playerId":"probe-1","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T03:27:19.870Z","tick":69231,"reason":"interval","actorCount":2,"stateHash":"44eb0dceb3233c2de431a3cf7219b7e3f279235e64c7cc465417226b0279e526"} +{"type":"checkpoint","at":"2026-08-04T03:27:24.878Z","tick":69381,"reason":"interval","actorCount":2,"stateHash":"4134d9ceb4dcfb9253a18cd3f2351c78a86550d959c00eac0613bbfb12e81037"} +{"type":"checkpoint","at":"2026-08-04T03:27:29.891Z","tick":69531,"reason":"interval","actorCount":2,"stateHash":"fea451c7cfc37211966319535d0dd47ef067427f64917397192881747c12ee96"} +{"type":"checkpoint","at":"2026-08-04T03:27:34.901Z","tick":69681,"reason":"interval","actorCount":2,"stateHash":"88be73f983d48c8021487ace9b567b2c905ef41b0a2a6e79f1a8589fbbba3225"} +{"type":"command.receipt","at":"2026-08-04T03:27:36.622Z","tick":69733,"actorId":"probe-1","commandId":1624000,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624000,"issued_at_tick":69733,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"ead028a79e83d0f8914ba9de9277cef6eeaf0521c17f6064bc402936408d91b8"}} +{"type":"command.receipt","at":"2026-08-04T03:27:36.637Z","tick":69734,"actorId":"probe-1","commandId":1624001,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624001,"issued_at_tick":69734,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"7b3b601338f0c42e1b85a7ddb9552fbaebfa180bb9db0128ee190f11c7836888"}} +{"type":"command.receipt","at":"2026-08-04T03:27:36.738Z","tick":69737,"actorId":"probe-1","commandId":1624002,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624002,"issued_at_tick":69737,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"00675d7776551687af98fd32cd6aa74b8803a3864bd2d0029ad0a0fd00b5fff9"}} +{"type":"command.receipt","at":"2026-08-04T03:27:36.838Z","tick":69740,"actorId":"probe-1","commandId":1624003,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624003,"issued_at_tick":69740,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"ba14516e6264510ecccdfb8452482a555f6cea5f8de8a44c4ac8a468cfafd058"}} +{"type":"command.receipt","at":"2026-08-04T03:27:36.938Z","tick":69743,"actorId":"probe-1","commandId":1624004,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624004,"issued_at_tick":69743,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"721603556e4fe3b7efa30a19a99d9417624c9a926b55d5390e7ea628c2ea2a4b"}} +{"type":"command.receipt","at":"2026-08-04T03:27:37.037Z","tick":69746,"actorId":"probe-1","commandId":1624005,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624005,"issued_at_tick":69746,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"5916d11234acde17dd37e58f377fe46f6853be34f2fd8b6d9172de9de3149d26"}} +{"type":"command.receipt","at":"2026-08-04T03:27:37.137Z","tick":69749,"actorId":"probe-1","commandId":1624006,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624006,"issued_at_tick":69749,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f0fee6a3c1fa94ba9f78762b38729a18661bbad8ee4aae7ceeb8113c22ec8423"}} +{"type":"command.receipt","at":"2026-08-04T03:27:37.237Z","tick":69752,"actorId":"probe-1","commandId":1624007,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624007,"issued_at_tick":69751,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"9b7a1f6167420509c1e2f6311019ab8761337fd20d0209568e4cccb8fe24ba04"}} +{"type":"command.receipt","at":"2026-08-04T03:27:37.337Z","tick":69755,"actorId":"probe-1","commandId":1624008,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624008,"issued_at_tick":69755,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"b6fcbce6ee64aac0180e6a62daf361d2a1af592c29f511c8007e2295146e8499"}} +{"type":"command.receipt","at":"2026-08-04T03:27:37.438Z","tick":69758,"actorId":"probe-1","commandId":1624009,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624009,"issued_at_tick":69758,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"ae7e4b5c25561005f5b69958e1da8ed09a0d339f683d029ba0ec13582b507f3e"}} +{"type":"command.receipt","at":"2026-08-04T03:27:37.538Z","tick":69761,"actorId":"probe-1","commandId":1624010,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624010,"issued_at_tick":69761,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"894e146a41711f95e3cbac8728097e87f1a0704e743cbcd92afa9774a1751301"}} +{"type":"command.receipt","at":"2026-08-04T03:27:37.637Z","tick":69764,"actorId":"probe-1","commandId":1624011,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624011,"issued_at_tick":69763,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f120825115a05cb78df0fd059512f3b8c812c18185a1c06b89ed16effc5dca00"}} +{"type":"command.receipt","at":"2026-08-04T03:27:37.738Z","tick":69767,"actorId":"probe-1","commandId":1624012,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624012,"issued_at_tick":69767,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"855898825447029d951c2d7808e711de58decf669c67903b3ef794dfbc6f5623"}} +{"type":"command.receipt","at":"2026-08-04T03:27:37.838Z","tick":69770,"actorId":"probe-1","commandId":1624013,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624013,"issued_at_tick":69770,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"fa6c02864528523fc8c8fea3c489e5d23f0de844247f8d4f30c4fe087b84db7b"}} +{"type":"command.receipt","at":"2026-08-04T03:27:37.939Z","tick":69773,"actorId":"probe-1","commandId":1624014,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624014,"issued_at_tick":69772,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"66371776a901d15ef49128747f98c4ec7f019c65fed05daf954aacacb68b2139"}} +{"type":"command.receipt","at":"2026-08-04T03:27:38.039Z","tick":69776,"actorId":"probe-1","commandId":1624015,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624015,"issued_at_tick":69776,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"8c3ceb500f4f30797689af4e940f35dd3050e731ea6667c1f3d6df1a6b2a8c07"}} +{"type":"command.receipt","at":"2026-08-04T03:27:38.138Z","tick":69779,"actorId":"probe-1","commandId":1624016,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624016,"issued_at_tick":69779,"command":{"SetMoveIntent":{"dx":0,"dy":0,"sprint":false}}},"session":4,"player":1,"stateHash":"1e845b13735416305bc6fe35d68a6952d84a5a434dd3100ee22cb1e2169e8fa0"}} +{"type":"checkpoint","at":"2026-08-04T03:27:39.903Z","tick":69832,"reason":"interval","actorCount":2,"stateHash":"dba8e10510558b0bb5226a727af63b9f9217f7ae6540a57d320333cbe1f78a3a"} +{"type":"checkpoint","at":"2026-08-04T03:27:44.923Z","tick":69982,"reason":"interval","actorCount":2,"stateHash":"9a020c2d5391e5de91a5855c2ef8e0d96697903cfd7ed81611dd64bcd59a78f5"} +{"type":"checkpoint","at":"2026-08-04T03:27:49.931Z","tick":70132,"reason":"interval","actorCount":2,"stateHash":"fafd3bdd20747929d11bf9780f0fe5fbf3e3abaf3ca0aae49ed39d6eb70572d0"} +{"type":"checkpoint","at":"2026-08-04T03:27:54.938Z","tick":70283,"reason":"interval","actorCount":2,"stateHash":"eabe0dc3440e1b978894e52a820f4baa252686cc0c45092d5781ab351b380fbb"} +{"type":"checkpoint","at":"2026-08-04T03:27:59.949Z","tick":70433,"reason":"interval","actorCount":2,"stateHash":"26f17c2daf70f797b1ca48d0c5b3275a109ceabba6d6c66c2a8921fe78e104f5"} +{"type":"checkpoint","at":"2026-08-04T03:28:04.960Z","tick":70583,"reason":"interval","actorCount":2,"stateHash":"7fb141f60a8e4c81c9da19a53655c91bd12778585c31cb6ca676c5fbd6e46280"} +{"type":"checkpoint","at":"2026-08-04T03:28:09.964Z","tick":70733,"reason":"interval","actorCount":2,"stateHash":"87ef6656dec53243d014e7a450b44a023149c3faabc714d691133fc9dbea6a83"} +{"type":"checkpoint","at":"2026-08-04T03:28:14.966Z","tick":70883,"reason":"interval","actorCount":2,"stateHash":"210bc64d1327ec9032233a648d6d9e3303b3bd9ecdd660346d09e08aa9ac036d"} +{"type":"checkpoint","at":"2026-08-04T03:28:19.970Z","tick":71034,"reason":"interval","actorCount":2,"stateHash":"af68db089f98d4d135eaf622ab6502a14cfa842caa29fe3ff542d8dc06f0f83c"} +{"type":"checkpoint","at":"2026-08-04T03:28:24.991Z","tick":71184,"reason":"interval","actorCount":2,"stateHash":"b7a220bf25efcb49d9260dcfff3a57616ca6645cee3b82b1d479c88f4e417ce4"} +{"type":"checkpoint","at":"2026-08-04T03:28:29.993Z","tick":71334,"reason":"interval","actorCount":2,"stateHash":"7651c79384f3a4103dca75febf687ba404cef28022f362b887347870858ee2f6"} +{"type":"command.receipt","at":"2026-08-04T03:28:33.738Z","tick":71447,"actorId":"probe-1","commandId":1624017,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624017,"issued_at_tick":71447,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"cd32ffe7efe8282acfe2830161100ac888b619aa163f3402ade485d0b9e7b8b5"}} +{"type":"command.receipt","at":"2026-08-04T03:28:33.936Z","tick":71453,"actorId":"probe-1","commandId":1624018,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624018,"issued_at_tick":71452,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"3ceaa51b0682d5abe4edbf7b6f7e57d448e288ec23d85055daf5a0cf800eda10"}} +{"type":"command.receipt","at":"2026-08-04T03:28:34.120Z","tick":71458,"actorId":"probe-1","commandId":1624019,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624019,"issued_at_tick":71458,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"c0a0429a229a4e395779cb63161a30f1cab803f823356b30fe1724da3f9e354c"}} +{"type":"command.receipt","at":"2026-08-04T03:28:34.320Z","tick":71464,"actorId":"probe-1","commandId":1624020,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624020,"issued_at_tick":71464,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"882395494ae22bd70153762ece04ac07530a2dbb54fc37fd3f0510a520141a44"}} +{"type":"command.receipt","at":"2026-08-04T03:28:34.502Z","tick":71469,"actorId":"probe-1","commandId":1624021,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624021,"issued_at_tick":71469,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"de5e3f69b9c018aa83728948fee3f4ad621596e8e66b077e9c5be59420866cf5"}} +{"type":"command.receipt","at":"2026-08-04T03:28:34.621Z","tick":71473,"actorId":"probe-1","commandId":1624022,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624022,"issued_at_tick":71473,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"e8a4eee24752a33c77eaeda41251a38e7d55684334f976ab1b3b1bf3320235b0"}} +{"type":"command.receipt","at":"2026-08-04T03:28:34.734Z","tick":71476,"actorId":"probe-1","commandId":1624023,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624023,"issued_at_tick":71476,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"e9bb44e960817cee708f554b9879ec9cf6b593caaf9e61f5f83384e2e9454af4"}} +{"type":"command.receipt","at":"2026-08-04T03:28:34.834Z","tick":71479,"actorId":"probe-1","commandId":1624024,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624024,"issued_at_tick":71479,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"280cf6b0605ba58173fbdc4fbd4e70f71201acabc53d8abea17c9be6322f0354"}} +{"type":"command.receipt","at":"2026-08-04T03:28:34.933Z","tick":71482,"actorId":"probe-1","commandId":1624025,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624025,"issued_at_tick":71482,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"e09a2a6504c4d95351b0019d9e5855edc323f16749fac1af1cfd6b55f0cd4b6f"}} +{"type":"checkpoint","at":"2026-08-04T03:28:35.005Z","tick":71485,"reason":"interval","actorCount":2,"stateHash":"39321985cf57fb46d5a880e0ed94d403bbd16c21b5044d21bf5f6f88696c31ba"} +{"type":"command.receipt","at":"2026-08-04T03:28:35.034Z","tick":71485,"actorId":"probe-1","commandId":1624026,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624026,"issued_at_tick":71485,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"507bf5804f79ff4492bc7dfc5b2c6049a3c04dbb8bc82ee16e014399805c0f2f"}} +{"type":"command.receipt","at":"2026-08-04T03:28:35.133Z","tick":71488,"actorId":"probe-1","commandId":1624027,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624027,"issued_at_tick":71488,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"eae43a2803a969f192a4dbc43cd30bfc2a87d84804ef8375066f094e23c01a39"}} +{"type":"command.receipt","at":"2026-08-04T03:28:35.234Z","tick":71491,"actorId":"probe-1","commandId":1624028,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624028,"issued_at_tick":71491,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"c6358c142802a2c11b09caa9ee12be405936fd62b8e854bd78b8b2e7de960c91"}} +{"type":"command.receipt","at":"2026-08-04T03:28:35.333Z","tick":71494,"actorId":"probe-1","commandId":1624029,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624029,"issued_at_tick":71494,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"baad3f55f0b0dd089c83cc80a45ed5fbf3234bea4f26ba344cfde0a488c79fe5"}} +{"type":"command.receipt","at":"2026-08-04T03:28:35.433Z","tick":71497,"actorId":"probe-1","commandId":1624030,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624030,"issued_at_tick":71497,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"6218d99de2bdf72c7f6f002af8c69accdd29bcedd8dea04546ca38086ed9fd2a"}} +{"type":"command.receipt","at":"2026-08-04T03:28:35.533Z","tick":71500,"actorId":"probe-1","commandId":1624031,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624031,"issued_at_tick":71500,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"9fe3b69b534bfe062908c75ec7ec54d521cf22195f727ccd84756a2208acb28d"}} +{"type":"command.receipt","at":"2026-08-04T03:28:35.633Z","tick":71503,"actorId":"probe-1","commandId":1624032,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624032,"issued_at_tick":71503,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"4bdccf87b0320287de264fb733eff7a5de1a7de9cc4c51e035679140818169ab"}} +{"type":"command.receipt","at":"2026-08-04T03:28:35.734Z","tick":71506,"actorId":"probe-1","commandId":1624033,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624033,"issued_at_tick":71506,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"44051fa8d6a2cbc840ef6defe6065ca976dbd74f0880a03bfd5876c9fb233ead"}} +{"type":"command.receipt","at":"2026-08-04T03:28:35.750Z","tick":71507,"actorId":"probe-1","commandId":1624034,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624034,"issued_at_tick":71507,"command":{"SetMoveIntent":{"dx":0,"dy":0,"sprint":false}}},"session":4,"player":1,"stateHash":"f0f1ace93d35f9ee1e8f6fbf4791593e88f708a8017f472c7fb33df39a0c779f"}} +{"type":"checkpoint","at":"2026-08-04T03:28:40.014Z","tick":71635,"reason":"interval","actorCount":2,"stateHash":"a76c03b23d0b91e7a9f2c123410493604fe09c5e3bee6c882e0b3fb203cf24c1"} +{"type":"checkpoint","at":"2026-08-04T03:28:45.023Z","tick":71785,"reason":"interval","actorCount":2,"stateHash":"462a506bcbe5cc76c650179efe5158b237fe59b171187f617dce770ca000717e"} +{"type":"checkpoint","at":"2026-08-04T03:28:50.029Z","tick":71935,"reason":"interval","actorCount":2,"stateHash":"c938d6934ff0ac486a70fcd783538a2e7fc7a44408f50e2962ea045a8bba6817"} +{"type":"checkpoint","at":"2026-08-04T03:28:55.037Z","tick":72086,"reason":"interval","actorCount":2,"stateHash":"5bcf9f5670faf99e090efb513a337e45c1e4fc3244783ae8d317580996e4044a"} +{"type":"command.receipt","at":"2026-08-04T03:28:55.685Z","tick":72105,"actorId":"probe-1","commandId":1624035,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624035,"issued_at_tick":72105,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"75a22151b6f54965b74b007306bd7449171f425e76d7ef3a2cccb08ab7811f49"}} +{"type":"command.receipt","at":"2026-08-04T03:28:55.734Z","tick":72106,"actorId":"probe-1","commandId":1624036,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624036,"issued_at_tick":72106,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"7d0ce663104ec676651ceca6eb72c973ea97ba68cf5ad7e3d2fbbf5048c2f05b"}} +{"type":"command.receipt","at":"2026-08-04T03:28:55.833Z","tick":72109,"actorId":"probe-1","commandId":1624037,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624037,"issued_at_tick":72109,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"c4fec5210c51cadb2b5c064c8055ae66552041d60d375f14df4661b535f525e8"}} +{"type":"command.receipt","at":"2026-08-04T03:28:55.934Z","tick":72112,"actorId":"probe-1","commandId":1624038,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624038,"issued_at_tick":72112,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"6fc22407a3a1b2192166f1d751afcc8c69a47bcf0127280f48b4981b191e4366"}} +{"type":"command.receipt","at":"2026-08-04T03:28:56.033Z","tick":72115,"actorId":"probe-1","commandId":1624039,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624039,"issued_at_tick":72115,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"0d678df4988307fa54b0716b7fb9304847288d651fcdfd833661e8079ca1dbea"}} +{"type":"command.receipt","at":"2026-08-04T03:28:56.133Z","tick":72118,"actorId":"probe-1","commandId":1624040,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624040,"issued_at_tick":72118,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"5152bbc8612277992d6927db03303e3e31d19896c72ad3a2ddf34989af96edca"}} +{"type":"command.receipt","at":"2026-08-04T03:28:56.237Z","tick":72122,"actorId":"probe-1","commandId":1624041,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624041,"issued_at_tick":72122,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"5c6fb1024143fa41326cb6519232877de825ce46f25bc760ca7c70f95e01b000"}} +{"type":"command.receipt","at":"2026-08-04T03:28:56.334Z","tick":72124,"actorId":"probe-1","commandId":1624042,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624042,"issued_at_tick":72124,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"bf4bf7352fca2f130ec1e7d852fc6c165082d33e72baab0d7f404f5231eb90f2"}} +{"type":"command.receipt","at":"2026-08-04T03:28:56.434Z","tick":72127,"actorId":"probe-1","commandId":1624043,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624043,"issued_at_tick":72127,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"f0a978e54bc1bc9c788c4309aee85d96ed4c33478a0b084ca5de9a32682d1d73"}} +{"type":"command.receipt","at":"2026-08-04T03:28:56.533Z","tick":72130,"actorId":"probe-1","commandId":1624044,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624044,"issued_at_tick":72130,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"c228c0e63a241c4a58c8300d24d5f0a2e03b4a0c5f1d943487ce937618722e69"}} +{"type":"command.receipt","at":"2026-08-04T03:28:56.634Z","tick":72133,"actorId":"probe-1","commandId":1624045,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624045,"issued_at_tick":72133,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"4ed917a855934a483ff5c5e5778b44fa2dc438ee80ad2b3e2d782162e9a6ae5a"}} +{"type":"command.receipt","at":"2026-08-04T03:28:56.734Z","tick":72136,"actorId":"probe-1","commandId":1624046,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624046,"issued_at_tick":72136,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"385cc4aa100e8937ece6d0129d2839c5564ee32a1afb04aaa6d97d32094f2254"}} +{"type":"command.receipt","at":"2026-08-04T03:28:56.834Z","tick":72139,"actorId":"probe-1","commandId":1624047,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624047,"issued_at_tick":72139,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"d471b62607cf605bf532cf2471a2f1a8f9adbcf407f62c19d16a7a5d126926b2"}} +{"type":"command.receipt","at":"2026-08-04T03:28:56.933Z","tick":72142,"actorId":"probe-1","commandId":1624048,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624048,"issued_at_tick":72142,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"ec6fc58b25097fb16c8d981c900c69e4f084d51912e47324621ad6b1f33cf0d7"}} +{"type":"command.receipt","at":"2026-08-04T03:28:57.035Z","tick":72145,"actorId":"probe-1","commandId":1624049,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624049,"issued_at_tick":72145,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"7bc35252e2741613fe27383ef64e100f489865e0c993704d12609b3038c808d4"}} +{"type":"command.receipt","at":"2026-08-04T03:28:57.134Z","tick":72148,"actorId":"probe-1","commandId":1624050,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624050,"issued_at_tick":72148,"command":{"SetMoveIntent":{"dx":0,"dy":-1,"facing":"Front","sprint":false}}},"session":4,"player":1,"stateHash":"b76d19f783938978dc8eb7088d4e629970bf1fc20682a42d09094e81e21c6577"}} +{"type":"command.receipt","at":"2026-08-04T03:28:57.203Z","tick":72151,"actorId":"probe-1","commandId":1624051,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624051,"issued_at_tick":72150,"command":{"SetMoveIntent":{"dx":0,"dy":0,"sprint":false}}},"session":4,"player":1,"stateHash":"d4f338b832b505d13867873b41c24728694abdf5a719340ef919bbc30a96b6ce"}} +{"type":"command.receipt","at":"2026-08-04T03:28:57.700Z","tick":72165,"actorId":"probe-1","commandId":1624052,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624052,"issued_at_tick":72165,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"aea086158f696a761ecedb74f0b45f80143437fa75717aefc47fc156b3088f9f"}} +{"type":"command.receipt","at":"2026-08-04T03:28:57.734Z","tick":72166,"actorId":"probe-1","commandId":1624053,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624053,"issued_at_tick":72166,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"b648bee6869f859bdc66580f4c1d1f02cb162549459d905d42ff6a2ad3899593"}} +{"type":"command.receipt","at":"2026-08-04T03:28:57.834Z","tick":72169,"actorId":"probe-1","commandId":1624054,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624054,"issued_at_tick":72169,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"6ffb0ba93dfaf4257e7c706bb5960dce12b8c8c7439dbaf0568be3dd517646fe"}} +{"type":"command.receipt","at":"2026-08-04T03:28:57.934Z","tick":72172,"actorId":"probe-1","commandId":1624055,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624055,"issued_at_tick":72172,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"9598dad43ae4d737d4b334bd6c07df9cf83bbb6bd9e403df6602c86bbff3605d"}} +{"type":"command.receipt","at":"2026-08-04T03:28:58.134Z","tick":72178,"actorId":"probe-1","commandId":1624057,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624057,"issued_at_tick":72178,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"441a64d1a2650e9f8f80bf33bcd123329aeefa1ade5964838a306b275420bdce"}} +{"type":"command.receipt","at":"2026-08-04T03:28:58.334Z","tick":72184,"actorId":"probe-1","commandId":1624059,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624059,"issued_at_tick":72184,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"91bbeba90944de3d91e0668a0970daa3a81fde754a5905d8d2637096aa38e3e2"}} +{"type":"command.receipt","at":"2026-08-04T03:28:58.533Z","tick":72190,"actorId":"probe-1","commandId":1624061,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624061,"issued_at_tick":72190,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"cf5b7faacf1588bc0480855ee5b6d35c6267186545ede40427b6ba8bf28352ab"}} +{"type":"command.receipt","at":"2026-08-04T03:28:58.733Z","tick":72196,"actorId":"probe-1","commandId":1624063,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624063,"issued_at_tick":72196,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"5498a738b25749773a08c29acac52d7ea3eca876cf44c7e66fdcf24eec39be1b"}} +{"type":"command.receipt","at":"2026-08-04T03:28:58.934Z","tick":72202,"actorId":"probe-1","commandId":1624065,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624065,"issued_at_tick":72202,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"6341a87ec011aa7eded592d66c5ed24eb93925438b74b3fdb7ca10d46d57fdaf"}} +{"type":"command.receipt","at":"2026-08-04T03:28:59.134Z","tick":72208,"actorId":"probe-1","commandId":1624067,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624067,"issued_at_tick":72208,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"1cb0f5053638a5ef44658a96527e67e99c1c22f8ba582ed029268f646d0f2897"}} +{"type":"command.receipt","at":"2026-08-04T03:28:59.717Z","tick":72226,"actorId":"probe-1","commandId":1624069,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624069,"issued_at_tick":72226,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":4,"player":1,"stateHash":"4ffbacc76d0191eaeffe23ee19c2e766fa722b7ef3cc94acb5e52f2f486d7c95"}} +{"type":"command.receipt","at":"2026-08-04T03:28:59.733Z","tick":72226,"actorId":"probe-1","commandId":1624070,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624070,"issued_at_tick":72226,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":4,"player":1,"stateHash":"4ffbacc76d0191eaeffe23ee19c2e766fa722b7ef3cc94acb5e52f2f486d7c95"}} +{"type":"command.receipt","at":"2026-08-04T03:28:59.834Z","tick":72229,"actorId":"probe-1","commandId":1624071,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624071,"issued_at_tick":72229,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":4,"player":1,"stateHash":"74b9fc82174bcee4a95f2cabfc22842afc195315e661547ecc66fa56fdd5b661"}} +{"type":"command.receipt","at":"2026-08-04T03:28:59.934Z","tick":72232,"actorId":"probe-1","commandId":1624072,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624072,"issued_at_tick":72232,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":4,"player":1,"stateHash":"845b89f1ed0c134d43626a887e5064c68b9bc515a704f977baa5b77d5594e851"}} +{"type":"checkpoint","at":"2026-08-04T03:29:00.042Z","tick":72236,"reason":"interval","actorCount":2,"stateHash":"b7a7bf0d58a0503d6125c822d74067a281e942a0c0a9b8880b5f7e792ef577ed"} +{"type":"command.receipt","at":"2026-08-04T03:29:00.168Z","tick":72239,"actorId":"probe-1","commandId":1624074,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624074,"issued_at_tick":72239,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":4,"player":1,"stateHash":"5309485e2fad25667fb56e55f45e082dcf85e618827aae43000bda1c96479663"}} +{"type":"command.receipt","at":"2026-08-04T03:29:00.367Z","tick":72245,"actorId":"probe-1","commandId":1624076,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624076,"issued_at_tick":72245,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":4,"player":1,"stateHash":"fa7a7f6572a6d9db389c43eee50d2ab76ec788a48c2a7b2524581e395f36172f"}} +{"type":"command.receipt","at":"2026-08-04T03:29:00.567Z","tick":72251,"actorId":"probe-1","commandId":1624078,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624078,"issued_at_tick":72251,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":4,"player":1,"stateHash":"aaf34a0c1e4296a7faa7097aebd031c8fb7d4f80088cb262901fcaccdce7981a"}} +{"type":"command.receipt","at":"2026-08-04T03:29:00.766Z","tick":72257,"actorId":"probe-1","commandId":1624080,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624080,"issued_at_tick":72257,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":4,"player":1,"stateHash":"a37f519084e5492bbe06265a14b9a335e7484f4f1c19d17763baf5af4d0b9b55"}} +{"type":"command.receipt","at":"2026-08-04T03:29:00.968Z","tick":72263,"actorId":"probe-1","commandId":1624082,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624082,"issued_at_tick":72263,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":4,"player":1,"stateHash":"c2d4cdcb2f3b11e2775449b745cffbbff1cac553197dbd5cb000fa728b28d0a0"}} +{"type":"command.receipt","at":"2026-08-04T03:29:01.167Z","tick":72269,"actorId":"probe-1","commandId":1624084,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624084,"issued_at_tick":72269,"command":{"SetMoveIntent":{"dx":-1,"dy":0,"facing":"Left","sprint":false}}},"session":4,"player":1,"stateHash":"0cc18c97eb2d34bb0319ec054218af67d11f4ea4e10dee23e69225a9b0b824b0"}} +{"type":"command.receipt","at":"2026-08-04T03:29:01.733Z","tick":72286,"actorId":"probe-1","commandId":1624086,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624086,"issued_at_tick":72286,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"b11b00b3d51fcff22ac2c109738e2f5ac18db8627b51a1ff5b42677f3ed9e666"}} +{"type":"command.receipt","at":"2026-08-04T03:29:01.766Z","tick":72287,"actorId":"probe-1","commandId":1624087,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624087,"issued_at_tick":72287,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"fc113abc9d5ab3d6f154bfb2e5f59205a2172dfa64d57259bb7fc017fbb841ff"}} +{"type":"command.receipt","at":"2026-08-04T03:29:01.867Z","tick":72290,"actorId":"probe-1","commandId":1624088,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624088,"issued_at_tick":72290,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"ddd6d25115a6b9c530b4c8eeb38bb1034196c9dc183b8cbdd398b8655dee0024"}} +{"type":"command.receipt","at":"2026-08-04T03:29:01.967Z","tick":72293,"actorId":"probe-1","commandId":1624089,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624089,"issued_at_tick":72293,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"a455e5baf7910074c101eee5c2195d8bf591d376602992a3c030f7d66719c64b"}} +{"type":"command.receipt","at":"2026-08-04T03:29:02.167Z","tick":72299,"actorId":"probe-1","commandId":1624091,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624091,"issued_at_tick":72299,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"ae66d61bc9c0b8bf8f69989eff0b3820f4a710da9f7bd4532a63afc6d02e0c9d"}} +{"type":"command.receipt","at":"2026-08-04T03:29:02.367Z","tick":72305,"actorId":"probe-1","commandId":1624093,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624093,"issued_at_tick":72305,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"ab3550998bc529b0dca72c4d052e7fd00af1a51570040645c4486a63fbc7950a"}} +{"type":"command.receipt","at":"2026-08-04T03:29:02.567Z","tick":72311,"actorId":"probe-1","commandId":1624095,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624095,"issued_at_tick":72311,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"eed797f25464f2eb88edbb20759bd522fb0f4cf5cacc384976e72a3b4ba5faff"}} +{"type":"command.receipt","at":"2026-08-04T03:29:02.767Z","tick":72317,"actorId":"probe-1","commandId":1624097,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624097,"issued_at_tick":72317,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"84173899842fb1584b2eb92b355fc203b712edb26407c43994b8cc154edf487f"}} +{"type":"command.receipt","at":"2026-08-04T03:29:02.967Z","tick":72323,"actorId":"probe-1","commandId":1624099,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624099,"issued_at_tick":72323,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"678e8da0080d7b69f786a9a85c3955e26166c3e2556930ff7d3c0988c24aa3d7"}} +{"type":"command.receipt","at":"2026-08-04T03:29:03.168Z","tick":72329,"actorId":"probe-1","commandId":1624101,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624101,"issued_at_tick":72329,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"8c4995b8737104dd814ac2d424c9d52496bf0abbfcec51d22bbff4225ec40462"}} +{"type":"checkpoint","at":"2026-08-04T03:29:05.044Z","tick":72386,"reason":"interval","actorCount":2,"stateHash":"972332339b2d1eb663533961b7e15529d2e1ced2ca840c0260127a1356bc34ec"} +{"type":"checkpoint","at":"2026-08-04T03:29:10.064Z","tick":72536,"reason":"interval","actorCount":2,"stateHash":"02e04f461b701a83e8f5cd4dca20b0897216c2dbeac6c7177161fbb48df3c784"} +{"type":"checkpoint","at":"2026-08-04T03:29:15.071Z","tick":72687,"reason":"interval","actorCount":2,"stateHash":"c33c9b92021405687eeeb47215513b94983ad75e2bfe05421356c8f00c996488"} +{"type":"checkpoint","at":"2026-08-04T03:29:20.086Z","tick":72837,"reason":"interval","actorCount":2,"stateHash":"3ee95537f51d063de0cb8ab5eaa6a3e12f9c3edef60c234baa5e52a2c354b6d2"} +{"type":"checkpoint","at":"2026-08-04T03:29:25.088Z","tick":72987,"reason":"interval","actorCount":2,"stateHash":"fbed79b147bf709b33c923af78ec8156a9a505b992f065dd8e2d318a891fbfd0"} +{"type":"checkpoint","at":"2026-08-04T03:29:30.096Z","tick":73138,"reason":"interval","actorCount":2,"stateHash":"1db613cb234cfcc9a1af7e2a79769bef144d4753c9a767cdb257bde78e7244d5"} +{"type":"checkpoint","at":"2026-08-04T03:29:35.115Z","tick":73288,"reason":"interval","actorCount":2,"stateHash":"53007cd058f936756c35c1d01fb558368bb9347de3805b4d73d808621c0354bf"} +{"type":"checkpoint","at":"2026-08-04T03:29:40.130Z","tick":73439,"reason":"interval","actorCount":2,"stateHash":"4f391a7e6931c9e65ffd95c37e3edfba51aa9008e83eef078a527782798062b7"} +{"type":"checkpoint","at":"2026-08-04T03:29:45.132Z","tick":73589,"reason":"interval","actorCount":2,"stateHash":"9f69336d8245546a61ea0416c72dba86097ff5d86effd13cf8113f0d4c121199"} +{"type":"command.receipt","at":"2026-08-04T03:29:47.744Z","tick":73667,"actorId":"probe-1","commandId":1624103,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624103,"issued_at_tick":73667,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"8267b39240267d820c04a271901af09725394adb1ef7b6e7a3dc9dfa74797ff7"}} +{"type":"command.receipt","at":"2026-08-04T03:29:47.862Z","tick":73671,"actorId":"probe-1","commandId":1624104,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624104,"issued_at_tick":73670,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"47174b4450da49352ff7556cc7ca90cc65ef300e41b8195941f71c9593304e16"}} +{"type":"command.receipt","at":"2026-08-04T03:29:47.994Z","tick":73674,"actorId":"probe-1","commandId":1624105,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624105,"issued_at_tick":73674,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"c2719ba5d009e5308c51f49bbfee9aab12c4c88ea94f7ebe00f3f79dd30e1382"}} +{"type":"command.receipt","at":"2026-08-04T03:29:48.093Z","tick":73677,"actorId":"probe-1","commandId":1624106,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624106,"issued_at_tick":73677,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"656adab834fe01384b5f87b2033020744c46c16d198a076abf9d789168f091ab"}} +{"type":"command.receipt","at":"2026-08-04T03:29:48.194Z","tick":73681,"actorId":"probe-1","commandId":1624107,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624107,"issued_at_tick":73680,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"c1de904006484157e514b5b49b338172ec2b9d92d967c21ccf096421749f1ad3"}} +{"type":"command.receipt","at":"2026-08-04T03:29:48.294Z","tick":73683,"actorId":"probe-1","commandId":1624108,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624108,"issued_at_tick":73683,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"b66cfcd0f64410fdf0a751807bea272b7f28bc4283ca38e777b467bf760ec60d"}} +{"type":"command.receipt","at":"2026-08-04T03:29:48.394Z","tick":73686,"actorId":"probe-1","commandId":1624109,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624109,"issued_at_tick":73686,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"1d81b80819e6993258bd590c9d3ab8cde5233fc4bbc2f23ee2925855b49f8485"}} +{"type":"command.receipt","at":"2026-08-04T03:29:48.494Z","tick":73689,"actorId":"probe-1","commandId":1624110,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624110,"issued_at_tick":73689,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"52d4d25f971bbf9c8170755c672a9c01ee010b11ca7b8919f385aaad5de07a90"}} +{"type":"command.receipt","at":"2026-08-04T03:29:48.594Z","tick":73693,"actorId":"probe-1","commandId":1624111,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624111,"issued_at_tick":73692,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"d5aaec13d8788cb0f1ed3f3ea345c488257fde85abbf4d676d6ddd3360e25926"}} +{"type":"command.receipt","at":"2026-08-04T03:29:48.710Z","tick":73696,"actorId":"probe-1","commandId":1624112,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624112,"issued_at_tick":73696,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"03882115090fc71f17f62b75111a995816c3d829a832b2398d0f0fd636c9880c"}} +{"type":"command.receipt","at":"2026-08-04T03:29:48.810Z","tick":73699,"actorId":"probe-1","commandId":1624113,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624113,"issued_at_tick":73699,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"bc3954aaf58ffd9d81bc2a2097e035fc3fb4a2b6753404189d1220d26aea1fb7"}} +{"type":"command.receipt","at":"2026-08-04T03:29:48.911Z","tick":73702,"actorId":"probe-1","commandId":1624114,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624114,"issued_at_tick":73702,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"56d92f139c472d9b684ff497cfb521260d186b03a4fee7a3fb2dfe7fbbc2a7f3"}} +{"type":"command.receipt","at":"2026-08-04T03:29:49.011Z","tick":73705,"actorId":"probe-1","commandId":1624115,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624115,"issued_at_tick":73705,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"4980fb6bde2f6015bed45522cc88e1a103484bcc6475fb801837ba6867b3f17c"}} +{"type":"command.receipt","at":"2026-08-04T03:29:49.111Z","tick":73708,"actorId":"probe-1","commandId":1624116,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624116,"issued_at_tick":73708,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"2588050d44e60daa5f77a4fc04e97447511b115c41c7c38cbddae6ff96f29fd9"}} +{"type":"command.receipt","at":"2026-08-04T03:29:49.210Z","tick":73711,"actorId":"probe-1","commandId":1624117,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624117,"issued_at_tick":73711,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"6303b9839de2953aa865644e4dd12d7b63e00535fa14be8732ef2f89e1510a46"}} +{"type":"command.receipt","at":"2026-08-04T03:29:49.311Z","tick":73714,"actorId":"probe-1","commandId":1624118,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624118,"issued_at_tick":73714,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"b4237b278670ada2fb912df5e13388c6aab9cc3f42a8a5f7ab5966fbdb73b4a8"}} +{"type":"command.receipt","at":"2026-08-04T03:29:49.445Z","tick":73718,"actorId":"probe-1","commandId":1624119,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624119,"issued_at_tick":73718,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"ed02eec0ffc47cdac6d15a2cf66517e6a87912bc4b0012fda3bc930ae2f1d2a4"}} +{"type":"command.receipt","at":"2026-08-04T03:29:49.578Z","tick":73722,"actorId":"probe-1","commandId":1624120,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624120,"issued_at_tick":73722,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"565fe2fb35f98e0cdfc3c5f25e9c807b6684c981f1db85baca7abe188d767c1e"}} +{"type":"command.receipt","at":"2026-08-04T03:29:49.745Z","tick":73727,"actorId":"probe-1","commandId":1624121,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624121,"issued_at_tick":73727,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"3c2367a397c77ef51603aed97fc7540abf3b64f63acc005ab00feec4d2b15911"}} +{"type":"command.receipt","at":"2026-08-04T03:29:49.927Z","tick":73732,"actorId":"probe-1","commandId":1624122,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624122,"issued_at_tick":73732,"command":{"SetMoveIntent":{"dx":0,"dy":1,"facing":"Back","sprint":false}}},"session":4,"player":1,"stateHash":"c2f9695e1a2801d19e66f8dd30c368a00745d7108ac2392bb19ac97b05ff987d"}} +{"type":"command.receipt","at":"2026-08-04T03:29:49.960Z","tick":73733,"actorId":"probe-1","commandId":1624123,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624123,"issued_at_tick":73733,"command":{"SetMoveIntent":{"dx":0,"dy":0,"sprint":false}}},"session":4,"player":1,"stateHash":"c63c450b23e00ab79a3cf72ba8d834c29d74b9242ecdbd9e009220ae9704a61d"}} +{"type":"checkpoint","at":"2026-08-04T03:29:50.140Z","tick":73739,"reason":"interval","actorCount":2,"stateHash":"99a0f6cd9b927cd98f8f06a053f16e3df41a770a4890bc5993182ce3da7194f1"} +{"type":"command.receipt","at":"2026-08-04T03:29:53.912Z","tick":73852,"actorId":"probe-1","commandId":1624124,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624124,"issued_at_tick":73852,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"4615e305d9e0a01adb1238f13f307754793b0715c3abc20352188708aeced4a0"}} +{"type":"command.receipt","at":"2026-08-04T03:29:54.047Z","tick":73856,"actorId":"probe-1","commandId":1624125,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624125,"issued_at_tick":73856,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"fb4a030c856584fc80b01de2597d0ec57ab35f2c4038db0c21a19aa55f774585"}} +{"type":"command.receipt","at":"2026-08-04T03:29:54.246Z","tick":73862,"actorId":"probe-1","commandId":1624126,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624126,"issued_at_tick":73862,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"5f755304f2f821c136e2be1ead4d89b78709671ed49169bd9dce361527030f0d"}} +{"type":"command.receipt","at":"2026-08-04T03:29:54.446Z","tick":73868,"actorId":"probe-1","commandId":1624127,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624127,"issued_at_tick":73868,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"c02c883f730be3a8683afa350f6f7cf6c55c60eb74b8896acf4c345a2f1f43f4"}} +{"type":"command.receipt","at":"2026-08-04T03:29:54.645Z","tick":73874,"actorId":"probe-1","commandId":1624128,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624128,"issued_at_tick":73874,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"ae6c18f915c39a3d16f78a3dbc04261775ba314e7779db648c4bbcbb62047c7c"}} +{"type":"command.receipt","at":"2026-08-04T03:29:54.829Z","tick":73880,"actorId":"probe-1","commandId":1624129,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624129,"issued_at_tick":73879,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"fc3522bb076b6b19fa57a41e2ceb32262ad31ec57d3be36b5550c132ee9f4c86"}} +{"type":"command.receipt","at":"2026-08-04T03:29:55.014Z","tick":73885,"actorId":"probe-1","commandId":1624130,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624130,"issued_at_tick":73885,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"a961fe2fb6b800a37eeb619ae0a8a2bbfdc58ed3dfbbfdd220c9e670882ab241"}} +{"type":"checkpoint","at":"2026-08-04T03:29:55.160Z","tick":73889,"reason":"interval","actorCount":2,"stateHash":"7ec2293aa54ba416eb42b32bfe7f185e45988db7ecc24558dd570dec3168adea"} +{"type":"command.receipt","at":"2026-08-04T03:29:55.214Z","tick":73891,"actorId":"probe-1","commandId":1624131,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624131,"issued_at_tick":73891,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"67420127d3533faf90cdc34145542ac77198de08816e84c1d8fe64699303f227"}} +{"type":"command.receipt","at":"2026-08-04T03:29:55.412Z","tick":73897,"actorId":"probe-1","commandId":1624132,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624132,"issued_at_tick":73897,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"99345de00ea427924fda4f9a0c18943500350a62613819a1c5c6e1089096eaf4"}} +{"type":"command.receipt","at":"2026-08-04T03:29:55.611Z","tick":73903,"actorId":"probe-1","commandId":1624133,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624133,"issued_at_tick":73903,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"7a7ba09c1a00ae69c15e86d63a50fa1817e2ec659ec871d7a80478a78489824a"}} +{"type":"command.receipt","at":"2026-08-04T03:29:55.811Z","tick":73909,"actorId":"probe-1","commandId":1624134,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624134,"issued_at_tick":73909,"command":{"SetMoveIntent":{"dx":1,"dy":0,"facing":"Right","sprint":false}}},"session":4,"player":1,"stateHash":"c8434bd00cade5587f193f2fae85d044245652f7efa24efc27d6ba4a1ea97350"}} +{"type":"command.receipt","at":"2026-08-04T03:29:55.930Z","tick":73913,"actorId":"probe-1","commandId":1624135,"commandKind":"SetMoveIntent","accepted":true,"eventIds":[],"rust":{"actorId":"probe-1","rustActorId":"player","envelope":{"session":1624,"player":1022207935,"command_id":1624135,"issued_at_tick":73913,"command":{"SetMoveIntent":{"dx":0,"dy":0,"sprint":false}}},"session":4,"player":1,"stateHash":"35cc8d6f2b4ac49204ea1f7e32778569771522c4014cabcef990e6a373ddea11"}} +{"type":"checkpoint","at":"2026-08-04T03:30:00.177Z","tick":74040,"reason":"interval","actorCount":2,"stateHash":"b58a2523701ffefa28dd2c2d1fe645064cf66618611424f79b566eb63b21ea1b"} +{"type":"checkpoint","at":"2026-08-04T03:30:05.193Z","tick":74190,"reason":"interval","actorCount":2,"stateHash":"5f7491f487c6d212ea30b9bd00e5049f6e391a20f0adcbffa9ccc0f315c51db9"} +{"type":"checkpoint","at":"2026-08-04T03:30:10.197Z","tick":74341,"reason":"interval","actorCount":2,"stateHash":"dabcb70866a9160ad65d6a9c68c9bd90dae97637bb21a8ab35b4bc286b117c89"} +{"type":"checkpoint","at":"2026-08-04T03:30:15.218Z","tick":74491,"reason":"interval","actorCount":2,"stateHash":"f7c06c0907da8b8960fe109af5d4632ee408370eff8b5417b22cd9b0404ee225"} +{"type":"checkpoint","at":"2026-08-04T03:30:20.226Z","tick":74641,"reason":"interval","actorCount":2,"stateHash":"c3771d685e9fe89a50a621aa59c981be5b92bcec537cd9730aeb1a36c3a4298a"} +{"type":"checkpoint","at":"2026-08-04T03:30:25.228Z","tick":74792,"reason":"interval","actorCount":2,"stateHash":"381b9af84bb6481b7d10d892e1dedf4059e3187250e053f0dad7efa3073587a7"} +{"type":"checkpoint","at":"2026-08-04T03:30:30.245Z","tick":74942,"reason":"interval","actorCount":2,"stateHash":"9f54e13623fd534eceb36347bbac6c2697afb9f0a68fa09d1e18c5bb86dd7799"} +{"type":"checkpoint","at":"2026-08-04T03:30:35.247Z","tick":75092,"reason":"interval","actorCount":2,"stateHash":"30c90667af71f56d4e377fcbdc8a0f962a75776b68717bae4242abc0504ed919"} +{"type":"checkpoint","at":"2026-08-04T03:30:40.260Z","tick":75242,"reason":"interval","actorCount":2,"stateHash":"edc4a499d9efda05c459b356ad66efd24a44fa21f93c5907ee95e9b86d4fd898"} +{"type":"checkpoint","at":"2026-08-04T03:30:45.268Z","tick":75393,"reason":"interval","actorCount":2,"stateHash":"09441624526dd7d2c6573b8bcda9947605a84c6191975c3d5491d4880d7b08fd"} +{"type":"checkpoint","at":"2026-08-04T03:30:50.283Z","tick":75543,"reason":"interval","actorCount":2,"stateHash":"75aa4e6252513e7a65da73b44a46f328f9b00656eba52263526a44b69e4adc4d"} +{"type":"checkpoint","at":"2026-08-04T03:30:55.296Z","tick":75694,"reason":"interval","actorCount":2,"stateHash":"549aaf26aec09ae620ebb1b38178426383e55e542f3e539c7c4fc310b17859c0"} +{"type":"checkpoint","at":"2026-08-04T03:31:00.304Z","tick":75844,"reason":"interval","actorCount":2,"stateHash":"46947c5f16b1a1323aaf144a8f7c8632677b3a0c28a38ad3d0d985a74ea3a8bb"} +{"type":"checkpoint","at":"2026-08-04T03:31:05.313Z","tick":75994,"reason":"interval","actorCount":2,"stateHash":"3584e6754197ead874a96c1e2a8747bd81b16963321cd7557da4eec3c6be362e"} +{"type":"checkpoint","at":"2026-08-04T03:31:10.328Z","tick":76145,"reason":"interval","actorCount":2,"stateHash":"8beaf0485e19e76c7bd521b40e2b51d8cc89b3f3bf7122e5e31fc9cc78928dd4"} +{"type":"checkpoint","at":"2026-08-04T03:31:15.350Z","tick":76295,"reason":"interval","actorCount":2,"stateHash":"3915d71288427fb3f13f9d84e7f8fbd5424f9afcba443fa5b642b5a68f3eca18"} +{"type":"checkpoint","at":"2026-08-04T03:31:20.362Z","tick":76446,"reason":"interval","actorCount":2,"stateHash":"7378b0c2346c58bc206b6890a3b5fb27ce71d15b1542447ea2f028c8d3b0fe8b"} +{"type":"checkpoint","at":"2026-08-04T03:31:25.366Z","tick":76596,"reason":"interval","actorCount":2,"stateHash":"35286fe8acdd192f46bfbcbfac8b60dcc45dae25d62c0486ab4c353b805580aa"} +{"type":"checkpoint","at":"2026-08-04T03:31:30.376Z","tick":76746,"reason":"interval","actorCount":2,"stateHash":"c4cdde00f9d697387b6b5566a9d362094780d48a0400e56c40c1887f4827d534"} +{"type":"checkpoint","at":"2026-08-04T03:31:35.381Z","tick":76896,"reason":"interval","actorCount":2,"stateHash":"838d622500458a35d7a1365d3594b113c7ba3ac34982714bed8a06c3ee4ac1b2"} +{"type":"checkpoint","at":"2026-08-04T03:31:40.387Z","tick":77046,"reason":"interval","actorCount":2,"stateHash":"f996ad17737c6981918c0b9a00c735da6e0b2752c0b4d8ceec851c37857c4f54"} +{"type":"checkpoint","at":"2026-08-04T03:31:45.396Z","tick":77197,"reason":"interval","actorCount":2,"stateHash":"9426b1ff23e46d2e7e9e2873195d4fbf7a6e34019c9bc388cbfec9784fb72b06"} +{"type":"checkpoint","at":"2026-08-04T03:31:50.403Z","tick":77347,"reason":"interval","actorCount":2,"stateHash":"216dc98b569ac5e6acd92d1e00348c0f5adf788f66198398017d69967d0d3371"} +{"type":"checkpoint","at":"2026-08-04T03:31:55.411Z","tick":77497,"reason":"interval","actorCount":2,"stateHash":"c8f99d39de1dcc5e88771c9e3d4d8d0a9f688daedb253e7ca47752a680d6d33b"} +{"type":"checkpoint","at":"2026-08-04T03:32:00.422Z","tick":77647,"reason":"interval","actorCount":2,"stateHash":"d3d9d04b87cb183c6facfe2d21c4318ac7bce0a16d8eb241aef4b6927011b13d"} +{"type":"checkpoint","at":"2026-08-04T03:32:05.430Z","tick":77798,"reason":"interval","actorCount":2,"stateHash":"32d0ca7b562ee6efcc1a74ea545cae114d913375c04af981a065ab8ce83f2372"} +{"type":"checkpoint","at":"2026-08-04T03:32:10.449Z","tick":77948,"reason":"interval","actorCount":2,"stateHash":"e2b3a3e5e5efdd577736ccff109c7e2c99f838a0881e4807771cec13758aff8d"} +{"type":"checkpoint","at":"2026-08-04T03:32:15.458Z","tick":78098,"reason":"interval","actorCount":2,"stateHash":"729bc9b54ea2a480906337c3d342f6c4109b2267d1eab7650ac2dc2b55e5b22f"} +{"type":"checkpoint","at":"2026-08-04T03:32:20.465Z","tick":78249,"reason":"interval","actorCount":2,"stateHash":"1cc8e98f383a4c2d56415e36e55b166264c2ded85176302913cb4f86761f23d1"} +{"type":"checkpoint","at":"2026-08-04T03:32:25.484Z","tick":78399,"reason":"interval","actorCount":2,"stateHash":"fa2b198b30e24c7a7370686472cc4c8743b6818f3ea97472a2d9b3b1aa348592"} +{"type":"checkpoint","at":"2026-08-04T03:32:30.496Z","tick":78550,"reason":"interval","actorCount":2,"stateHash":"18c34da3d826e3ebee60938c8dfea1c890896a3bbcd452c9e40eda0656561d21"} +{"type":"checkpoint","at":"2026-08-04T03:32:35.502Z","tick":78700,"reason":"interval","actorCount":2,"stateHash":"9055b99d48928ede7905b62c6517cb1146b3ce2009d40fcaa7fe0b5f313c5afb"} +{"type":"checkpoint","at":"2026-08-04T03:32:40.508Z","tick":78850,"reason":"interval","actorCount":2,"stateHash":"2fa647495e2cd20d1b4b12229f0a916503635e2923dfe9ed133a6d31f6921973"} +{"type":"checkpoint","at":"2026-08-04T03:32:45.510Z","tick":79000,"reason":"interval","actorCount":2,"stateHash":"42d9e4b47bdb4e26b2333e85377ab30376ddd8a7b7f99d5d183ee8d4c43273c0"} +{"type":"session.disconnect","at":"2026-08-04T03:32:46.579Z","tick":79032,"sessionId":"g_4","actorId":"probe-1","removedTransientActor":true} +{"type":"session.connect","at":"2026-08-04T03:32:48.683Z","tick":79032,"sessionId":"g_5","actorId":"probe-1","playerId":"probe-1","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T03:32:50.529Z","tick":79088,"reason":"interval","actorCount":2,"stateHash":"8fd0b10308e99c3136895644f572b0cca842edf8144bdc00fb1cb9a148569efd"} +{"type":"checkpoint","at":"2026-08-04T03:32:55.544Z","tick":79238,"reason":"interval","actorCount":2,"stateHash":"bf6790f1b7b63aec5d733158458467a8130b7f638e5a6d7633a1a3781ff5c463"} +{"type":"session.disconnect","at":"2026-08-04T03:32:55.666Z","tick":79242,"sessionId":"g_5","actorId":"probe-1","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T03:33:00.562Z","tick":79242,"reason":"interval","actorCount":2,"stateHash":"0237018beb0cdbcb7bf4aaf8c6e506366d009b1f32bb387e55c97d31e0ce8d18"} +{"type":"session.connect","at":"2026-08-04T03:54:25.078Z","tick":79242,"sessionId":"g_1","actorId":"gp-1","playerId":"gp-1","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T03:54:25.101Z","tick":79243,"reason":"interval","actorCount":2,"stateHash":"2ac92f9692614945c80f6e87f639911998a5aa509f43c0e6f7096b600f05128f"} +{"type":"checkpoint","at":"2026-08-04T03:54:30.105Z","tick":79393,"reason":"interval","actorCount":2,"stateHash":"33fe162f35ee60a0cd06916218b2bc42a3a581429b7ca2543ba4d70f4f7f6415"} +{"type":"session.disconnect","at":"2026-08-04T03:54:32.703Z","tick":79471,"sessionId":"g_1","actorId":"gp-1","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T03:54:35.117Z","tick":79471,"reason":"interval","actorCount":2,"stateHash":"7798f622ba652f0d1fc4e0e4e5a5191d088305fdbd911f0fffb6106dca6f7e32"} +{"type":"session.connect","at":"2026-08-04T03:56:47.500Z","tick":79471,"sessionId":"g_2","actorId":"shot-1","playerId":"shot-1","zoneId":"open-desert"} +{"type":"checkpoint","at":"2026-08-04T03:56:47.511Z","tick":79471,"reason":"interval","actorCount":2,"stateHash":"4a56c983e261f9d5bcd214d7dc722740928959d6e955e50d19b0944358323590"} +{"type":"checkpoint","at":"2026-08-04T03:56:52.525Z","tick":79622,"reason":"interval","actorCount":2,"stateHash":"4b9b5dbd904fe2e7eddb194e58ac17f0b981d217dd6ad0a978fc2849dd5f0222"} +{"type":"session.disconnect","at":"2026-08-04T03:56:53.726Z","tick":79658,"sessionId":"g_2","actorId":"shot-1","removedTransientActor":true} +{"type":"checkpoint","at":"2026-08-04T03:56:57.541Z","tick":79658,"reason":"interval","actorCount":2,"stateHash":"bee77e3f1909750cc065c0a63eb3c7cf4edcc8401e73c15e9b03ef9ec3e65214"} diff --git a/server/src/alpha/control-store.ts b/server/src/alpha/control-store.ts index 732cf7b7..0a9ddfb5 100644 --- a/server/src/alpha/control-store.ts +++ b/server/src/alpha/control-store.ts @@ -702,11 +702,12 @@ export class AlphaControlStore { } createBugReport(input: PersistBugReportInput): PersistedBugReport { - const body = input.body - .trim() - // Intentionally strip C0 controls except tab/newline/carriage-return, plus DEL. - // eslint-disable-next-line no-control-regex - .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, ""); + const body = [...input.body.trim()] + .filter((character) => { + const code = character.charCodeAt(0); + return code === 9 || code === 10 || code === 13 || code > 31 && code !== 127; + }) + .join(""); const validId = (value: string, max: number): boolean => { const bytes = Buffer.byteLength(value, "utf8"); return bytes >= 1 && bytes <= max; diff --git a/server/src/auth/runtime.ts b/server/src/auth/runtime.ts index 3d912739..4c304a78 100644 --- a/server/src/auth/runtime.ts +++ b/server/src/auth/runtime.ts @@ -7,6 +7,7 @@ export interface RuntimeAuthConfig { readonly shardId: string; readonly clientReleaseId: string; readonly serverReleaseId: string; + readonly acceptedClientReleaseIds?: readonly string[]; readonly issuer: string; /** Exact HTTPS storefront origin required by standalone HTTP cookie/API admission. */ readonly origin?: string; @@ -134,9 +135,20 @@ export function runtimeAuthConfigFromEnv(env: NodeJS.ProcessEnv = process.env): const serverReleaseId = required(env.SUCCESSOR_SERVER_RELEASE_ID ?? env.SUCCESSOR_RELEASE_ID ?? "dev", "SUCCESSOR_SERVER_RELEASE_ID"); const issuer = required(env.SUCCESSOR_LAUNCH_ISSUER ?? "successor-server", "SUCCESSOR_LAUNCH_ISSUER"); if (mode === "legacy") return { mode, shardId, clientReleaseId, serverReleaseId, issuer }; + const acceptedClientReleaseIds = (env.SUCCESSOR_ALPHA_CLIENT_RELEASE_ALLOWLIST ?? clientReleaseId) + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + if ( + acceptedClientReleaseIds.length === 0 + || !acceptedClientReleaseIds.includes(clientReleaseId) + || acceptedClientReleaseIds.some((value) => !/^[A-Za-z0-9][A-Za-z0-9._@-]{0,127}$/u.test(value)) + ) { + throw new Error("SUCCESSOR_ALPHA_CLIENT_RELEASE_ALLOWLIST must contain the default client release and only valid release ids"); + } const controlDbPath = required(env.ALPHA_CONTROL_DB_PATH, "ALPHA_CONTROL_DB_PATH"); const claimSecret = parseSecret(required(env.ALPHA_CONTROL_CLAIM_SECRET, "ALPHA_CONTROL_CLAIM_SECRET")); const origin = requiredStandaloneOrigin(env.SUCCESSOR_ALPHA_ORIGIN, "SUCCESSOR_ALPHA_ORIGIN"); const clientOrigin = requiredStandaloneOrigin(env.SUCCESSOR_ALPHA_CLIENT_ORIGIN, "SUCCESSOR_ALPHA_CLIENT_ORIGIN"); - return { mode, shardId, clientReleaseId, serverReleaseId, issuer, origin, clientOrigin, controlDbPath, claimSecret }; + return { mode, shardId, clientReleaseId, acceptedClientReleaseIds, serverReleaseId, issuer, origin, clientOrigin, controlDbPath, claimSecret }; } diff --git a/server/src/auth/standalone.test.ts b/server/src/auth/standalone.test.ts index 55fddf45..7a5dea3a 100644 --- a/server/src/auth/standalone.test.ts +++ b/server/src/auth/standalone.test.ts @@ -19,7 +19,7 @@ function character(worldEntryClaimed: boolean): CharacterRecord { id: "char-1", ownerRef: "owner-1", name: "Atlas", - appearance: { skinTone: "#c78f62", hair: "hair_mop", hairMat: "hair_raven", face: null }, + appearance: { body: "female", skinTone: "#c78f62", hair: "hair_mop", hairMat: "hair_raven", face: null }, worn: [{ item: "under_bodysuit", colors: ["#89cff0"] }], wornColors: { under_bodysuit: ["#89cff0"] }, position: { areaId: "waterworks", x: 4, y: 5, facing: "right" }, @@ -46,6 +46,7 @@ describe("standalone launch identity hydration", () => { characterId: "char-1", returningCharacter: false, appearance: { skin: "#c78f62", hair: "hair_mop", hair_mat: "hair_raven" }, + sprite: "adventurer-premium-female", worn: [{ item: "under_bodysuit", colors: ["#89cff0"] }], professionIds: ["scout"], skillBoxIds: ["scout-novice"], diff --git a/server/src/auth/standalone.ts b/server/src/auth/standalone.ts index b89848b6..bbbb0706 100644 --- a/server/src/auth/standalone.ts +++ b/server/src/auth/standalone.ts @@ -1,5 +1,5 @@ import type { LaunchProvenance, LaunchPurpose, RedeemedLaunch } from "../alpha/index.js"; -import type { CharacterRecord, CharacterStore } from "../game/characterStore.js"; +import { characterSpriteForAppearance, type CharacterRecord, type CharacterStore } from "../game/characterStore.js"; import { characterAuthoritySeed } from "../game/characterAuthoritySeed.js"; import type { GameActorAppearanceSnapshot, GameActorSnapshot, GameActorVitals, GameActorWornPiece } from "../game/protocol.js"; import type { ChatSessionIdentity } from "../chat/hub.js"; @@ -13,6 +13,7 @@ export interface StandaloneLaunchIdentity extends ChatSessionIdentity { readonly characterId: string; readonly returningCharacter: boolean; readonly appearance: GameActorAppearanceSnapshot; + readonly sprite: string; readonly worn: GameActorWornPiece[]; readonly wornColors: Record; readonly professionIds: string[]; @@ -53,14 +54,19 @@ export async function redeemStandaloneLaunch( purpose: LaunchPurpose, controlStore: StandaloneLaunchStore, characterStore: CharacterStore, - config: Pick, + config: Pick, isCharacterIdReserved?: (characterId: string) => boolean, + presentedClientReleaseId = config.clientReleaseId, ): Promise { + const acceptedReleases = config.acceptedClientReleaseIds ?? [config.clientReleaseId]; + if (!acceptedReleases.includes(presentedClientReleaseId)) { + throw new Error("client release is not accepted"); + } const launch = await controlStore.redeemCapability({ token, purpose, shardId: config.shardId, - clientReleaseId: config.clientReleaseId, + clientReleaseId: presentedClientReleaseId, serverReleaseId: config.serverReleaseId, issuer: config.issuer, }); @@ -101,6 +107,7 @@ export function standaloneIdentity(launch: RedeemedLaunch, character: CharacterR characterId: character.id, returningCharacter: character.worldEntryClaimed, appearance, + sprite: characterSpriteForAppearance(character.appearance), worn: character.worn.map((entry) => ({ item: entry.item, colors: [...entry.colors] })), wornColors: Object.fromEntries(Object.entries(character.wornColors).map(([item, colors]) => [item, [...colors]])), professionIds: authoritySeed.professionIds ?? [], diff --git a/server/src/chat/hub.test.ts b/server/src/chat/hub.test.ts index 4f258b47..727bb7d6 100644 --- a/server/src/chat/hub.test.ts +++ b/server/src/chat/hub.test.ts @@ -197,6 +197,50 @@ describe("ChatHub websocket routes", () => { ).toBe(false); }); + it("names a sender from the live shard when the client sends no display name", () => { + const hub = new ChatHub({ + sendHelloOnConnect: false, + nameAuthority: { displayNameForActor: (id) => (id === "char_grug" ? "Grugtest" : null) }, + }); + const socket = new FakeChatSocket(); + const session = hub.connect(socket, { userId: "char_grug", displayName: "", zoneId: "open-desert" }); + + hub.handlePacketForTest(session.id, { type: "chat.send", channel: "local", body: "hello" }); + + const line = socket.messages.find( + (packet) => packet.type === "chat.message" && packet.message.body === "hello", + ); + expect(line?.type).toBe("chat.message"); + if (line?.type === "chat.message") { + expect(line.message.sender.displayName).toBe("Grugtest"); + expect(line.message.sender.id).toBe("char_grug"); + } + }); + + it("upgrades a session named after its raw id once the actor reaches the shard", () => { + let resident = false; + const hub = new ChatHub({ + sendHelloOnConnect: false, + nameAuthority: { displayNameForActor: () => (resident ? "Grugtest" : null) }, + }); + const socket = new FakeChatSocket(); + // Chat can open before the actor exists, which is when the raw id leaks. + const session = hub.connect(socket, { userId: "char_grug", displayName: "", zoneId: "open-desert" }); + + hub.handlePacketForTest(session.id, { type: "chat.send", channel: "local", body: "early" }); + resident = true; + hub.handlePacketForTest(session.id, { type: "chat.send", channel: "local", body: "later" }); + + const named = (body: string) => { + const packet = socket.messages.find( + (entry) => entry.type === "chat.message" && entry.message.body === body, + ); + return packet?.type === "chat.message" ? packet.message.sender.displayName : ""; + }; + expect(named("early")).toBe("char_grug"); + expect(named("later")).toBe("Grugtest"); + }); + it("keeps directed presence character-scoped and masks ignored viewers without stale state", () => { const dir = mkdtempSync(path.join(os.tmpdir(), "successor-chat-social-")); try { diff --git a/server/src/chat/hub.ts b/server/src/chat/hub.ts index 0ee8e796..0d09951a 100644 --- a/server/src/chat/hub.ts +++ b/server/src/chat/hub.ts @@ -43,6 +43,8 @@ export interface ChatSocket { export interface ChatSessionIdentity { userId: string; + /** May be empty: the hub then names the session from the live shard, then + * the character store, and only falls back to the raw id if neither knows. */ displayName: string; zoneId: string; partyId?: string; @@ -76,6 +78,16 @@ export interface ChatSpatialAuthority { positionForActor(actorId: string): ChatSpatialPosition | null; } +/** + * Live authority display-name lookup. + * + * A client may open its chat socket before its actor exists on the shard, so + * the name is resolved again on the first message rather than only at connect. + */ +export interface ChatNameAuthority { + displayNameForActor(actorId: string): string | null; +} + export interface ChatDevOnlyGuildIdentity { /** Explicitly test-only; never populated from a redeemed launch ticket. */ devOnlyGuildId?: string; @@ -121,6 +133,7 @@ export interface ChatHubOptions { social?: ChatSocialAdapter; guildAuthority?: ChatGuildAuthority; spatialAuthority?: ChatSpatialAuthority; + nameAuthority?: ChatNameAuthority; localRadiusCells?: number; sendHelloOnConnect?: boolean; sessionRevocations?: LaunchSessionRevocationSink; @@ -174,6 +187,7 @@ export class ChatHub { private readonly social?: ChatSocialAdapter; private readonly guildAuthority?: ChatGuildAuthority; private readonly spatialAuthority?: ChatSpatialAuthority; + private readonly nameAuthority?: ChatNameAuthority; private readonly loadedSocialUsers = new Set(); private readonly friendWatchersByUser = new Map>(); private readonly history = new Map(); @@ -210,6 +224,7 @@ export class ChatHub { this.social = options.social; this.guildAuthority = options.guildAuthority; this.spatialAuthority = options.spatialAuthority; + this.nameAuthority = options.nameAuthority; this.localRadiusCells = finitePositive(options.localRadiusCells, defaultLocalRadiusCells); this.sendHelloOnConnect = options.sendHelloOnConnect ?? true; this.sessionRevocations = options.sessionRevocations; @@ -239,7 +254,12 @@ export class ChatHub { throw new Error("too many sessions for user"); } - const displayName = normalizeDisplayName(identity.displayName, userId); + // A client that does not carry a name in its handshake still has one in the + // character store, and the hub already reads that store to name whisper and + // friend targets. Without this the fallback is the raw character id, so + // every line the player sends is attributed to `char_0254efc180a54b6a`. + const claimedName = identity.displayName || this.resolveDisplayName(userId); + const displayName = normalizeDisplayName(claimedName, userId); const session: ChatSession = { id: `s_${this.nextSessionSeq++}`, socket, @@ -477,7 +497,7 @@ export class ChatHub { this.error(session, recipients.code, recipients.message, requestId); return; } - const message = this.makeMessage(channel, session.user, moderation.body, this.currentAreaId(session), targetId); + const message = this.makeMessage(channel, this.senderFor(session), moderation.body, this.currentAreaId(session), targetId); this.rememberHistory(message); this.broadcast(recipients.recipients, { type: "chat.message", message }); this.counters.messagesRouted += 1; @@ -845,6 +865,37 @@ export class ChatHub { return this.social?.resolveCharacter(value) ?? null; } + /** + * Best display name for a character id, or "" when nothing knows one. + * + * The live shard is asked first: it holds the name the world is already + * showing over the player's head. The character store is the durable + * fallback for a session whose actor is not resident. + */ + private resolveDisplayName(userId: string): string { + return this.nameAuthority?.displayNameForActor(userId) + || this.resolveTarget(userId)?.name + || ""; + } + + /** + * The sender stamped on an outgoing message. + * + * A client may open its chat socket before its actor exists on the shard, in + * which case connect had nothing to resolve and fell back to the raw id. + * Retrying on the first message that still carries that fallback upgrades the + * session once, so a player never spends a session named `char_...`. + */ + private senderFor(session: ChatSession): ChatUser { + if (session.user.displayName !== session.user.id) return session.user; + const resolved = this.resolveDisplayName(session.user.id); + if (!resolved) return session.user; + const displayName = normalizeDisplayName(resolved, session.user.id); + session.user = { ...session.user, displayName }; + this.displayNames.set(session.user.id, displayName); + return session.user; + } + private persistSocial(characterId: string, targetId: string, relation: CharacterSocialRelation): boolean { return this.social?.saveSocialContact(characterId, targetId, relation).ok ?? true; diff --git a/server/src/chat/routes.ts b/server/src/chat/routes.ts index 46726a27..35bdb5f2 100644 --- a/server/src/chat/routes.ts +++ b/server/src/chat/routes.ts @@ -13,6 +13,7 @@ import { ChatHub, type ChatSocket } from "./hub.js"; const standaloneAuthenticateSchema = z.object({ type: z.literal("chat.authenticate"), chatTicket: z.string().min(32).max(256), + release: z.string().trim().min(1).max(128), }).strict(); const CHAT_AUTH_FRAME_MAX_BYTES = 1_024; @@ -55,7 +56,10 @@ export async function registerChatRoutes(app: FastifyInstance, options: ChatRout } if (!devIdentityAllowed()) { socket.close(1008, "session ticket required"); return; } const userId = normalizeUserId(query.playerId ?? query.userId ?? ""); - const displayName = normalizeDisplayName(query.displayName ?? "", userId || "Guest"); + // Empty fallback on purpose. Defaulting to the user id here would hand the + // hub a name that looks supplied, so its live-shard and character-store + // lookups would never run and every line would read `char_...`. + const displayName = normalizeDisplayName(query.displayName ?? "", ""); const zoneId = normalizeUserId(query.zoneId ?? query.zone ?? "open-desert") || "open-desert"; const partyId = query.partyId ? normalizeUserId(query.partyId) : undefined; const devOnlyGuildId = query.guildId ? normalizeUserId(query.guildId) : undefined; @@ -87,7 +91,7 @@ async function authenticateStandaloneSocket( const parsed = standaloneAuthenticateSchema.safeParse(parseFrame(data)); if (!parsed.success) { socket.close(1008, "chat authentication required"); return; } try { - const identity = await redeemStandaloneLaunch(parsed.data.chatTicket, "chat", controlStore, characterStore, runtimeAuth); + const identity = await redeemStandaloneLaunch(parsed.data.chatTicket, "chat", controlStore, characterStore, runtimeAuth, undefined, parsed.data.release); // The URL/query was intentionally never consulted; only the bounded // first frame supplies the one-use chat capability. const hubIdentity = { ...identity, userId: normalizeUserId(identity.characterId) }; diff --git a/server/src/game/characterStore.test.ts b/server/src/game/characterStore.test.ts index 71089ecb..4ce322d9 100644 --- a/server/src/game/characterStore.test.ts +++ b/server/src/game/characterStore.test.ts @@ -21,6 +21,7 @@ import { FACE_BROW_COLORS, FACE_EYE_COLORS, FACE_LIP_COLORS } from "./face.gen.j import type { GameActorSnapshot } from "./protocol.js"; const appearance = { + body: "male" as const, skinTone: "#aabbcc", hair: "hair_mop" as const, hairMat: "hair_raven", @@ -699,14 +700,14 @@ describe("CharacterStore", () => { }); describe("character appearance (hair is an appearance property, not an item)", () => { it("accepts any well-formed hair style id — validation is a pattern, not a fixed enum", () => { - const normalized = normalizeCharacterAppearance({ skinTone: "#aabbcc", hair: "hair_ponytail_long", hairMat: "hair_silver", face: null }); - expect(normalized).toEqual({ skinTone: "#aabbcc", hair: "hair_ponytail_long", hairMat: "hair_silver", face: null }); + const normalized = normalizeCharacterAppearance({ body: "female", skinTone: "#aabbcc", hair: "hair_ponytail_long", hairMat: "hair_silver", face: null }); + expect(normalized).toEqual({ body: "female", skinTone: "#aabbcc", hair: "hair_ponytail_long", hairMat: "hair_silver", face: null }); }); it("uses explicit null for bald and rejects an empty current hair field", () => { - const bald = normalizeCharacterAppearance({ skinTone: "#aabbcc", hair: null, hairMat: "hair_raven", face: null }); - expect(bald).toEqual({ skinTone: "#aabbcc", hair: null, hairMat: "hair_raven", face: null }); - expect(normalizeCharacterAppearance({ skinTone: "#aabbcc", hair: "", hairMat: "hair_raven", face: null })).toBeNull(); + const bald = normalizeCharacterAppearance({ body: "male", skinTone: "#aabbcc", hair: null, hairMat: "hair_raven", face: null }); + expect(bald).toEqual({ body: "male", skinTone: "#aabbcc", hair: null, hairMat: "hair_raven", face: null }); + expect(normalizeCharacterAppearance({ body: "male", skinTone: "#aabbcc", hair: "", hairMat: "hair_raven", face: null })).toBeNull(); expect(characterAppearanceToActorAppearance(bald!)).toEqual({ skin: "#aabbcc", hair: null, hair_mat: "hair_raven", face: null }); }); @@ -715,13 +716,18 @@ describe("character appearance (hair is an appearance property, not an item)", ( expect(normalizeCharacterAppearance({ skinTone: "#aabbcc", hair: 42, hairMat: "hair_raven", face: null })).toBeNull(); }); - it("round-trips a created character's hair through the store and back to actor appearance", () => withStore((store) => { - const created = store.create({ name: "Coily", appearance: { skinTone: "#4a3223", hair: "hair_crop2", hairMat: "hair_chestnut", face: null } }); + it("round-trips a created character's body and hair through the store and actor identity", () => withStore((store) => { + const created = store.create({ name: "Coily", appearance: { body: "female", skinTone: "#4a3223", hair: "hair_crop2", hairMat: "hair_chestnut", face: null } }); if (!created.ok) throw new Error("expected create to succeed"); - expect(created.record.appearance).toEqual({ skinTone: "#4a3223", hair: "hair_crop2", hairMat: "hair_chestnut", face: null }); + expect(created.record.appearance).toEqual({ body: "female", skinTone: "#4a3223", hair: "hair_crop2", hairMat: "hair_chestnut", face: null }); expect(characterAppearanceToActorAppearance(created.record.appearance)).toEqual({ skin: "#4a3223", hair: "hair_crop2", hair_mat: "hair_chestnut", face: null }); - expect(defaultCharacterAppearance().hair).toBe("hair_mop"); + expect(defaultCharacterAppearance()).toMatchObject({ body: "male", hair: "hair_mop" }); })); + + it("defaults pre-body records to type 1 and rejects unknown body ids", () => { + expect(normalizeCharacterAppearance({ skinTone: "#aabbcc", hair: null, hairMat: "hair_raven", face: null })?.body).toBe("male"); + expect(normalizeCharacterAppearance({ body: "other", skinTone: "#aabbcc", hair: null, hairMat: "hair_raven", face: null })).toBeNull(); + }); }); describe("character face (face-kit selection validated against the generated registry)", () => { diff --git a/server/src/game/characterStore.ts b/server/src/game/characterStore.ts index 4bd678f7..cbb51cf9 100644 --- a/server/src/game/characterStore.ts +++ b/server/src/game/characterStore.ts @@ -31,7 +31,10 @@ const initialProfessionIdSet = new Set(initialProfessionIds); * material id. Style ids are pattern-validated, NOT an enum — new hairs added to * the pawn pack + creation picker work without touching this file. */ +export type CharacterBody = "male" | "female"; + export interface CharacterAppearance { + body: CharacterBody; skinTone: string; hair: string | null; hairMat: string; @@ -39,6 +42,10 @@ export interface CharacterAppearance { face: CharacterFaceConfig | null; } +export function characterSpriteForAppearance(appearance: CharacterAppearance): string { + return appearance.body === "female" ? "adventurer-premium-female" : "adventurer-premium-male"; +} + export interface CharacterWornEntry { item: string; colors: string[]; @@ -243,6 +250,7 @@ const defaultSkinTone = "#c78f62"; const defaultHairStyleId = "hair_mop"; const defaultHairMat = "hair_raven"; const defaultAppearance: CharacterAppearance = { + body: "male", skinTone: defaultSkinTone, hair: defaultHairStyleId, hairMat: defaultHairMat, @@ -807,6 +815,11 @@ export function normalizeCharacterAppearance(value: unknown): CharacterAppearanc if (!value || typeof value !== "object" || Array.isArray(value)) return null; const input = value as Record; if (!["skinTone", "hair", "hairMat", "face"].every((key) => Object.prototype.hasOwnProperty.call(input, key))) return null; + const body = input.body === undefined + ? "male" + : input.body === "male" || input.body === "female" + ? input.body + : null; const rawSkinTone = typeof input.skinTone === "string" ? input.skinTone.trim().toLowerCase() : ""; const skinTone = skinTonePattern.test(rawSkinTone) ? rawSkinTone : null; const rawHair = input.hair; @@ -817,8 +830,8 @@ export function normalizeCharacterAppearance(value: unknown): CharacterAppearanc : undefined; const hairMat = typeof input.hairMat === "string" ? input.hairMat.trim() : ""; const face = normalizeCharacterFaceAppearance(input.face); - if (!skinTone || hair === undefined || face === undefined || !hairIdentifierPattern.test(hairMat)) return null; - return { skinTone, hair, hairMat, face }; + if (!body || !skinTone || hair === undefined || face === undefined || !hairIdentifierPattern.test(hairMat)) return null; + return { body, skinTone, hair, hairMat, face }; } export function defaultCharacterAppearance(): CharacterAppearance { @@ -847,6 +860,7 @@ export function characterAppearanceToActorAppearance(appearance: CharacterAppear export function actorAppearanceToCharacterAppearance(appearance: GameActorAppearanceSnapshot | undefined): CharacterAppearance { if (!appearance) return defaultCharacterAppearance(); return normalizeCharacterAppearance({ + body: "male", skinTone: appearance.skin, hair: appearance.hair, hairMat: appearance.hair_mat, diff --git a/server/src/game/characterStore.worn.test.ts b/server/src/game/characterStore.worn.test.ts index 6b3a07b4..3621d2c8 100644 --- a/server/src/game/characterStore.worn.test.ts +++ b/server/src/game/characterStore.worn.test.ts @@ -14,6 +14,7 @@ import { WARDROBE_PALETTES, WARDROBE_PIECES } from "./wardrobe.gen.js"; */ const appearance = { + body: "male" as const, skinTone: "#aabbcc", hair: "hair_mop" as const, hairMat: "hair_raven", diff --git a/server/src/game/colyseusRoom.identity.test.ts b/server/src/game/colyseusRoom.identity.test.ts index 30b6e250..fd31085e 100644 --- a/server/src/game/colyseusRoom.identity.test.ts +++ b/server/src/game/colyseusRoom.identity.test.ts @@ -8,7 +8,7 @@ import { devIdentityAllowed, identityFromOptions } from "./colyseusRoom.js"; // @ts-expect-error — the verification script is JavaScript without a declaration file. import { writeLoadCharacterStore } from "../../../tools/verification/load/player-load.mjs"; -const validAppearance = { skinTone: "#c78f62", hair: "hair_mop", hairMat: "hair_raven", face: null }; +const validAppearance = { body: "female" as const, skinTone: "#c78f62", hair: "hair_mop", hairMat: "hair_raven", face: null }; function currentIdentityRecord(overrides: Record): Record { return { @@ -481,6 +481,7 @@ describe("colyseusRoom identity resolution (W2 ticket gate)", () => { characterId: "char-ticket", returningCharacter: false, displayName: "Atlas-Prime", + sprite: "adventurer-premium-female", zoneId: "open-desert-overworld", entitlement: { access: true, characterSlots: 3, activeUntil: "2026-08-09T00:00:00.000Z" }, }); diff --git a/server/src/game/colyseusRoom.ts b/server/src/game/colyseusRoom.ts index 1dc6da17..6eccaa36 100644 --- a/server/src/game/colyseusRoom.ts +++ b/server/src/game/colyseusRoom.ts @@ -5,6 +5,7 @@ import { redeemStandaloneLaunch, type StandaloneLaunchStore } from "../auth/stan import type { LaunchSessionRevocationSink, RuntimeAuthConfig } from "../auth/runtime.js"; import { characterAppearanceToActorAppearance, + characterSpriteForAppearance, characterWornToActorWorn, normalizeCharacterName, normalizeInitialProfessionId, @@ -344,6 +345,7 @@ export async function identityFromOptions( ): Promise { const query = isRecord(options) ? stringRecord(options) : {}; const standaloneToken = query.gameTicket?.trim(); + const standaloneRelease = query.release?.trim(); if (auth.runtimeAuth?.mode === "standalone") { if (query.ticket?.trim()) rejectJoin("launch ticket must be in join body"); if (auth.authenticatedIdentity) { @@ -356,9 +358,9 @@ export async function identityFromOptions( requireInitialProfessionForFirstEntry(character); return auth.authenticatedIdentity; } - if (!standaloneToken) rejectJoin("game ticket required"); + if (!standaloneToken || !standaloneRelease) rejectJoin("game ticket and release required"); try { - const identity = await redeemStandaloneLaunch(standaloneToken, "game", auth.controlStore!, characterStore, auth.runtimeAuth, policy.isCharacterIdReserved); + const identity = await redeemStandaloneLaunch(standaloneToken, "game", auth.controlStore!, characterStore, auth.runtimeAuth, policy.isCharacterIdReserved, standaloneRelease); const character = characterStore.get(identity.characterId, identity.ownerRef); if (!character) rejectJoin("invalid game ticket character"); requireInitialProfessionForFirstEntry(character); @@ -389,6 +391,7 @@ export async function identityFromOptions( entitlement: ticketIdentity.entitlement, pendingFirstEntryCommit: hostedFirstEntryCommit, appearance: characterAppearanceToActorAppearance(character.appearance), + sprite: characterSpriteForAppearance(character.appearance), worn: characterWornToActorWorn(character.worn), wornColors: cloneWornColors(character.wornColors), ...authoritySeed, @@ -428,6 +431,7 @@ export async function identityFromOptions( : { returningCharacter: character.worldEntryClaimed }), ownerRef: character.ownerRef, appearance: characterAppearanceToActorAppearance(character.appearance), + sprite: characterSpriteForAppearance(character.appearance), worn: characterWornToActorWorn(character.worn), wornColors: cloneWornColors(character.wornColors), ...authoritySeed, diff --git a/server/src/game/colyseusServer.auth.test.ts b/server/src/game/colyseusServer.auth.test.ts index 65df539f..3d7897a3 100644 --- a/server/src/game/colyseusServer.auth.test.ts +++ b/server/src/game/colyseusServer.auth.test.ts @@ -9,6 +9,7 @@ const runtimeAuth: RuntimeAuthConfig = { origin: "https://www.successorgame.com", shardId: "open-desert", clientReleaseId: "release-a", + acceptedClientReleaseIds: ["release-a", "release-beta"], serverReleaseId: "server-a", issuer: "successor-server", }; @@ -67,14 +68,14 @@ describe("standalone Colyseus pre-admission auth", () => { runtimeAuth, controlStore: { redeemCapability: redeem } as never, }); - const auth = await RoomClass.onAuth("", { gameTicket: "g".repeat(32) }, {} as never); + const auth = await RoomClass.onAuth("", { gameTicket: "g".repeat(32), release: "release-a" }, {} as never); expect(auth).toMatchObject({ characterId: character.id, ownerRef: character.ownerRef }); const room = roomHarness(RoomClass, characterStore, runtimeAuth); const client = { sessionId: "session-atlas", ref: {}, send: () => undefined, leave: () => undefined }; await room.onJoin(client, { gameTicket: "g".repeat(32) }, auth); expect(redeem).toHaveBeenCalledTimes(1); expect(room.identities.get("session-atlas")).toMatchObject({ characterId: character.id, ownerRef: character.ownerRef }); - await expect(RoomClass.onAuth("", { gameTicket: "g".repeat(32) }, {} as never)).resolves.toBe(false); + await expect(RoomClass.onAuth("", { gameTicket: "g".repeat(32), release: "release-a" }, {} as never)).resolves.toBe(false); expect(redeem).toHaveBeenCalledTimes(2); }); @@ -91,7 +92,7 @@ describe("standalone Colyseus pre-admission auth", () => { runtimeAuth, controlStore: { redeemCapability: redeem, revokeLaunch: vi.fn() } as never, }); - await expect(RoomClass.onAuth("", { gameTicket: "r".repeat(32) }, {} as never)).resolves.toBe(false); + await expect(RoomClass.onAuth("", { gameTicket: "r".repeat(32), release: "release-a" }, {} as never)).resolves.toBe(false); const auth = { actorId: character.id, playerId: character.id, displayName: character.name, zoneId: runtimeAuth.shardId, characterId: character.id, ownerRef: character.ownerRef }; const room = roomHarness(RoomClass, characterStore, runtimeAuth, true); const client = { sessionId: "session-player", ref: {}, send: () => undefined, leave: () => undefined }; @@ -118,10 +119,35 @@ describe("standalone Colyseus pre-admission auth", () => { controlStore: { redeemCapability: redeem, revokeLaunch: vi.fn() } as never, }); - await expect(RoomClass.onAuth("", { gameTicket: "a".repeat(32) }, {} as never)).resolves.toBe(false); + await expect(RoomClass.onAuth("", { gameTicket: "a".repeat(32), release: "release-a" }, {} as never)).resolves.toBe(false); expect(redeem).toHaveBeenCalledTimes(1); }); + it("redeems stable and beta capabilities against their presented release", async () => { + const redeem = vi.fn(async ({ clientReleaseId }: { clientReleaseId: string }) => ({ + launchId: `launch-${clientReleaseId}`, + accountId: "account-atlas", + ownerRef: character.ownerRef, + characterId: character.id, + shardId: runtimeAuth.shardId, + clientReleaseId, + serverReleaseId: runtimeAuth.serverReleaseId, + issuer: runtimeAuth.issuer, + purpose: "game" as const, + })); + const RoomClass = createStandaloneAuthenticatedRoomClass({ + shard: { isReservedCharacterId: () => false } as never, + characterStore: { get: () => character } as never, + runtimeAuth, + controlStore: { redeemCapability: redeem } as never, + }); + + await expect(RoomClass.onAuth("", { gameTicket: "s".repeat(32), release: "release-a" }, {} as never)).resolves.toMatchObject({ clientReleaseId: "release-a" }); + await expect(RoomClass.onAuth("", { gameTicket: "b".repeat(32), release: "release-beta" }, {} as never)).resolves.toMatchObject({ clientReleaseId: "release-beta" }); + await expect(RoomClass.onAuth("", { gameTicket: "x".repeat(32), release: "release-unknown" }, {} as never)).resolves.toBe(false); + expect(redeem.mock.calls.map(([input]) => input.clientReleaseId)).toEqual(["release-a", "release-beta"]); + }); + it("replays an early ready view once async identity admission completes", () => { const connect = vi.fn(); const view = { areaId: "open-desert", x: 0, y: 0, radiusCells: 192 }; diff --git a/server/src/game/colyseusServer.ts b/server/src/game/colyseusServer.ts index 7196d227..8107c70c 100644 --- a/server/src/game/colyseusServer.ts +++ b/server/src/game/colyseusServer.ts @@ -14,6 +14,7 @@ import type { BugReportWriter } from "../support/bugReports.js"; const standaloneMatchmakeSchema = z.object({ gameTicket: z.string().trim().min(32).max(256), + release: z.string().trim().min(1).max(128), }).strict(); const MATCHMAKE_RATE_WINDOW_MS = 1_000; const MATCHMAKE_RATE_LIMIT = 32; @@ -218,7 +219,7 @@ export function createStandaloneAuthenticatedRoomClass(options: ColyseusGameServ const parsed = standaloneMatchmakeSchema.safeParse(clientOptions); if (!parsed.success) return false; try { - return await redeemStandaloneLaunch(parsed.data.gameTicket, "game", controlStore, options.characterStore, runtimeAuth, options.shard.isReservedCharacterId.bind(options.shard)); + return await redeemStandaloneLaunch(parsed.data.gameTicket, "game", controlStore, options.characterStore, runtimeAuth, options.shard.isReservedCharacterId.bind(options.shard), parsed.data.release); } catch { return false; } diff --git a/server/src/game/protocol.ts b/server/src/game/protocol.ts index c866b400..226cc360 100644 --- a/server/src/game/protocol.ts +++ b/server/src/game/protocol.ts @@ -63,6 +63,7 @@ const CLIENT_COMMAND_KEYS = [ "SetEquippedClothing", "DebugGiveItem", "DebugGrantSkillBoxes", + "DebugGiveCredits", "EnterTransition", "UseConsumable", "RefillAmmo", @@ -279,6 +280,13 @@ export const rawClientCommandSchema = z.union([ skill_box_ids: z.array(z.string().min(1).max(96)).min(1).max(64), }), }), + z.object({ + // Signed: a tester drains a wallet as often as they fill one. Bounded well + // inside i64 so the authority's saturating add can never be reached. + DebugGiveCredits: z.object({ + amount: z.number().int().min(-1_000_000_000).max(1_000_000_000), + }), + }), z.object({ EnterTransition: z.object({ transition_id: z.string().min(1).max(96), @@ -2072,7 +2080,12 @@ export interface GameCommandReceipt { export type GameCompactReceipt = [commandId: number, accepted: 0 | 1, tick: number, reasonCode?: string]; -export type GamePlayerPositionAck = [x: number, y: number]; +export type GamePlayerPositionAck = [x: number, y: number, appliedMoveCommandId: number]; + +export interface GameMovementProfileAck { + walkSpeedMilliPerSecond: number; + sprintSpeedMilliPerSecond: number; +} export interface GameCombatEvent { kind?: "ranged_roll"; @@ -2198,6 +2211,7 @@ export type GameServerPacket = acks: GameCompactReceipt[]; playerActor?: GameActorSnapshot; playerPosition?: GamePlayerPositionAck; + movementProfile?: GameMovementProfileAck; events?: GameCombatEvent[]; compactEvents?: GameCompactCombatEvent[]; abilityQueue?: AbilityQueueView | null; diff --git a/server/src/game/rustAuthorityBridge.ts b/server/src/game/rustAuthorityBridge.ts index fa18e68a..dd04915e 100644 --- a/server/src/game/rustAuthorityBridge.ts +++ b/server/src/game/rustAuthorityBridge.ts @@ -934,6 +934,8 @@ export interface RustAuthorityActorSnapshot { skillPointsUsed?: number; skillPointsCap?: number; shotSpreadDegreesMilli?: number; + walkSpeedMilliPerSecond?: number; + sprintSpeedMilliPerSecond?: number; credits?: number; respawnTick?: number; nextSampleTick?: number; diff --git a/server/src/game/shard.test.ts b/server/src/game/shard.test.ts index 65c13a7d..d3df1b77 100644 --- a/server/src/game/shard.test.ts +++ b/server/src/game/shard.test.ts @@ -515,6 +515,7 @@ describe("GameShard", () => { hair_mat: "hair_umber", face, }, + sprite: "adventurer-premium-female", }; const submittedActors: RustAuthorityActorUpsertInput[] = []; const internals = shard as unknown as { @@ -544,6 +545,7 @@ describe("GameShard", () => { direction: actor.direction, }), appearance: actor.appearance, + sprite: actor.sprite, }, inventory: [], reservations: [], @@ -559,7 +561,9 @@ describe("GameShard", () => { shard.connect(socket, identity); await waitFor(() => socket.sent.some((packet) => JSON.parse(packet).type === "game.hello")); expect(submittedActors.at(-1)?.appearance?.face).toEqual(face); + expect(submittedActors.at(-1)?.sprite).toBe("adventurer-premium-female"); expect(helloActor(socket, identity.actorId)?.appearance.face).toEqual(face); + expect(helloActor(socket, identity.actorId)?.sprite).toBe("adventurer-premium-female"); } finally { shard.close(); } @@ -8173,7 +8177,7 @@ describe("GameShard", () => { const ackPacket = packets(socket).find((packet) => packet.type === "game.acks"); expect(ackPacket?.acks).toEqual([[2, 1, expect.any(Number)]]); expect(ackPacket?.playerActor).toBeUndefined(); - expect(ackPacket?.playerPosition).toEqual([expect.any(Number), 17]); + expect(ackPacket?.playerPosition).toEqual([expect.any(Number), 17, 2]); expect(ackPacket?.playerPosition[0]).toBeLessThan(11); } finally { shard.close(); @@ -8201,7 +8205,7 @@ describe("GameShard", () => { const ackPacket = packets(socket).find((packet) => packet.type === "game.acks"); expect(ackPacket?.acks).toEqual([[1, 1, expect.any(Number)]]); - expect(ackPacket?.playerPosition).toBeUndefined(); + expect(ackPacket?.playerPosition).toEqual([expect.any(Number), 17, 1]); expect(ackPacket?.playerActor?.vitals.action).toBe(beforeAction - 1); expect(ackPacket?.playerActor?.x).toBeLessThan(11); } finally { @@ -10122,8 +10126,8 @@ describe("GameShard", () => { try { expect(shard.defaultJoinSpawnForActor("char_fresh")).toEqual({ areaId: "open-desert-overworld", - x: 512, - y: 513, + x: 516, + y: 511, facing: "front", }); } finally { @@ -10144,7 +10148,7 @@ describe("GameShard", () => { displayName: "Fresh Missing Spawn", zoneId: "open-desert", }; - expect(internals.ensureActor(identity)).toMatchObject({ x: 512, y: 513 }); + expect(internals.ensureActor(identity)).toMatchObject({ x: 516, y: 511 }); expect(internals.ensureActor({ ...identity, spawn: { areaId: "open-desert-overworld", x: 300, y: 400, facing: "right" }, @@ -10310,6 +10314,12 @@ describe("GameShard", () => { ["quarry-probe", "quarry-chopper", 3107, "quarry-chopper"], ["kiln-probe", "wpn-carbine", 3112, "wpn-carbine"], ["lightning-probe", "lightning-carbine", 3121, "lightning-carbine"], + ["pistol-probe", "wpn-pistol", 3122, "wpn-pistol"], + ["assault-probe", "wpn-assault", 3123, "wpn-assault"], + ["shotgun-probe", "wpn-shotgun", 3124, "wpn-shotgun"], + ["sniper-probe", "wpn-sniper", 3125, "wpn-sniper"], + ["heavy-probe", "wpn-heavy", 3126, "wpn-heavy"], + ["launcher-probe", "wpn-launcher", 3127, "wpn-launcher"], ] as const; internals.applyRustAuthorityTickOutput({ schema: "successor.rust-authority-bridge-tick.v1", @@ -10351,6 +10361,48 @@ describe("GameShard", () => { } }); + it("equips concrete ranged inventory items by id and preserves item variants", () => { + const shard = testShard({ slicePath: currentRuntimeSlicePath, snapshotIntervalMs: 10_000 }); + try { + const concreteWeapons = [ + [3122, "wpn-pistol"], + [3123, "wpn-assault"], + [3124, "wpn-shotgun"], + [3125, "wpn-sniper"], + [3126, "wpn-heavy"], + [3127, "wpn-launcher"], + ] as const; + for (const [index, [itemId, weaponId]] of concreteWeapons.entries()) { + const variantId = 80_000 + index; + const granted = shard.submitCommandForTest("player", envelope(index * 2 + 1, { + DebugGiveItem: { item_id: itemId, variant_id: variantId, quantity: 1 }, + }), undefined, true); + expect(granted.receipt).toMatchObject({ accepted: true }); + + const equipped = shard.submitCommandForTest("player", envelope(index * 2 + 2, { + SetEquippedWeapon: { + weapon_id: weaponId, + weapon_item_id: itemId, + weapon_variant_id: variantId, + }, + })); + expect(equipped.receipt).toMatchObject({ accepted: true }); + expect(shard.snapshotFor("player").actors.player?.weapon).toMatchObject({ + weaponId, + weaponItemId: itemId, + weaponVariantId: variantId, + }); + } + + const retired = shard.submitCommandForTest("player", envelope(99, { + SetEquippedWeapon: { weapon_item_id: 3113 }, + })); + expect(retired.receipt).toMatchObject({ accepted: false, reasonCode: "unknown_item" }); + } finally { + shard.close(); + } + }); + it("preserves an actor's weapon when a later Rust sync omits the weapon field", () => { const shard = testShard({ slicePath: currentRuntimeSlicePath, snapshotIntervalMs: 10_000 }); try { diff --git a/server/src/game/shard.ts b/server/src/game/shard.ts index 7851347c..349d5c75 100644 --- a/server/src/game/shard.ts +++ b/server/src/game/shard.ts @@ -69,7 +69,6 @@ import { type GameCompactDirection, type GameCompactLifeState, type GameCompactVitals, - type GamePlayerPositionAck, type GameShardDelta, type GameServerPacket, type GameShardSnapshot, @@ -158,6 +157,8 @@ export interface GameSessionIdentity { /** Hosted control-plane first-entry marker, committed after local durability. */ pendingFirstEntryCommit?: { entryNonce: string; shardId: string; releaseId: string }; appearance?: GameActorAppearanceSnapshot; + /** Durable humanoid body route encoded as the canonical player sprite. */ + sprite?: string; /** Creator worn wardrobe set (validated by the character store). */ worn?: GameActorWornPiece[]; /** Durable creator palette cache, including unequipped pieces. */ @@ -580,6 +581,8 @@ interface AuthorityActor { skillPointsCap?: number; credits?: number; shotSpreadDegreesMilli?: number; + walkSpeedMilliPerSecond?: number; + sprintSpeedMilliPerSecond?: number; combatQueue?: GameActorCombatQueueSnapshot; abilityQueue?: AbilityQueueView | null; inCombat: boolean; @@ -664,6 +667,9 @@ interface GameSession { // state or the journal; it only expands an ingress sentinel to a concrete family. lastResourceFamily?: string; seenCommands: Set; + commandChain: Promise; + lastMoveCommandId: number; + appliedMoveCommandId: number; ingressBudgets: Map; lastSnapshotTick: number; lastActorDeltaTick: number; @@ -1784,6 +1790,9 @@ export class GameShard { connectedAtMs, characterId: identity.characterId, seenCommands: new Set(), + commandChain: Promise.resolve(), + lastMoveCommandId: 0, + appliedMoveCommandId: 0, ingressBudgets: new Map(), lastSnapshotTick: -1, lastActorDeltaTick: -1, @@ -1852,13 +1861,20 @@ export class GameShard { zoneId: identity.zoneId, }); socket.on("message", (data) => { - void this.handleRawMessage(session.id, data).catch((error: unknown) => { + const onError = (error: unknown) => { this.logger?.warn({ error, sessionId: session.id }, "game message handler failed"); this.sendError(session, "internal_error", "game command failed"); - }); + }; + if (this.rustAuthorityMode === "live") { + session.commandChain = session.commandChain + .then(() => this.handleRawMessage(session.id, data)) + .catch(onError); + } else { + void this.handleRawMessage(session.id, data).catch(onError); + } }); - socket.on("close", () => this.disconnectSession(session.id)); socket.on("error", (error) => this.logger?.warn({ error, sessionId: session.id }, "game socket error")); + socket.on("close", () => this.disconnectSession(session.id)); const sendHello = (): void => { if (!this.sessions.has(session.id) || session.socket.readyState !== 1) return; const snapshot = this.snapshotForSession(session); @@ -2029,6 +2045,7 @@ export class GameShard { actor.label = actor.displayName; if (applyIdentitySeed) { actor.appearance = identity.appearance ? cloneActorAppearance(identity.appearance) : actor.appearance; + if (identity.sprite) actor.sprite = identity.sprite; if (identity.wornColors) actor.wornColors = cloneWornColors(identity.wornColors); actor.worn = identity.worn ? cloneActorWorn(identity.worn) : actor.worn; } @@ -2158,6 +2175,15 @@ export class GameShard { } } + /** + * Record a refused command, and tell the actor that issued it. + * + * The ring behind `/game/status` is for operators. Without the message the + * player gets nothing at all: they press SURVEY with no survey tool, the + * authority answers `target_unavailable`, and the pane sits there looking + * broken. Every gated system - survey, craft, bank, guild, group - reads as + * unimplemented until the refusal is delivered. + */ private recordCommandRejection(actorId: string, command: ClientCommand, reasonCode: string | undefined, tick = this.tick): void { const safeTick = Number.isFinite(tick) ? Math.trunc(tick) : this.tick; const entry: GameShardRecentRejection = { @@ -2169,10 +2195,26 @@ export class GameShard { if (this.recentRejections.length < recentRejectionCapacity) { this.recentRejections.push(entry); this.recentRejectionWriteIndex = this.recentRejections.length % recentRejectionCapacity; - return; + } else { + this.recentRejections[this.recentRejectionWriteIndex] = entry; + this.recentRejectionWriteIndex = (this.recentRejectionWriteIndex + 1) % recentRejectionCapacity; + } + this.notifyCommandRejected(entry); + } + + /** Deliver a refusal to every session driving the actor that caused it. */ + private notifyCommandRejected(entry: GameShardRecentRejection): void { + // Movement is refused constantly by the ingress budget and is not a player + // decision; surfacing it would bury every refusal that is one. + if (entry.kind === "SetMoveIntent") return; + for (const session of this.sessions.values()) { + if (session.actorId !== entry.actorId) continue; + this.sendSessionMessage(session, "commandRejected", { + kind: entry.kind, + reasonCode: entry.reasonCode, + tick: entry.tick, + }); } - this.recentRejections[this.recentRejectionWriteIndex] = entry; - this.recentRejectionWriteIndex = (this.recentRejectionWriteIndex + 1) % recentRejectionCapacity; } private recentRejectionsForStatus(): GameShardRecentRejection[] { @@ -3157,6 +3199,11 @@ export class GameShard { session: rustSessionNumber(session), player: this.actorNetId(session.actorId), ingressBudgetSession: session, + // One switch governs every debug grant. Without this the HTTP debug route + // works while the in-game character builder is refused, which reads as + // the builder being broken rather than the shard being locked. Unset in + // production, so a player socket still cannot grant itself anything. + allowDebugCommand: debugAuthorityCommandsEnabled(), }); } @@ -3179,6 +3226,11 @@ export class GameShard { } private consumeIngressBudget(session: GameSession, command: ClientCommand): boolean { + // The budget exists to stop players spamming the authority. A debug grant + // only exists behind GAME_DEBUG_AUTHORITY_COMMANDS, and the character + // builder legitimately commits a whole kit in one frame - rate-limiting it + // silently dropped part of every large grant. + if (isDebugAuthorityCommand(command) && debugAuthorityCommandsEnabled()) return true; const kind = commandKind(command); const rule = this.ingressBudgetRuleForKind(kind); const nowMs = finiteNumberOr(this.ingressBudgetConfig.nowMs(), this.clock.nowMs()); @@ -4718,6 +4770,14 @@ export class GameShard { : finiteInteger(rawSkillPointsCap, actor.skillPointsCap ?? 0); const nextCredits = finiteInteger(snapshot.credits, actor.credits ?? 0); const nextShotSpreadDegreesMilli = finiteInteger(snapshot.shotSpreadDegreesMilli, actor.shotSpreadDegreesMilli ?? 0); + const nextWalkSpeedMilliPerSecond = finiteInteger( + snapshot.walkSpeedMilliPerSecond, + actor.walkSpeedMilliPerSecond ?? 1_357, + ); + const nextSprintSpeedMilliPerSecond = finiteInteger( + snapshot.sprintSpeedMilliPerSecond, + actor.sprintSpeedMilliPerSecond ?? 6_526, + ); const nextCombatQueue = this.typescriptCombatQueueForRustQueue(snapshot.combatQueue); const nextAbilityQueue = this.typescriptAbilityQueueForRustQueue(snapshot.abilityQueue); const nextInCombat = snapshot.inCombat === true; @@ -4941,6 +5001,8 @@ export class GameShard { actor.skillPointsUsed = nextSkillPointsUsed; actor.skillPointsCap = nextSkillPointsCap; actor.shotSpreadDegreesMilli = nextShotSpreadDegreesMilli; + actor.walkSpeedMilliPerSecond = nextWalkSpeedMilliPerSecond; + actor.sprintSpeedMilliPerSecond = nextSprintSpeedMilliPerSecond; actor.credits = nextCredits; actor.combatQueue = nextCombatQueue; actor.abilityQueue = nextAbilityQueue; @@ -5325,6 +5387,13 @@ export class GameShard { if (!actor || !Number.isFinite(actor.x) || !Number.isFinite(actor.y)) return null; return { areaId: actor.areaId, x: actor.x, y: actor.y }; } + /** Live authority display name, so chat attributes a line to the same name + * the world already draws over that actor. */ + chatDisplayNameForActor(actorId: string): string | null { + const resolvedActorId = this.characterActorIds.get(actorId) ?? actorId; + const actor = this.actors.get(resolvedActorId); + return actor?.displayName || actor?.label || null; + } characterHasDurableAuthorityState(characterId: string): boolean { const actorId = this.characterActorIds.get(characterId) ?? characterId; return this.durableAuthorityStateExistsForJoin( @@ -5462,7 +5531,7 @@ export class GameShard { for (const id of ownerIds) { this.activeTradeSessionsByActorId.delete(id); this.debugSeenCommandsByActor.delete(id); - this.debugNextCommandIdByActor.delete(id); + if (id !== rustActorId) this.debugNextCommandIdByActor.delete(id); this.rustAuthorityRegisteredActorIds.delete(id); this.rustAuthorityActorUpserts.delete(id); this.rustAuthorityLinkDeadActorIds.delete(id); @@ -5909,6 +5978,7 @@ export class GameShard { const applyGameplaySeed = options.applyGameplaySeed ?? true; if (applyGameplaySeed) { actor.appearance = identity.appearance ? cloneActorAppearance(identity.appearance) : actor.appearance ?? cloneActorAppearance(defaultActorAppearance); + if (identity.sprite) actor.sprite = identity.sprite; if (identity.worn) actor.worn = cloneActorWorn(identity.worn); if (identity.professionIds !== undefined) actor.professionIds = normalizeProfessionIds(identity.professionIds); if (identity.skillBoxIds !== undefined) actor.skillBoxIds = normalizeSkillBoxIds(identity.skillBoxIds); @@ -6255,12 +6325,21 @@ export class GameShard { && ("Move" in packet.data.envelope.command || "SetMoveIntent" in packet.data.envelope.command) ? performance.now() : 0; + const moveTraceReceivedAtWallMs = moveTraceReceivedAtMs > 0 ? Date.now() : 0; // Bare /survey //sample reuse-last: resolve the ingress sentinel to a // concrete family (or an honest prompt) BEFORE anything is forwarded to Rust // or appended to the journal. const resolvedResource = this.resolveResourceSentinelForSession(session, packet.data.envelope); + const isMoveCommand = "Move" in packet.data.envelope.command + || "SetMoveIntent" in packet.data.envelope.command; let result: SubmitCommandResult; - if ("reject" in resolvedResource) { + if (isMoveCommand && packet.data.envelope.command_id < session.lastMoveCommandId) { + result = { + receipt: this.reject(packet.data.envelope.command_id, "stale_move_command"), + events: [], + delta: this.receiptDeltaFor(session.actorId), + }; + } else if ("reject" in resolvedResource) { this.recordCommandRejection( session.actorId, packet.data.envelope.command, @@ -6287,10 +6366,23 @@ export class GameShard { } if (result.receipt.accepted) { this.recordSessionResourceContext(session, resolvedEnvelope); + if (isMoveCommand) { + session.lastMoveCommandId = Math.max( + session.lastMoveCommandId, + packet.data.envelope.command_id, + ); + session.appliedMoveCommandId = session.lastMoveCommandId; + } } } - if (gameMoveTraceEnabled && ("Move" in packet.data.envelope.command || "SetMoveIntent" in packet.data.envelope.command)) { - this.writeMoveTraceReceipt(session, packet.data.envelope, result.receipt, moveTraceReceivedAtMs); + if (gameMoveTraceEnabled && isMoveCommand) { + this.writeMoveTraceReceipt( + session, + packet.data.envelope, + result.receipt, + moveTraceReceivedAtMs, + moveTraceReceivedAtWallMs, + ); } // A receipt is an acknowledgement, not merely an in-memory result. Do not // release it to a client until this command group has reached stable media. @@ -6332,14 +6424,32 @@ export class GameShard { playerSnapshot, ); const playerMoveAck = shouldSendFullMoveAck - ? { playerActor: playerSnapshot } - : { playerPosition: playerSnapshot ? playerPositionAck(playerSnapshot) : undefined }; + ? { + playerActor: playerSnapshot, + playerPosition: playerSnapshot + ? [playerSnapshot.x, playerSnapshot.y, session.appliedMoveCommandId] as [number, number, number] + : undefined, + } + : { + playerPosition: playerSnapshot + ? [playerSnapshot.x, playerSnapshot.y, session.appliedMoveCommandId] as [number, number, number] + : undefined, + }; if (playerSnapshot) this.rememberActorSnapshots(session, { [playerSnapshot.id]: playerSnapshot }); const ackEventsDue = this.pendingCombatEventsDue(session); + const movementActor = this.actors.get(session.actorId); + const movementProfile = movementActor?.walkSpeedMilliPerSecond !== undefined + && movementActor.sprintSpeedMilliPerSecond !== undefined + ? { + walkSpeedMilliPerSecond: movementActor.walkSpeedMilliPerSecond, + sprintSpeedMilliPerSecond: movementActor.sprintSpeedMilliPerSecond, + } + : undefined; this.send(session, { type: "game.acks", acks: session.pendingReceipts.splice(0).map(compactReceipt), ...playerMoveAck, + movementProfile, events: this.takePendingCombatEvents(session, ackEventsDue), abilityQueue: this.abilityQueueForSession(session), abilityQueueEvents: this.takePendingAbilityQueueEvents(session), @@ -6363,14 +6473,18 @@ export class GameShard { envelope: ClientCommandEnvelope, receipt: GameCommandReceipt, receivedAtMs: number, + receivedAtWallMs: number, ): void { if (!gameMoveTraceEnabled || (!("Move" in envelope.command) && !("SetMoveIntent" in envelope.command))) return; const elapsedMs = receivedAtMs > 0 ? performance.now() - receivedAtMs : 0; + const tracedAtWallMs = Date.now(); const move = "Move" in envelope.command ? envelope.command.Move : envelope.command.SetMoveIntent; process.stdout.write(`${JSON.stringify({ schema: "successor.move-trace.v1", event: "move.receipt", - atMs: Date.now(), + atMs: tracedAtWallMs, + receivedAtMs: receivedAtWallMs || null, + authorityAppliedAtMs: tracedAtWallMs, shardId: this.shardId, actor: session.actorId, sessionId: session.id, @@ -6386,6 +6500,7 @@ export class GameShard { durationTicks: "duration_ticks" in move ? move.duration_ticks : null, sprint: move.sprint === true, receiptMs: Number(elapsedMs.toFixed(3)), + receiveToAuthorityMs: Number(elapsedMs.toFixed(3)), })}\n`); } @@ -6450,8 +6565,18 @@ export class GameShard { if ("SetEquippedWeapon" in command) { const requestedWeaponId = command.SetEquippedWeapon.weapon_id; const weaponItemId = Math.max(0, Math.trunc(command.SetEquippedWeapon.weapon_item_id ?? 0)); - const weaponVariantId = Math.max(0, Math.trunc(command.SetEquippedWeapon.weapon_variant_id ?? 0)); + const requestedWeaponVariantId = command.SetEquippedWeapon.weapon_variant_id; + const weaponVariantId = requestedWeaponVariantId === undefined + ? undefined + : Math.max(0, Math.trunc(requestedWeaponVariantId)); const itemWeaponId = weaponItemId > 0 ? authorityWeaponIdForInventoryItemId(weaponItemId) : null; + const ownedWeaponRows = weaponItemId > 0 + ? this.inventory.filter((row) => ( + row.itemId === weaponItemId + && this.actorOwnsInventoryContainer(actor.id, row.container) + && row.available > 0 + )) + : []; if (weaponItemId > 0 && !itemWeaponId) { return { accepted: false, reasonCode: "unknown_item", events: [] }; } @@ -6462,10 +6587,16 @@ export class GameShard { return { accepted: false, reasonCode: "unknown_weapon", events: [] }; } else if (requestedWeaponId && itemWeaponId && requestedWeaponId !== itemWeaponId) { return { accepted: false, reasonCode: "no_weapon_equipped", events: [] }; - } else if (weaponItemId > 0 && !this.inventory.some((row) => row.itemId === weaponItemId && this.actorOwnsInventoryContainer(actor.id, row.container) && row.available > 0)) { + } else if (weaponItemId > 0 && ownedWeaponRows.length === 0) { return { accepted: false, reasonCode: "item_unavailable", events: [] }; } else { - actor.weapon = authorityActorWeaponSnapshot(weaponId, weaponItemId, weaponVariantId); + const resolvedWeaponVariantId = weaponItemId > 0 + ? weaponVariantId ?? Math.min(...ownedWeaponRows.map((row) => row.variantId)) + : 0; + if (weaponItemId > 0 && !ownedWeaponRows.some((row) => row.variantId === resolvedWeaponVariantId)) { + return { accepted: false, reasonCode: "item_unavailable", events: [] }; + } + actor.weapon = authorityActorWeaponSnapshot(weaponId, weaponItemId, resolvedWeaponVariantId); } this.dirtyActorIds.add(actor.id); return { accepted: true, events: [] }; @@ -7499,11 +7630,14 @@ export class GameShard { } if (this.rustAuthorityFlushInFlight) { this.authoritySkippedInFlightCount += 1; - this.resetAuthorityTickClock(now); return; } - const tickCount = this.consumeDebugAuthorityTickCount(); + const tickCount = this.activeDebugClockAdvance + ? this.consumeDebugAuthorityTickCount() + : options.force + ? 1 + : this.consumeAuthorityTickCount(now, false); if (tickCount <= 0) return; this.recordAuthorityTickStep(tickCount); this.rustAuthorityFlushInFlight = true; @@ -7660,10 +7794,12 @@ export class GameShard { || hasVisibleInventoryDelta || session.pendingAbilityQueueEvents.length > 0; const interestRefreshDue = session.interestDirty; + const selfHighDetailDirty = highDetailDirtyActorIds.has(session.actorId); const routineDeltaDue = interestRefreshDue || session.lastActorDeltaTick < 0 || this.tick - session.lastActorDeltaTick >= routineDeltaIntervalTicks; - const routineBudgeted = routineDeltaDue && !hasUrgentState && !interestRefreshDue; + const routineBudgeted = + routineDeltaDue && !hasUrgentState && !interestRefreshDue && !selfHighDetailDirty; if (routineBudgeted) { if (routineSessionsThisFlush >= maxRoutineSessionsPerFlush) { continue; @@ -7701,7 +7837,6 @@ export class GameShard { ? this.routineInterestActorIds(session) : []; const hasKnownActorRemovals = includeRoutineState && this.hasActorRemovalsForSession(session); - const selfHighDetailDirty = highDetailDirtyActorIds.has(session.actorId); const focusActorIds = uniqueActorIds([ ...(selfHighDetailDirty ? [session.actorId] : []), ...eventFocusActors, @@ -10444,9 +10579,6 @@ function compactReceipt(receipt: GameCommandReceipt): GameCompactReceipt { : [receipt.commandId, receipt.accepted ? 1 : 0, receipt.tick]; } -function playerPositionAck(actor: GameActorSnapshot): GamePlayerPositionAck { - return [actor.x, actor.y]; -} function shouldSendPlayerActorMoveAck( move: Extract["Move"], @@ -10968,6 +11100,18 @@ function authorityWeaponIdForInventoryItemId(itemId: number | undefined): Author return "wpn-carbine"; case 3121: return "lightning-carbine"; + case 3122: + return "wpn-pistol"; + case 3123: + return "wpn-assault"; + case 3124: + return "wpn-shotgun"; + case 3125: + return "wpn-sniper"; + case 3126: + return "wpn-heavy"; + case 3127: + return "wpn-launcher"; default: return null; } @@ -11075,6 +11219,18 @@ function inventoryItemNameForId(itemId: number): string | null { return "Kiln Energy Cell Carbine"; case 3121: return "Lightning Carbine"; + case 3122: + return "Badge Bolt Pistol"; + case 3123: + return "Slagrail Vanguard"; + case 3124: + return "Coilgate Scatter"; + case 3125: + return "Kiln Long Pattern"; + case 3126: + return "Bastion LMG"; + case 3127: + return "Flare Net Launcher"; case 7103: return "Combat Helm"; case 4001: @@ -11672,6 +11828,7 @@ function commandKind(command: ClientCommand): string { if ("SetEquippedClothing" in command) return "SetEquippedClothing"; if ("DebugGiveItem" in command) return "DebugGiveItem"; if ("DebugGrantSkillBoxes" in command) return "DebugGrantSkillBoxes"; + if ("DebugGiveCredits" in command) return "DebugGiveCredits"; if ("DiscardStack" in command) return "DiscardStack"; if ("EnterTransition" in command) return "EnterTransition"; if ("UseConsumable" in command) return "UseConsumable"; @@ -11785,6 +11942,9 @@ function commandKind(command: ClientCommand): string { const ingressBudgetCapacityEnv = "GAME_INGRESS_BUDGET_CAPACITY"; const ingressBudgetRefillEnv = "GAME_INGRESS_BUDGET_REFILL_PER_SECOND"; +const movementIngressBudgetCapacityEnv = "GAME_MOVE_INGRESS_BUDGET_CAPACITY"; +const movementIngressBudgetRefillEnv = "GAME_MOVE_INGRESS_BUDGET_REFILL_PER_SECOND"; +const defaultMovementIngressBudgetRule = { capacity: 40, refillPerSecond: 30 } as const; const defaultIngressBudgetRule = { capacity: 10, refillPerSecond: 5 } as const; const ingressBudgetCommandKinds = [ "Move", @@ -11797,6 +11957,7 @@ const ingressBudgetCommandKinds = [ "SetEquippedClothing", "DebugGiveItem", "DebugGrantSkillBoxes", + "DebugGiveCredits", "EnterTransition", "UseConsumable", "RefillAmmo", @@ -11963,10 +12124,18 @@ function createIngressBudgetConfig( const commandKinds: Record = {}; for (const kind of ingressBudgetCommandKinds) { const override = options?.commandKinds?.[kind]; + const fallback = kind === "SetMoveIntent" || kind === "Move" + ? normalizeIngressBudgetRule({ + capacity: envNumber(env[movementIngressBudgetCapacityEnv]) + ?? defaultMovementIngressBudgetRule.capacity, + refillPerSecond: envNumber(env[movementIngressBudgetRefillEnv]) + ?? defaultMovementIngressBudgetRule.refillPerSecond, + }, defaultMovementIngressBudgetRule) + : defaultRule; commandKinds[kind] = normalizeIngressBudgetRule({ - capacity: override?.capacity ?? defaultRule.capacity, - refillPerSecond: override?.refillPerSecond ?? defaultRule.refillPerSecond, - }, defaultRule); + capacity: override?.capacity ?? fallback.capacity, + refillPerSecond: override?.refillPerSecond ?? fallback.refillPerSecond, + }, fallback); } return { default: defaultRule, @@ -11999,8 +12168,15 @@ function finiteNumberOr(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } +// The one switch that unlocks debug grants, read at call time so a test can +// flip it without re-importing the module. +function debugAuthorityCommandsEnabled(): boolean { + const value = process.env.GAME_DEBUG_AUTHORITY_COMMANDS; + return value === "1" || value === "true"; +} + function isDebugAuthorityCommand(command: ClientCommand): boolean { - return "DebugGiveItem" in command || "DebugGrantSkillBoxes" in command; + return "DebugGiveItem" in command || "DebugGrantSkillBoxes" in command || "DebugGiveCredits" in command; } function cardinalFromDirection(direction: Direction): CardinalDirection { diff --git a/server/src/index.ts b/server/src/index.ts index 8f64a3c7..c7963015 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -83,7 +83,7 @@ export async function createApp() { const originPattern = process.env.NODE_ENV === "production" ? /^https:\/\/[^/]+$/u : /^https?:\/\/[^/]+$/u; if (!origin || !originPattern.test(origin)) throw new Error("SUCCESSOR_ALPHA_ORIGIN must be an exact origin"); const requiredLegalVersions = alphaLegalVersionsFromEnv(); - const allowlist = (process.env.SUCCESSOR_ALPHA_CLIENT_RELEASE_ALLOWLIST ?? runtimeAuth.clientReleaseId).split(",").map((value) => value.trim()).filter(Boolean); + const allowlist = runtimeAuth.acceptedClientReleaseIds ?? [runtimeAuth.clientReleaseId]; const gameEndpoint = process.env.SUCCESSOR_ALPHA_GAME_ENDPOINT?.trim(); const chatEndpoint = process.env.SUCCESSOR_ALPHA_CHAT_ENDPOINT?.trim(); assertStandaloneSocketEndpoints(origin, gameEndpoint, chatEndpoint, process.env.NODE_ENV === "production"); @@ -94,6 +94,7 @@ export async function createApp() { configureTicketLogger(app.log); let liveGuildIdForActor: (actorId: string) => string | null = () => null; let liveChatPositionForActor: (actorId: string) => { areaId: string; x: number; y: number } | null = () => null; + let liveDisplayNameForActor: (actorId: string) => string | null = () => null; const chatHub = new ChatHub({ logger: app.log, social: characterStore, @@ -103,6 +104,9 @@ export async function createApp() { spatialAuthority: { positionForActor: (actorId) => liveChatPositionForActor(actorId), }, + nameAuthority: { + displayNameForActor: (actorId) => liveDisplayNameForActor(actorId), + }, localRadiusCells: numberEnv("CHAT_LOCAL_RADIUS_CELLS", 24), maxSessions: numberEnv("CHAT_MAX_SESSIONS", 10_000), maxSessionsPerUser: numberEnv("CHAT_MAX_SESSIONS_PER_USER", 4), @@ -157,6 +161,7 @@ export async function createApp() { }); liveGuildIdForActor = (actorId) => gameShard.guildIdForActor(actorId); liveChatPositionForActor = (actorId) => gameShard.chatPositionForActor(actorId); + liveDisplayNameForActor = (actorId) => gameShard.chatDisplayNameForActor(actorId); await registerGameRoutes(app, { shard: gameShard, characterStore, diff --git a/server/src/lifecycleSupervisor.test.ts b/server/src/lifecycleSupervisor.test.ts index 829c3331..ce87119f 100644 --- a/server/src/lifecycleSupervisor.test.ts +++ b/server/src/lifecycleSupervisor.test.ts @@ -19,5 +19,8 @@ describe("hosted durability lease", () => { first.child.kill("SIGKILL"); await new Promise((resolve) => setTimeout(resolve, 30)); expect(hostedStateLockHealthy()).toBe(false); + const replacement = await acquireHostedStateLock(stateDir); + leases.push(replacement); + expect(hostedStateLockHealthy()).toBe(true); }); }); diff --git a/server/src/lifecycleSupervisor.ts b/server/src/lifecycleSupervisor.ts index 63085ce6..5bb6a703 100644 --- a/server/src/lifecycleSupervisor.ts +++ b/server/src/lifecycleSupervisor.ts @@ -2,7 +2,7 @@ import childProcess from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -const flockBin = "/usr/bin/flock"; +const flockBins = ["/usr/bin/flock", "/opt/homebrew/bin/flock", "/usr/local/bin/flock"] as const; const handshakeSchema = "successor.state-lock.v1"; const handshakeTimeoutMs = 5_000; const releaseTimeoutMs = 2_000; @@ -24,12 +24,15 @@ export async function acquireHostedStateLock(stateDir: string, lockPath = path.j const resolvedStateDir = path.resolve(stateDir); const resolvedLockPath = path.resolve(lockPath); fs.mkdirSync(resolvedStateDir, { recursive: true }); - if (!fs.existsSync(flockBin)) throw new Error(`hosted state locking requires ${flockBin}`); + const flockBin = [process.env.SUCCESSOR_FLOCK_BIN, ...flockBins] + .find((candidate) => candidate && fs.existsSync(candidate)); + if (!flockBin) throw new Error(`hosted state locking requires one of ${flockBins.join(", ")}`); const moduleDir = path.dirname(new URL(import.meta.url).pathname); const supervisorCandidates = [path.join(moduleDir, "state-lock-supervisor.js"), path.join(moduleDir, "..", "dist", "state-lock-supervisor.js")]; const supervisorPath = supervisorCandidates.find((candidate) => fs.existsSync(candidate)); if (!supervisorPath) throw new Error(`hosted state-lock supervisor is missing at ${supervisorCandidates.join(", ")}`); - const child = childProcess.spawn(flockBin, ["--exclusive", "--nonblock", "--no-fork", resolvedLockPath, process.execPath, supervisorPath, resolvedLockPath], { + const flockArgs = ["-x", "-n", ...(process.platform === "linux" ? ["--no-fork"] : []), resolvedLockPath, process.execPath, supervisorPath, resolvedLockPath]; + const child = childProcess.spawn(flockBin, flockArgs, { cwd: resolvedStateDir, detached: true, stdio: ["pipe", "ignore", "pipe", "pipe"], @@ -39,7 +42,11 @@ export async function acquireHostedStateLock(stateDir: string, lockPath = path.j const handshake = await waitForHandshake(child, resolvedLockPath); const lease = { lockPath: resolvedLockPath, ownerPid: handshake.pid, child, released: false }; activeLease = lease; - child.once("exit", () => { if (activeLease === lease) activeLease = undefined; }); + child.once("exit", () => { + if (activeLease === lease) activeLease = undefined; + if (lease.released || !child.pid) return; + try { process.kill(-child.pid, "SIGKILL"); } catch { /* process group already gone */ } + }); return lease; } catch (error) { try { child.stdin?.destroy(); } catch { /* already closed */ } diff --git a/server/src/state-lock-supervisor.ts b/server/src/state-lock-supervisor.ts index 2960dc51..7fc79ba4 100644 --- a/server/src/state-lock-supervisor.ts +++ b/server/src/state-lock-supervisor.ts @@ -6,7 +6,8 @@ const schema = "successor.state-lock.v1"; if (!lockPath) fail("state-lock supervisor requires a lock path"); const lockStat = fs.statSync(lockPath); let lockFd: number | null = null; -for (const name of fs.readdirSync("/proc/self/fd")) { +const fdDirectory = fs.existsSync("/proc/self/fd") ? "/proc/self/fd" : "/dev/fd"; +for (const name of fs.readdirSync(fdDirectory)) { const fd = Number(name); if (!Number.isInteger(fd) || fd <= 3) continue; try { diff --git a/site/account/index.html b/site/account/index.html index 397c6884..43b3b394 100644 --- a/site/account/index.html +++ b/site/account/index.html @@ -4,13 +4,9 @@ Account | Successor - + - - +
-
- - - Successor - - + + + Successor + + +
+
@@ -82,130 +53,131 @@

Character workshop

-

- Name your character. Choose a profession. Enter the world. -

+

Name a character. Choose a trade. Enter.

-
-

Sign in

-
-
-
- - -

+
+
Sign in
+

Sign in

+
+ +
+
+ + +

+
+
+ + +

+
-
- - -

+
+ +
-
-
- - -
- + +
-
-

Register

-
-
-
- - -

Your public name in the world. 3–24 characters.

-

+
+
Register
+

Register

+
+ +
+
+ + +

Your public name. 3–24 characters.

+

+
+
+ + +

10+ characters. Long beats clever; there is no reset.

+

+
+
+ + +

+
+ +

-
- - -

At least 10 characters. Long beats clever; there is no reset.

-

+
+ +
-
- - -

-
- -

-
-
- - -
- + +
@@ -213,7 +185,7 @@

Signed in

-
-
-

Invite alpha. Saves may reset.

- -

From Lycaon LLC, in partnership with ROYCORP GAME STUDIOS.

-
+ diff --git a/site/alpha/index.html b/site/alpha/index.html index f2ac5dc7..46d75bcb 100644 --- a/site/alpha/index.html +++ b/site/alpha/index.html @@ -2,15 +2,11 @@ - - Successor Alpha Access | Successor - + + Alpha | Successor + - - +
-
- - - Successor - - + + + Successor + + +
+
@@ -89,15 +60,15 @@

Routes

Register or sign in.

  • - Connect Device + Connect device

    Approve an installed client.

  • - Play + Play

    Enter through the browser.

  • - Download + Download

    Linux and macOS builds.

  • @@ -105,18 +76,16 @@

    Routes

    diff --git a/site/beta/index.html b/site/beta/index.html new file mode 100644 index 00000000..3c986b0c --- /dev/null +++ b/site/beta/index.html @@ -0,0 +1,76 @@ + + + + + + Rust web beta | Successor + + + + + + + + + + + +
    + + + Successor + + +
    +
    +
    +
    +
    +
    +
    + +

    Rust web beta

    +

    The new Rust WebGL2 client. Your characters, the live world.

    +

    Beta software can fail. The supported client stays at Play.

    + + + + +
    +
    + + +
    +
    +
    + + diff --git a/site/connect/index.html b/site/connect/index.html index 9e856ab5..e07c6244 100644 --- a/site/connect/index.html +++ b/site/connect/index.html @@ -4,13 +4,9 @@ Approve a device | Successor - + - - +
    -
    - - - Successor - - + + + Successor + + +
    +

    Approve a device

    -

    Enter the code shown by the client. Never enter your password there.

    +

    Enter the code the client shows. Never your password.

    -
    -

    - Codes last ten minutes. Approval lasts until you revoke it. -

    -
    +

    Codes last ten minutes. Approval lasts until you revoke it.

    @@ -136,19 +105,17 @@

    Devices you approved

    -