From 6043a65df8a3d571b39aa3b6605c87d799659a54 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Mon, 14 Sep 2026 22:11:41 +0800 Subject: [PATCH 1/3] moly: Add player data import and improve runtime reliability Add UID and JSON layout imports to Settings with previews, palette mapping, backup restoration, and matching site selection. Set the workspace version to 0.0.1 and add static CI without build steps. Order simulation dependencies, isolate browser renderer startup, coordinate settings writers, validate asset paths, and enforce pack identity and memory limits. Record checker process outcomes and run identity. --- .cargo/config.toml | 14 - .github/workflows/ci.yml | 35 + .gitignore | 2 + Cargo.lock | 875 +++++++++++++++++- Cargo.toml | 11 +- README.md | 60 ++ crates/moly-app/Cargo.toml | 10 +- crates/moly-app/examples/capture.rs | 1 + crates/moly-app/src/asset_source.rs | 51 +- crates/moly-app/src/lib.rs | 62 +- crates/moly-app/src/main.rs | 10 +- crates/moly-app/src/player_data_input.rs | 47 + crates/moly-app/src/render_backend.rs | 56 +- crates/moly-app/src/site_request.rs | 4 +- crates/moly-assets/Cargo.toml | 6 + crates/moly-assets/src/http.rs | 134 +++ crates/moly-assets/src/lib.rs | 11 + crates/moly-assets/src/packs.rs | 126 ++- crates/moly-assets/src/player_data.rs | 489 ++++++++++ crates/moly-assets/src/read_limits.rs | 150 +++ crates/moly-game/Cargo.toml | 9 +- crates/moly-game/src/balloon.rs | 3 + crates/moly-game/src/fence.rs | 4 +- crates/moly-game/src/fixture.rs | 159 ++-- crates/moly-game/src/fixture_attach.rs | 13 +- crates/moly-game/src/fixture_colors.rs | 290 ++++++ crates/moly-game/src/fixture_compact.rs | 12 +- crates/moly-game/src/fixture_edit.rs | 17 +- .../src/fixture_edit/presentation.rs | 7 +- .../moly-game/src/fixture_edit/validation.rs | 6 +- crates/moly-game/src/fixture_edit_ui.rs | 4 +- crates/moly-game/src/fixture_gallery.rs | 10 +- .../moly-game/src/fixture_layout_inventory.rs | 2 + crates/moly-game/src/fixture_layouts.rs | 79 +- crates/moly-game/src/fixture_material.rs | 6 +- crates/moly-game/src/fixture_scene_inputs.rs | 2 +- crates/moly-game/src/fixture_surfaces.rs | 3 +- crates/moly-game/src/game_settings.rs | 28 +- crates/moly-game/src/info.rs | 8 +- crates/moly-game/src/lib.rs | 111 +-- crates/moly-game/src/menu_dialog.rs | 8 +- .../src/npc_fixture_activity/areas.rs | 4 +- crates/moly-game/src/npc_objective.rs | 38 +- crates/moly-game/src/player_data.rs | 667 +++++++++++++ crates/moly-game/src/player_data_ui.rs | 308 ++++++ crates/moly-game/src/road.rs | 7 +- crates/moly-game/src/schedule.rs | 75 +- crates/moly-game/src/settings_store.rs | 113 ++- .../moly-game/src/shaders/sky_gradient.wgsl | 4 +- crates/moly-game/src/site.rs | 49 +- crates/moly-game/src/sky.rs | 13 +- crates/moly-law/src/fixture/position.rs | 64 ++ tools/balloon-order/check.mjs | 39 +- tools/boundary/check.mjs | 6 +- tools/version/check.py | 34 + web/boot.mjs | 51 + web/build-wasm.mjs | 41 +- web/index.html | 4 +- web/main.mjs | 86 +- web/player-api-worker.mjs | 11 + web/player-api.mjs | 51 + web/serve.mjs | 126 ++- 62 files changed, 4170 insertions(+), 556 deletions(-) delete mode 100644 .cargo/config.toml create mode 100644 .github/workflows/ci.yml create mode 100644 crates/moly-app/src/player_data_input.rs create mode 100644 crates/moly-assets/src/http.rs create mode 100644 crates/moly-assets/src/player_data.rs create mode 100644 crates/moly-assets/src/read_limits.rs create mode 100644 crates/moly-game/src/fixture_colors.rs create mode 100644 crates/moly-game/src/player_data.rs create mode 100644 crates/moly-game/src/player_data_ui.rs create mode 100644 tools/version/check.py create mode 100644 web/boot.mjs create mode 100644 web/player-api-worker.mjs create mode 100644 web/player-api.mjs diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index de9b2fe..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,14 +0,0 @@ -# Shared build cache. This repo and its sibling worktrees sit two levels below -# the cache directory, so the same relative path resolves there from any of -# them; registry dependencies then build once for all trees instead of once -# per tree. -# -# incremental = false: the cache is shared by every checkout of this repo. -# rustc keys incremental sessions by crate name only, so a session written by -# one checkout is reused by another whose sources differ — that produces -# archives mixing fresh metadata with stale object code, which then link into -# binaries that silently omit whole modules. Rebuilding from scratch every -# time is slower per edit but keeps every binary honest. -[build] -target-dir = "../../shared-target" -incremental = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4a07b7a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: Static checks + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: static-checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Check workspace version + run: python tools/version/check.py + - name: Check JavaScript syntax + shell: bash + run: git ls-files -z '*.mjs' | xargs -0 -r -n1 node --check + - name: Check public source boundary + run: node tools/boundary/check.mjs + - name: Check commit whitespace + run: git show --format= --check diff --git a/.gitignore b/.gitignore index b3058ce..50b2c63 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ !/screenshots/readme.png /*.exe /build-guarded.log +/balloon-order-*.log +/balloon-order-*.json diff --git a/Cargo.lock b/Cargo.lock index d2b61b9..e4cecfc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,8 +44,8 @@ dependencies = [ "accesskit_consumer", "hashbrown 0.15.5", "objc2 0.5.2", - "objc2-app-kit", - "objc2-foundation", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -175,6 +175,23 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "x11rb", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -202,6 +219,28 @@ dependencies = [ "libloading", ] +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "assert_type_match" version = "0.1.1" @@ -291,6 +330,64 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "async-task" version = "4.7.1" @@ -300,6 +397,17 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1862,6 +1970,15 @@ dependencies = [ "libloading", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "codespan-reporting" version = "0.12.0" @@ -2238,6 +2355,17 @@ dependencies = [ "objc2 0.6.4", ] +[[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.5", +] + [[package]] name = "disqualified" version = "1.0.0" @@ -2280,6 +2408,20 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "ehttp" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04499d3c719edecfad5c9b46031726c8540905d73be6d7e4f9788c4a298da908" +dependencies = [ + "document-features", + "js-sys", + "ureq", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "either" version = "1.18.0" @@ -2317,6 +2459,33 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2344,6 +2513,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "euclid" version = "0.22.14" @@ -2497,6 +2672,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[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-channel" version = "0.3.34" @@ -2555,8 +2739,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-io", "futures-macro", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -2581,6 +2767,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[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.3.4" @@ -2870,6 +3067,12 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hexasphere" version = "16.0.0" @@ -2887,6 +3090,110 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +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 = "image" version = "0.25.10" @@ -3171,6 +3478,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "litrs" version = "1.0.0" @@ -3234,6 +3547,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "metal" version = "0.32.0" @@ -3277,7 +3599,7 @@ dependencies = [ [[package]] name = "moly-app" -version = "0.1.0" +version = "0.0.1" dependencies = [ "bevy", "console_error_panic_hook", @@ -3290,35 +3612,44 @@ dependencies = [ [[package]] name = "moly-assets" -version = "0.1.0" +version = "0.0.1" dependencies = [ "async-lock", "bevy", "flate2", "futures-lite", "gltf", + "js-sys", "moly-law", "serde", "serde_json", "sha2", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] name = "moly-game" -version = "0.1.0" +version = "0.0.1" dependencies = [ + "arboard", "bevy", + "ehttp", "js-sys", "moly-assets", "moly-law", + "rfd", "serde_json", "swash", + "tempfile", + "wasm-bindgen-futures", "web-sys", ] [[package]] name = "moly-law" -version = "0.1.0" +version = "0.0.1" [[package]] name = "moxcms" @@ -3562,10 +3893,23 @@ dependencies = [ "objc2 0.5.2", "objc2-core-data", "objc2-core-image", - "objc2-foundation", + "objc2-foundation 0.2.2", "objc2-quartz-core", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-cloud-kit" version = "0.2.2" @@ -3576,7 +3920,7 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -3587,7 +3931,7 @@ checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -3599,7 +3943,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -3609,6 +3953,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", ] [[package]] @@ -3619,7 +3978,7 @@ checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", "objc2-metal", ] @@ -3632,7 +3991,7 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-contacts", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -3654,6 +4013,17 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-kit" version = "0.3.2" @@ -3665,6 +4035,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + [[package]] name = "objc2-link-presentation" version = "0.2.2" @@ -3673,8 +4054,8 @@ checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ "block2 0.5.1", "objc2 0.5.2", - "objc2-app-kit", - "objc2-foundation", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -3686,7 +4067,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -3698,7 +4079,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", "objc2-metal", ] @@ -3709,7 +4090,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" dependencies = [ "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -3725,7 +4106,7 @@ dependencies = [ "objc2-core-data", "objc2-core-image", "objc2-core-location", - "objc2-foundation", + "objc2-foundation 0.2.2", "objc2-link-presentation", "objc2-quartz-core", "objc2-symbols", @@ -3741,7 +4122,7 @@ checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -3754,7 +4135,7 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -3825,12 +4206,22 @@ dependencies = [ ] [[package]] -name = "owned_ttf_parser" -version = "0.25.1" +name = "ordered-stream" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" dependencies = [ - "ttf-parser", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", ] [[package]] @@ -3963,6 +4354,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "portable-atomic" version = "1.15.0" @@ -3978,6 +4375,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "pp-rs" version = "0.2.1" @@ -4215,6 +4621,44 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rodio" version = "0.20.1" @@ -4292,6 +4736,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -4403,6 +4882,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4435,6 +4925,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -4577,6 +5077,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svg_fmt" version = "0.4.5" @@ -4616,6 +5122,17 @@ dependencies = [ "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 = "sys-locale" version = "0.3.2" @@ -4651,6 +5168,19 @@ dependencies = [ "slotmap", ] +[[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 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -4734,6 +5264,16 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.13.2" @@ -4917,6 +5457,17 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -4959,6 +5510,53 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[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", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.26.0" @@ -5010,6 +5608,12 @@ dependencies = [ "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 = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -5202,6 +5806,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "wgpu" version = "27.0.1" @@ -5857,8 +6479,8 @@ dependencies = [ "memmap2", "ndk 0.9.0", "objc2 0.5.2", - "objc2-app-kit", - "objc2-foundation", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", "objc2-ui-kit", "orbclient", "percent-encoding", @@ -5909,6 +6531,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "x11-dl" version = "2.21.0" @@ -5978,6 +6606,99 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" +[[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 = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zeno" version = "0.3.3" @@ -6004,6 +6725,66 @@ dependencies = [ "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 = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "zlib-rs" version = "0.6.7" @@ -6015,3 +6796,45 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.5", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml index 158b7f9..09f5389 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,16 +9,13 @@ members = [ [workspace.package] edition = "2021" -version = "0.1.0" +version = "0.0.1" license = "AGPL-3.0-only" [workspace.dependencies] -# webgl2 + webgpu 两个后端都编进同一份 wasm 产物,运行时二选一 -# (moly-app 的 render_backend 探测后钉死;只开 webgpu 会让无 WebGPU 的 -# 浏览器在 adapter 请求上启动即 panic——0.18 的后端选择是编译期三选一, -# 且失败无重试)。debug 特性开着不关:翻转让共享缓存的 bevy 指纹整族 -# 失效、全员重编几百 MB。 -bevy = { version = "0.18.1", features = ["3d", "debug", "webgl2", "webgpu"] } +# The application adds WebGPU for its default build. The WebGL2 build omits +# that feature so the engine uses its required uniform padding and limits. +bevy = { version = "0.18.1", features = ["3d", "debug", "webgl2"] } serde_json = { version = "1", features = ["preserve_order"] } [profile.dev] diff --git a/README.md b/README.md index 8197ce0..2c7a389 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,66 @@ MYSEKAI的还原项目,使用 Rust 与 [Bevy](https://bevyengine.org/) 构建 运行所需的模型、贴图、动作和音频由使用者自行提供,不随仓库分发。 项目代码与游戏资源的权利归属相互独立。 +## 本地运行 + +安装稳定版 Rust 和 Node.js 22,将 `MOLY_ASSET_ROOT` 指向已提取的资源目录, +运行 `cargo run --release -p moly-app`。构建默认写入仓库的 `target/`;需要共享缓存时, +自行设置 `CARGO_TARGET_DIR`,增量编译可通过 `CARGO_INCREMENTAL` 配置。 + +浏览器版需要 `wasm32-unknown-unknown` target 和与 Cargo.lock 一致的 wasm-bindgen CLI: + +```sh +rustup target add wasm32-unknown-unknown +cargo install wasm-bindgen-cli --version 0.2.127 --locked +node web/build-wasm.mjs +node web/serve.mjs +``` + +打开开发服务器给出的 `?assets=/assets/` 地址。启动页先检查图形支持,点击开始时启用音频。 +构建脚本分别生成 `web/pkg/webgpu/` 和 `web/pkg/webgl2/`,页面只下载所选后端的模块。 +WebGPU 不可用时会选择 WebGL2;启动失败页可重新加载,或用 `?renderer=webgl2` 重试。 +每次重试都会重新加载页面。浏览器同时只允许一个页面写入存档,其余页面可浏览; +关闭可写页面后,重新加载其他页面可申请写入权限。不支持 Web Locks 的环境保持只读。 + +开发服务器只监听回环地址。资源目录应可信且只读,目录内链接的实际目标必须仍在同一挂载根内。 +分包模式(`?packs=1`)限制目录/清单为 16 MiB、单 blob 为 128 MiB、单个解码资产为 +256 MiB(分包载荷),解压大小最多为 blob 大小的 256 倍加 64 KiB。最多同时读取 8 份资源,传输与解码 +缓冲合计预算为 512 MiB。浏览器分包响应逐块读取,超限或超时即中止;这些预算不包含 +解析后的目录、资源及场景对象、GPU 资源和浏览器内部缓存。 + +## 玩家数据导入 + +打开 **Settings(F10)→ Player data**,选择与资源一致的区服,输入 UID 并点击 +**Fetch player**,或使用 **Choose JSON** 读取玩家数据。确认预览后点击 +**Import preview**,即可加载家具布局、配色及等级对应的场地大小。 +原生程序也支持拖入 JSON 文件。支持原始 Home 响应及 `updatedResources` 包装。 +导入后会进入对应场景,重启时默认打开已导入的主页;显式站点参数仍可覆盖启动位置。 + +导入保留一份原布局备份,**Restore backup** 可以恢复;音量、画质及其他设置保持原值。 +有未保存的家具编辑、数据无效或资源不匹配时不会覆盖存档。自定义图片、饰件和墙纸地板 +记录会保留,但这些外观的渲染尚未接入。 + +资源根需要提供 `fixture-models/player-data.json` 和其中引用的配色贴图。该目录包含 +区服及家具、场地、等级解锁主表,不含玩家数据。各区服需使用对应的主表和模型资源。 + +原生默认从 `https://haruki-api.menardi.top/api/{region}/mysekai/{target_user_id}` 获取数据。 +可通过 `MOLY_PLAYER_API` 覆盖 URL 模板,`MOLY_PLAYER_REGION` 选择 `cn` 或 `jp`。 +`MOLY_PLAYER_UID` 在启动时获取并预览数据;`MOLY_PLAYER_DATA_FILE` 在启动时导入指定 JSON。 +UID 与文件启动参数只能选择一种。 + +浏览器版使用同源 `/player-api/{region}/{target_user_id}`,开发服务器 `web/serve.mjs` +已包含转发。使用 Cloudflare 静态资源绑定时,可使用 `web/player-api-worker.mjs`。 +页面参数 `player_uid`、`player_region`、`player_api` 可覆盖获取输入。 + +## 版本与 CI + +版本统一定义在 `Cargo.toml` 的 `[workspace.package]` 中,所有 crate 继承该版本。 +设置面板显示当前版本;原生程序可通过 `moly-app --version` 查询,无需加载资源。 + +GitHub Actions 在推送、拉取请求和手动触发时执行版本一致性、JavaScript 语法、 +开源边界及提交空白检查。CI 不编译、不打包,也不需要游戏资源。 +版本和边界检查可在本地运行 `python tools/version/check.py`、`node tools/boundary/check.mjs`。 + ## 代码结构 | 目录 | 职责 | diff --git a/crates/moly-app/Cargo.toml b/crates/moly-app/Cargo.toml index c4f9902..136289e 100644 --- a/crates/moly-app/Cargo.toml +++ b/crates/moly-app/Cargo.toml @@ -4,6 +4,10 @@ version.workspace = true edition.workspace = true license.workspace = true +[features] +default = ["webgpu"] +webgpu = ["bevy/webgpu"] + [dependencies] bevy = { workspace = true } moly-assets = { path = "../moly-assets" } @@ -16,8 +20,6 @@ moly-game = { path = "../moly-game" } wasm-bindgen = "=0.2.127" # wasm 的 panic 默认只剩一句 RuntimeError;资产源的拒绝要整句落进控制台。 console_error_panic_hook = "0.1" -# Navigator 供 render_backend 取 navigator 对象;gpu 属性本身用 js-sys 的 -# Reflect 读(见该模块注释——web-sys 的 Gpu 绑定还挂在 web_sys_unstable_apis -# 门后,不为此开 rustflags)。 -web-sys = { version = "0.3", features = ["Window", "Location", "UrlSearchParams", "Navigator"] } +# Browser startup events and same-origin page parameters. +web-sys = { version = "0.3", features = ["Window", "Location", "UrlSearchParams", "Navigator", "Event", "EventTarget", "CustomEvent", "CustomEventInit", "Url"] } js-sys = "0.3" diff --git a/crates/moly-app/examples/capture.rs b/crates/moly-app/examples/capture.rs index 9e13418..39a580c 100644 --- a/crates/moly-app/examples/capture.rs +++ b/crates/moly-app/examples/capture.rs @@ -19,6 +19,7 @@ fn main() { let source = moly_app::asset_source::resolve().expect("asset source"); let site = moly_app::site_request::resolve().expect("site request"); let mut app = moly_game::app(source, site); + moly_app::player_data_input::configure(&mut app).expect("player data inputs"); app.add_systems( Update, move |mut commands: Commands, time: Res>, mut requested: Local| { diff --git a/crates/moly-app/src/asset_source.rs b/crates/moly-app/src/asset_source.rs index ef327c9..ea36f6a 100644 --- a/crates/moly-app/src/asset_source.rs +++ b/crates/moly-app/src/asset_source.rs @@ -8,6 +8,21 @@ use moly_assets::AssetSource; +/// Asset roots are canonical same-origin directory paths, not URL references. +pub fn validate_asset_prefix(base: &str) -> Result<(), String> { + if !base.starts_with('/') + || base.starts_with("//") + || !base.ends_with('/') + || base + .bytes() + .any(|b| b <= b' ' || b == 0x7f || matches!(b, b'\\' | b'%' | b'?' | b'#')) + || base.split('/').any(|part| matches!(part, "." | "..")) + { + return Err("?assets= must be a canonical same-origin directory path ending in /; URL references, encoded separators, query and fragment are not allowed".into()); + } + Ok(()) +} + #[cfg(not(target_arch = "wasm32"))] pub fn resolve() -> Result { let raw = std::env::var("MOLY_ASSET_ROOT").map_err(|_| { @@ -23,14 +38,17 @@ pub fn resolve() -> Result { path.display() )); } - if path.join("asset-packs.json").is_file() { Ok(AssetSource::NativePacks { path }) } - else { Ok(AssetSource::NativeDir { path }) } + if path.join("asset-packs.json").is_file() { + Ok(AssetSource::NativePacks { path }) + } else { + Ok(AssetSource::NativeDir { path }) + } } #[cfg(target_arch = "wasm32")] pub fn resolve() -> Result { - let window = web_sys::window() - .ok_or_else(|| "the wasm build only runs inside a page".to_owned())?; + let window = + web_sys::window().ok_or_else(|| "the wasm build only runs inside a page".to_owned())?; let search = window .location() .search() @@ -40,18 +58,19 @@ pub fn resolve() -> Result { let base = params.get("assets").ok_or_else(|| { "missing ?assets=/ — the HTTP base assets are fetched from".to_owned() })?; - if base.is_empty() { - return Err("?assets= is empty; it must be a same-origin path prefix ending in /".into()); - } - if !base.starts_with('/') { - return Err(format!( - "?assets={base} must be a same-origin absolute path starting with /" - )); - } - if !base.ends_with('/') { - return Err(format!( - "?assets={base} must end in / — it is a prefix asset keys are appended to" - )); + validate_asset_prefix(&base)?; + let location = window.location(); + let page = location.href().map_err(|_| "Could not read page URL")?; + let url = web_sys::Url::new_with_base(&base, &page).map_err(|_| "Invalid asset URL prefix")?; + if url.origin() + != location + .origin() + .map_err(|_| "Could not read page origin")? + || url.pathname() != base + || !url.search().is_empty() + || !url.hash().is_empty() + { + return Err("?assets= must resolve to a canonical path on the page origin".into()); } match params.get("packs").as_deref() { Some("1") => Ok(AssetSource::HttpPacks { url: base }), diff --git a/crates/moly-app/src/lib.rs b/crates/moly-app/src/lib.rs index 6fd6c52..c3e43ce 100644 --- a/crates/moly-app/src/lib.rs +++ b/crates/moly-app/src/lib.rs @@ -1,7 +1,8 @@ -//! 入口库:native 与 wasm 共用一条启动路径。wasm 业务导出面为 0—— -//! 仅 `start` 一个导出;native 走 `main.rs`。 +//! Shared application startup. The browser supplies its preflighted renderer +//! and the lifetime of its exclusive storage lease. pub mod asset_source; +pub mod player_data_input; pub mod site_request; #[cfg(target_arch = "wasm32")] @@ -15,7 +16,9 @@ use wasm_bindgen::prelude::wasm_bindgen; /// 起一次 app。资产源与站点选择在这里解析、经 `moly_game::app` 装上—— /// 消费方读资源,不要再各自去读 env 或 URL(拒绝点全树只在 `asset_source` /// 与 `site_request` 两处入口)。 -pub fn run() { +fn run_app( + #[cfg(target_arch = "wasm32")] web_render_settings: bevy::render::settings::WgpuSettings, +) { let source = match asset_source::resolve() { Ok(source) => source, Err(message) => fail_loud(&message), @@ -27,14 +30,31 @@ pub fn run() { // 渲染后端只在 wasm 分支选(取舍在 `render_backend`);native 展开后 // 与双后端升级前逐行一致。 #[cfg(target_arch = "wasm32")] - let mut app = moly_game::app(source, site, render_backend::settings()); + let mut app = moly_game::app(source, site, web_render_settings); #[cfg(not(target_arch = "wasm32"))] let mut app = moly_game::app(source, site); + if let Err(message) = player_data_input::configure(&mut app) { + fail_loud(&message); + } #[cfg(target_arch = "wasm32")] - attach_canvas(&mut app); + { + attach_canvas(&mut app); + app.add_systems(Startup, || { + if let Some(window) = web_sys::window() { + if let Ok(event) = web_sys::Event::new("moly-ready") { + let _ = window.dispatch_event(&event); + } + } + }); + } app.run(); } +#[cfg(not(target_arch = "wasm32"))] +pub fn run() { + run_app(); +} + #[cfg(not(target_arch = "wasm32"))] fn fail_loud(message: &str) -> ! { eprintln!("moly-app: {message}"); @@ -67,10 +87,34 @@ fn attach_canvas(app: &mut App) { window.fit_canvas_to_parent = true; } -/// wasm 侧唯一导出:起一次 app。 +/// Starts one browser application synchronously inside the trusted click. +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub fn start(backend: &str, writable: bool) -> Result<(), wasm_bindgen::JsValue> { + std::panic::set_hook(Box::new(|info| { + // Disable writes before notifying JavaScript; the host may release its lease. + moly_game::set_browser_storage_writable(false); + if let Some(window) = web_sys::window() { + let message = info.payload().downcast_ref::().map(String::as_str) + .or_else(|| info.payload().downcast_ref::<&str>().copied()) + .unwrap_or("Unexpected game error"); + let init = web_sys::CustomEventInit::new(); + init.set_detail(&message.into()); + if let Ok(event) = web_sys::CustomEvent::new_with_event_init_dict("moly-error", &init) { + let _ = window.dispatch_event(&event); + } + } + console_error_panic_hook::hook(info); + })); + let settings = render_backend::settings(backend) + .map_err(|error| wasm_bindgen::JsValue::from_str(&error))?; + set_storage_writable(writable); + run_app(settings); + Ok(()) +} + #[cfg(target_arch = "wasm32")] #[wasm_bindgen] -pub fn start() { - console_error_panic_hook::set_once(); - run(); +pub fn set_storage_writable(writable: bool) { + moly_game::set_browser_storage_writable(writable); } diff --git a/crates/moly-app/src/main.rs b/crates/moly-app/src/main.rs index 43ade16..8313c1a 100644 --- a/crates/moly-app/src/main.rs +++ b/crates/moly-app/src/main.rs @@ -1,4 +1,4 @@ -//! 入口。native 开窗;wasm 是一次 `App::run()`,业务导出面为 0。 +//! Native window entry; the browser bootstrap invokes the library start export. //! //! wasm 下本 bin 不是入口:rustc 会把 bin 的 main 放进 wasm 的 start //! section,wasm-bindgen 把它搬成 `__wbindgen_start` 在实例化时自动执行, @@ -8,14 +8,18 @@ //! //! 但也不能写成空 main:链接器会把「没有任何引用的」wasm-bindgen 运行时 //! 内建导出 GC 掉,wasm-bindgen 随即报 clone_ref intrinsic 缺失。这里引用 -//! run 而不调用,整张依赖图保持可达。 +//! start 而不调用,整张依赖图保持可达。 #[cfg(target_arch = "wasm32")] fn main() { - let _run_referenced_not_called: fn() = moly_app::run; + let _start_referenced_not_called = moly_app::start; } #[cfg(not(target_arch = "wasm32"))] fn main() { + if std::env::args().skip(1).any(|arg| matches!(arg.as_str(), "--version" | "-V")) { + println!("moly {}", moly_game::VERSION); + return; + } moly_app::run(); } diff --git a/crates/moly-app/src/player_data_input.rs b/crates/moly-app/src/player_data_input.rs new file mode 100644 index 0000000..aacfd4e --- /dev/null +++ b/crates/moly-app/src/player_data_input.rs @@ -0,0 +1,47 @@ +//! Optional startup inputs for the shared player-data importer. + +use bevy::prelude::App; +use moly_game::player_data::PlayerDataImport; + +#[cfg(not(target_arch = "wasm32"))] +pub fn configure(app: &mut App) -> Result<(), String> { + let env = |name: &str| -> Result, String> { + match std::env::var(name) { + Ok(value) => Ok(Some(value.trim().to_owned())), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(_) => Err(format!("{name} is not Unicode")), + } + }; + let uid = env("MOLY_PLAYER_UID")?; + let file = std::env::var_os("MOLY_PLAYER_DATA_FILE"); + if uid.is_some() && file.is_some() { + return Err("Choose either MOLY_PLAYER_UID or MOLY_PLAYER_DATA_FILE".into()); + } + let mut state = app.world_mut().resource_mut::(); + state.configure(env("MOLY_PLAYER_API")?, env("MOLY_PLAYER_REGION")?)?; + if let Some(uid) = uid { + state.request_uid(uid); + } + if let Some(file) = file { + let text = moly_game::player_data::read_json_file(std::path::Path::new(&file))?; + state.request_json(text, true); + } + Ok(()) +} + +#[cfg(target_arch = "wasm32")] +pub fn configure(app: &mut App) -> Result<(), String> { + let window = web_sys::window().ok_or("Browser window is unavailable")?; + let search = window + .location() + .search() + .map_err(|_| "Could not read player import parameters")?; + let params = web_sys::UrlSearchParams::new_with_str(&search) + .map_err(|_| "Invalid player import parameters")?; + let mut state = app.world_mut().resource_mut::(); + state.configure(params.get("player_api"), params.get("player_region"))?; + if let Some(uid) = params.get("player_uid") { + state.request_uid(uid); + } + Ok(()) +} diff --git a/crates/moly-app/src/render_backend.rs b/crates/moly-app/src/render_backend.rs index 16707b1..ff5483c 100644 --- a/crates/moly-app/src/render_backend.rs +++ b/crates/moly-app/src/render_backend.rs @@ -1,55 +1,21 @@ -//! wasm 渲染后端选择:WebGPU 优先、WebGL2 兜底,运行时定。 -//! -//! 0.18 的后端选择是编译期三选一(bevy_render `settings.rs`:`webgl` 且非 -//! `webgpu` → GL;`webgpu` → 仅 BROWSER_WEBGPU),workspace 两个特性都开后 -//! 默认值落在 BROWSER_WEBGPU,且 adapter 请求失败没有重试 -//! (bevy_render `renderer/mod.rs` 直接 expect panic)——所以必须在起 app -//! 前探测、把后端钉死,否则无 WebGPU 的浏览器启动即炸。 +//! Consume the backend selected by the browser's asynchronous preflight. use bevy::render::settings::{Backends, WgpuLimits, WgpuSettings}; -/// 探测 `navigator.gpu`,按结果给 `RenderPlugin.render_creation` 用的设置。 -/// -/// 取 Rust 侧直查(读 `navigator.gpu` 属性、`is_undefined()` 判在不在) -/// 不取 web 壳注入全局标志:拒绝点与消费点同在入口 crate,壳保持 -/// 只管 bootstrap;全局标志要跨语言传、且在页面脚本层可被改动,直查每次 -/// 加载拿浏览器真值。代价只是 wasm 多编一组属性读。 -/// -/// 属性读取走 js-sys 的 `Reflect.get`,不用 web-sys 的 `Navigator::gpu()` -/// 绑定——后者还挂在 `web_sys_unstable_apis` cfg 门后,为它给树加 -/// rustflags 会改共享构建缓存的指纹面(全体工作树连带复编)。Reflect -/// 与真实属性读同语义(走 getter 链;属性缺席返回 undefined),且 -/// js-sys 已是 web-sys 的既有依赖,不新增锁内包。 -/// -/// 两臂 limits 都显式给:两特性同开时 `WgpuSettings::default()` 的 limits -/// 是全量默认值,GL 臂若靠默认值会丢掉 webgl2 的保守上限。WebGPU 臂吃全 -/// 量默认(等价 webgpu 单特性时代的形状)。 -/// -/// 探测只认 `navigator.gpu` 的在场性:暴露了该属性但 `requestAdapter` 失败 -/// 的环境仍会在 Bevy 的 adapter 请求上 panic——0.18 无重试,异步先探 -/// adapter 要改导出面为 async,超出本单(见 REPORT 挂账)。 -pub(crate) fn settings() -> WgpuSettings { - let webgpu = web_sys::window() - .map(|window| has_navigator_gpu(window.navigator().as_ref())) - .unwrap_or(false); - if webgpu { - WgpuSettings { +pub(crate) fn settings(backend: &str) -> Result { + if (backend == "webgpu") != cfg!(feature = "webgpu") { + return Err("The selected renderer requires its matching browser module".into()); + } + match backend { + "webgpu" => Ok(WgpuSettings { backends: Some(Backends::BROWSER_WEBGPU), ..Default::default() - } - } else { - // 照 `webgl` 单特性时代 `WgpuSettings::default()` 给 GL 的形状。 - WgpuSettings { + }), + "webgl2" => Ok(WgpuSettings { backends: Some(Backends::GL), limits: WgpuLimits::downlevel_webgl2_defaults(), ..Default::default() - } + }), + _ => Err("A preflighted webgpu or webgl2 backend is required".into()), } } - -/// `navigator.gpu` 在不在(读属性触发 getter 链;不存在/读失败都算不在)。 -fn has_navigator_gpu(navigator: &wasm_bindgen::JsValue) -> bool { - js_sys::Reflect::get(navigator, &"gpu".into()) - .map(|value| !value.is_undefined()) - .unwrap_or(false) -} diff --git a/crates/moly-app/src/site_request.rs b/crates/moly-app/src/site_request.rs index ed02cdc..db02ad1 100644 --- a/crates/moly-app/src/site_request.rs +++ b/crates/moly-app/src/site_request.rs @@ -31,7 +31,7 @@ pub fn resolve() -> Result { let site = match env("MOLY_SITE")? { Some(raw) if !raw.trim().is_empty() => raw.trim().to_owned(), Some(_) => return Err("MOLY_SITE is set but empty; it names a site type".into()), - None => DEFAULT_SITE.to_owned(), + None => moly_game::player_data::preferred_site().unwrap_or_else(|| DEFAULT_SITE.to_owned()), }; let room_level = parse_level(env("MOLY_ROOM_LEVEL")?.as_deref(), "MOLY_ROOM_LEVEL")?; let offline_home_level = parse_level(env("MOLY_OFFLINE_HOME_LEVEL")?.as_deref(), "MOLY_OFFLINE_HOME_LEVEL")?; @@ -48,7 +48,7 @@ pub fn resolve() -> Result { let site = match params.get("site") { Some(raw) if !raw.trim().is_empty() => raw.trim().to_owned(), Some(_) => return Err("?site= is empty; it names a site type".into()), - None => DEFAULT_SITE.to_owned(), + None => moly_game::player_data::preferred_site().unwrap_or_else(|| DEFAULT_SITE.to_owned()), }; let room_level = parse_level(params.get("level").as_deref(), "level")?; let offline_home_level = parse_level(params.get("offline_home_level").as_deref(), "offline_home_level")?; diff --git a/crates/moly-assets/Cargo.toml b/crates/moly-assets/Cargo.toml index 2e71249..b1907a8 100644 --- a/crates/moly-assets/Cargo.toml +++ b/crates/moly-assets/Cargo.toml @@ -16,3 +16,9 @@ async-lock = "3" flate2 = "1" futures-lite = "2" sha2 = "0.10" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = ["Window", "AbortController", "AbortSignal", "ReadableStream", "ReadableStreamDefaultReader", "Request", "RequestInit", "RequestMode", "RequestRedirect", "Response", "Headers"] } diff --git a/crates/moly-assets/src/http.rs b/crates/moly-assets/src/http.rs new file mode 100644 index 0000000..d886f9f --- /dev/null +++ b/crates/moly-assets/src/http.rs @@ -0,0 +1,134 @@ +//! Stream bounded pack responses; do not materialize an unbounded ArrayBuffer. + +use bevy::asset::io::AssetReaderError; +use wasm_bindgen::{closure::Closure, JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; +use web_sys::{ + AbortController, ReadableStreamDefaultReader, Request, RequestInit, RequestMode, + RequestRedirect, Response, +}; + +fn error(message: impl Into) -> AssetReaderError { + std::io::Error::other(message.into()).into() +} + +struct RequestGuard { + controller: AbortController, + window: web_sys::Window, + timer: i32, + _callback: Closure, +} + +impl RequestGuard { + fn new() -> Result { + let window = + web_sys::window().ok_or_else(|| error("HTTP asset reads require a browser window"))?; + let controller = + AbortController::new().map_err(|e| error(format!("Create asset request: {e:?}")))?; + let abort = controller.clone(); + let callback = Closure::wrap(Box::new(move || abort.abort()) as Box); + let timer = window + .set_timeout_with_callback_and_timeout_and_arguments_0( + callback.as_ref().unchecked_ref(), + 60_000, + ) + .map_err(|e| error(format!("Set asset request timeout: {e:?}")))?; + Ok(Self { + controller, + window, + timer, + _callback: callback, + }) + } +} + +impl Drop for RequestGuard { + fn drop(&mut self) { + self.window.clear_timeout_with_handle(self.timer); + self.controller.abort(); + } +} + +pub(crate) async fn read_bytes( + root: &str, + path: &str, + limit: usize, +) -> Result, AssetReaderError> { + let guard = RequestGuard::new()?; + let encoded = path + .split('/') + .map(|part| String::from(js_sys::encode_uri_component(part))) + .collect::>() + .join("/"); + let url = format!("{root}{encoded}"); + let init = RequestInit::new(); + init.set_mode(RequestMode::SameOrigin); + init.set_redirect(RequestRedirect::Error); + init.set_signal(Some(&guard.controller.signal())); + let request = Request::new_with_str_and_init(&url, &init) + .map_err(|e| error(format!("Invalid asset request: {e:?}")))?; + let response: Response = JsFuture::from(guard.window.fetch_with_request(&request)) + .await + .map_err(|e| error(format!("Asset HTTP request failed: {e:?}")))? + .dyn_into() + .map_err(|_| error("Asset HTTP response is invalid"))?; + match response.status() { + 200 => {} + 403 | 404 => return Err(AssetReaderError::NotFound(path.into())), + status => return Err(AssetReaderError::HttpError(status)), + } + if response + .headers() + .get("content-length") + .ok() + .flatten() + .and_then(|value| value.parse::().ok()) + .is_some_and(|bytes| bytes > limit as u64) + { + return Err(error(format!( + "Asset HTTP response exceeds its byte limit: {path}" + ))); + } + let mut bytes = crate::read_limits::buffer( + limit + .checked_add(1) + .ok_or_else(|| error("Asset limit overflow"))?, + )?; + let Some(body) = response.body() else { + return Ok(bytes); + }; + let reader: ReadableStreamDefaultReader = body + .get_reader() + .dyn_into() + .map_err(|_| error("Could not open asset response stream"))?; + loop { + let chunk = JsFuture::from(reader.read()) + .await + .map_err(|e| error(format!("Read asset response: {e:?}")))?; + if js_sys::Reflect::get(&chunk, &JsValue::from_str("done")) + .ok() + .and_then(|v| v.as_bool()) + == Some(true) + { + break; + } + let chunk: js_sys::Uint8Array = js_sys::Reflect::get(&chunk, &JsValue::from_str("value")) + .map_err(|_| error("Asset stream has no bytes"))? + .dyn_into() + .map_err(|_| error("Asset stream is not binary"))?; + let end = bytes + .len() + .checked_add(chunk.length() as usize) + .filter(|end| *end <= limit) + .ok_or_else(|| { + error(format!( + "Asset HTTP response exceeds its byte limit: {path}" + )) + })?; + let start = bytes.len(); + bytes.resize(end, 0); + chunk.copy_to(&mut bytes[start..]); + } + reader.release_lock(); + Ok(bytes) +} diff --git a/crates/moly-assets/src/lib.rs b/crates/moly-assets/src/lib.rs index 91a61e1..fe26ba2 100644 --- a/crates/moly-assets/src/lib.rs +++ b/crates/moly-assets/src/lib.rs @@ -8,8 +8,12 @@ pub mod material_passes; pub mod material_textures; pub mod scene_state; pub mod source_navigation; +pub mod player_data; pub mod ui_layout; mod packs; +mod read_limits; +#[cfg(target_arch = "wasm32")] +mod http; use bevy::app::App; use bevy::asset::{io::AssetSourceBuilder, AssetApp, AssetPath}; @@ -38,6 +42,13 @@ pub enum AssetSource { /// 必须在 `DefaultPlugins` 之前调用:AssetPlugin 构建时固化全部资产源, /// 之后注册只打一行 error 并被丢弃。 pub fn install(app: &mut App, source: AssetSource) { + #[cfg(target_arch = "wasm32")] + if let AssetSource::HttpPacks { url } = &source { + let root = url.clone(); + app.register_asset_source(SOURCE, AssetSourceBuilder::new(move || Box::new(packs::PackReader::http(root.clone())))); + app.insert_resource(source); + return; + } let root = match &source { AssetSource::NativeDir { path } | AssetSource::NativePacks { path } => path.to_string_lossy().to_string(), AssetSource::HttpBase { url } | AssetSource::HttpPacks { url } => url.clone(), diff --git a/crates/moly-assets/src/packs.rs b/crates/moly-assets/src/packs.rs index bbb7a49..f2eede3 100644 --- a/crates/moly-assets/src/packs.rs +++ b/crates/moly-assets/src/packs.rs @@ -1,5 +1,9 @@ //! Resolve logical asset paths through versioned manifests without preloading a package. +use crate::read_limits::{ + self, Budget, Buffer, SharedReader, MAX_ASSET_BYTES, MAX_BLOB_BYTES, MAX_DOCUMENT_BYTES, + MAX_EXPANSION_RATIO, +}; use async_lock::OnceCell; use bevy::asset::io::{ AssetReader, AssetReaderError, ErasedAssetReader, PathStream, Reader, VecReader, @@ -65,12 +69,29 @@ struct Entry { xf: Option, } -type InFlight = OnceCell>; +#[derive(Hash, PartialEq, Eq)] +struct Representation { + path: String, + codec: String, + blob_bytes: usize, + bytes: usize, + blob_sha256: String, + content_sha256: String, +} + +type InFlight = OnceCell>; + +enum Source { + Reader(Box), + #[cfg(target_arch = "wasm32")] + Http(String), +} pub(crate) struct PackReader { - inner: Box, + source: Source, catalog: OnceCell, - in_flight: Mutex>>, + in_flight: Mutex>>, + budget: Arc, } fn invalid(message: impl Into) -> AssetReaderError { @@ -99,25 +120,66 @@ fn key(path: &Path) -> Result { impl PackReader { pub(crate) fn new(inner: Box) -> Self { Self { - inner, + source: Source::Reader(inner), catalog: OnceCell::new(), in_flight: Mutex::new(HashMap::new()), + budget: read_limits::shared_budget(), } } - async fn bytes(&self, path: &str) -> Result, AssetReaderError> { + #[cfg(target_arch = "wasm32")] + pub(crate) fn http(root: String) -> Self { + Self { + source: Source::Http(root), + catalog: OnceCell::new(), + in_flight: Mutex::new(HashMap::new()), + budget: read_limits::shared_budget(), + } + } + + async fn bytes(&self, path: &str, limit: usize) -> Result, AssetReaderError> { let path = key(Path::new(path))?; - let mut reader = self.inner.read(Path::new(&path)).await?; - let mut bytes = Vec::new(); - reader.read_to_end(&mut bytes).await?; + let inner = match &self.source { + Source::Reader(inner) => inner, + #[cfg(target_arch = "wasm32")] + Source::Http(root) => return crate::http::read_bytes(root, &path, limit).await, + }; + let mut reader = inner.read(Path::new(&path)).await?; + let mut bytes = read_limits::buffer( + limit + .checked_add(1) + .ok_or_else(|| invalid("Asset byte limit overflow"))?, + )?; + let mut chunk = [0u8; 64 * 1024]; + loop { + let remaining = (limit + 1 - bytes.len()).min(chunk.len()); + let count = + futures_lite::AsyncReadExt::read(&mut reader, &mut chunk[..remaining]).await?; + if count == 0 { + break; + } + bytes.extend_from_slice(&chunk[..count]); + if bytes.len() > limit { + return Err(invalid(format!("Asset exceeds its byte limit: {path}"))); + } + } Ok(bytes) } + async fn document_bytes(&self, path: &str) -> Result { + let _slot = self.budget.slots.acquire().await; + let reservation = self.budget.reserve(MAX_DOCUMENT_BYTES + 1)?; + Ok(Buffer { + bytes: self.bytes(path, MAX_DOCUMENT_BYTES).await?, + _reservation: reservation, + }) + } + async fn catalog(&self) -> Result<&Catalog, AssetReaderError> { self.catalog .get_or_try_init(|| async { - let bytes = self.bytes("asset-packs.json").await?; - let document: CatalogDocument = serde_json::from_slice(&bytes) + let bytes = self.document_bytes("asset-packs.json").await?; + let document: CatalogDocument = serde_json::from_slice(&bytes.bytes) .map_err(|e| invalid(format!("asset pack catalog: {e}")))?; if document.schema != "moly-asset-packs/1" { return Err(invalid("unsupported asset pack catalog")); @@ -170,8 +232,8 @@ impl PackReader { package .loaded .get_or_try_init(|| async { - let bytes = self.bytes(&package.document.manifest).await?; - let document: ManifestDocument = serde_json::from_slice(&bytes) + let bytes = self.document_bytes(&package.document.manifest).await?; + let document: ManifestDocument = serde_json::from_slice(&bytes.bytes) .map_err(|e| invalid(format!("package {}: {e}", package.document.id)))?; if document.schema != "moly-asset-manifest/1" || document.version != version { return Err(invalid(format!( @@ -182,6 +244,12 @@ impl PackReader { let mut entries = HashMap::new(); for entry in document.entries { let path = key(Path::new(&entry.path))?; + let expansion_limit = entry.blob_bytes.checked_mul(MAX_EXPANSION_RATIO) + .and_then(|value| value.checked_add(64 * 1024)) + .ok_or_else(|| invalid("Asset expansion limit overflow"))?; + if entry.blob_bytes > MAX_BLOB_BYTES || entry.bytes > MAX_ASSET_BYTES || entry.bytes > expansion_limit { + return Err(invalid(format!("Packed asset exceeds transfer, decoded-size or expansion budget: {path}"))); + } if entry.xf.is_some() || entry.http_encoding != "identity" || !matches!(entry.codec.as_str(), "identity" | "gzip") @@ -222,24 +290,39 @@ impl PackReader { .await } - async fn payload(&self, path: &str, entry: &Entry) -> Result, AssetReaderError> { + async fn payload(&self, path: &str, entry: &Entry) -> Result, AssetReaderError> { + let identity = Representation { + path: key(Path::new(path))?, + codec: entry.codec.clone(), + blob_bytes: entry.blob_bytes, + bytes: entry.bytes, + blob_sha256: entry.blob_sha256.clone(), + content_sha256: entry.content_sha256.clone(), + }; let flight = { let mut flights = self .in_flight .lock() .map_err(|_| invalid("asset request state unavailable"))?; - if let Some(flight) = flights.get(path).and_then(Weak::upgrade) { + if let Some(flight) = flights.get(&identity).and_then(Weak::upgrade) { flight } else { flights.retain(|_, value| value.strong_count() != 0); let flight = Arc::new(InFlight::new()); - flights.insert(path.to_owned(), Arc::downgrade(&flight)); + flights.insert(identity, Arc::downgrade(&flight)); flight } }; let bytes = flight .get_or_try_init(|| async { - let bytes = self.bytes(path).await?; + let _slot = self.budget.slots.acquire().await; + let peak = entry + .blob_bytes + .checked_add(entry.bytes) + .and_then(|value| value.checked_add(2)) + .ok_or_else(|| invalid("Asset buffer size overflow"))?; + let mut reservation = self.budget.reserve(peak)?; + let bytes = self.bytes(path, entry.blob_bytes).await?; if bytes.len() != entry.blob_bytes || format!("{:x}", Sha256::digest(&bytes)) != entry.blob_sha256 { @@ -249,10 +332,11 @@ impl PackReader { ))); } let decoded = if entry.codec == "gzip" { - let mut decoded = Vec::with_capacity(entry.bytes); + let mut decoded = read_limits::buffer(entry.bytes + 1)?; flate2::read::GzDecoder::new(bytes.as_slice()) .take(entry.bytes as u64 + 1) .read_to_end(&mut decoded)?; + drop(bytes); decoded } else { bytes @@ -265,7 +349,11 @@ impl PackReader { entry.path ))); } - Ok::, AssetReaderError>(decoded.into()) + reservation.shrink_to(decoded.capacity()); + Ok::, AssetReaderError>(Arc::new(Buffer { + bytes: decoded, + _reservation: reservation, + })) }) .await?; Ok(bytes.clone()) @@ -289,7 +377,7 @@ impl AssetReader for PackReader { .ok_or_else(|| AssetReaderError::NotFound(path.to_owned()))?; let path = format!("{}{}", manifest.blob_prefix, entry.blob); let bytes = self.payload(&path, entry).await?; - Ok(VecReader::new(bytes.to_vec())) + Ok(SharedReader::new(bytes)) } async fn read_meta<'a>(&'a self, path: &'a Path) -> Result { diff --git a/crates/moly-assets/src/player_data.rs b/crates/moly-assets/src/player_data.rs new file mode 100644 index 0000000..10e7a0e --- /dev/null +++ b/crates/moly-assets/src/player_data.rs @@ -0,0 +1,489 @@ +//! Original Mysekai housing responses joined to caller-supplied master data. +//! Coordinates remain in the game's grid frame. Player IDs are opaque strings. + +use moly_law::fixture::{position::layout_type, Direction, GridPosition, Vector3Int}; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; + +pub const MAX_IMPORT_BYTES: usize = 32 * 1024 * 1024; +const MAX_FIXTURES: usize = 20_000; +const ARRAYS: [&str; 9] = [ + "mysekaiFixtures", + "mysekaiCanvases", + "mysekaiGrowingPlants", + "mysekaiCustomFixtureCollections", + "mysekaiCustomFixturePenlights", + "mysekaiCustomFixtureHonors", + "mysekaiCustomFixtureBondsHonors", + "mysekaiCustomFixtureRecordJackets", + "mysekaiCustomFixturePhotos", +]; + +#[derive(Clone, Debug)] +pub struct ImportedFixture { + pub uid: String, + pub fixture_id: i32, + pub package: String, + pub center: GridPosition, + pub grid_size: Vector3Int, + pub layout: u8, + pub direction: Direction, + pub texture_id: u32, + pub source: Value, + pub source_array: String, +} + +#[derive(Clone, Debug)] +pub struct ImportedSite { + pub id: u32, + pub site_type: String, + pub level: u32, + pub fixtures: Vec, + pub source: Value, +} + +#[derive(Clone, Debug)] +pub struct ImportedPlayerData { + pub rank: u32, + pub region: String, + pub player_name: Option, + pub player_id: Option, + pub sites: Vec, + pub source: Value, + pub notices: Vec, +} + +impl ImportedPlayerData { + pub fn fixture_count(&self) -> usize { + self.sites.iter().map(|site| site.fixtures.len()).sum() + } +} + +pub struct PlayerDataCatalog { + region: String, + tables: HashMap>, + packages: HashSet, + colors: Value, +} + +impl PlayerDataCatalog { + pub fn from_json(master: &str, models: &str) -> Result { + let document: Value = + serde_json::from_str(master).map_err(|e| format!("Import catalog: {e}"))?; + if document["version"].as_u64() != Some(1) { + return Err("Unsupported player import catalog version".into()); + } + let region = text(&document["region"], "catalog region")?.to_owned(); + if !matches!(region.as_str(), "cn" | "jp") { + return Err("Import catalog region must be cn or jp".into()); + } + let mut tables = HashMap::new(); + for name in [ + "mysekaiFixtures", + "mysekaiCustomFixtures", + "mysekaiSites", + "mysekaiSiteLevels", + "mysekaiSiteLayouts", + "mysekaiRankReleases", + ] { + let rows = array(&document["tables"][name], name)?; + let mut entries = HashMap::new(); + for row in rows { + let id = positive(&row["id"], "master ID")?; + if entries.insert(id, row.clone()).is_some() { + return Err(format!("Duplicate {name} master ID {id}")); + } + } + tables.insert(name.to_owned(), entries); + } + let models: Value = + serde_json::from_str(models).map_err(|e| format!("Model catalog: {e}"))?; + let entries = models["packages"] + .as_object() + .ok_or("Model catalog has no packages")?; + let packages = entries + .iter() + .filter(|(_, entry)| { + entry["status"].as_str() == Some("exported") + && entry["hasFixtureView"].as_bool() == Some(true) + && entry["glb"].as_str().is_some() + }) + .map(|(name, _)| name.clone()) + .collect(); + Ok(Self { + region, + tables, + packages, + colors: document["colorTextures"].clone(), + }) + } + + pub fn region(&self) -> &str { + &self.region + } + + pub fn import( + &self, + json: &str, + requested_region: Option<&str>, + ) -> Result { + if json.len() > MAX_IMPORT_BYTES { + return Err("Player data exceeds 32 MiB".into()); + } + let source: Value = serde_json::from_str(json.trim_start_matches('\u{feff}')) + .map_err(|e| format!("Invalid player JSON: {e}"))?; + if !source.is_object() { + return Err("Player data must be a JSON object".into()); + } + let root = if source.get("data").is_some_and(Value::is_object) + && source.get("userMysekaiSiteHousingLayouts").is_none() + && source.get("updatedResources").is_none() + { + &source["data"] + } else { + &source + }; + let updated = match root.get("updatedResources") { + Some(value) if value.is_object() => value, + Some(_) => return Err("updatedResources must be an object".into()), + None => root, + }; + if [ + requested_region, + root["region"].as_str(), + source["region"].as_str(), + ] + .into_iter() + .flatten() + .any(|region| region != self.region) + { + return Err(format!( + "Player region does not match the {} asset catalog; use matching assets", + self.region + )); + } + let rank_value = [ + root.get("mysekaiRank"), + updated + .get("userMysekaiGamedata") + .and_then(|v| v.get("mysekaiRank")), + root.get("userMysekaiGamedata") + .and_then(|v| v.get("mysekaiRank")), + ] + .into_iter() + .flatten() + .next() + .ok_or("Player data has no mysekaiRank")?; + let rank = positive(rank_value, "mysekaiRank")?; + let sites_value = updated + .get("userMysekaiSiteHousingLayouts") + .or_else(|| root.get("userMysekaiSiteHousingLayouts")) + .ok_or("No userMysekaiSiteHousingLayouts in this file")?; + let rows = array(sites_value, "userMysekaiSiteHousingLayouts")?; + if rows.is_empty() { + return Err("Player data contains no housing sites".into()); + } + let mut sites = Vec::new(); + let mut site_ids = HashSet::new(); + let mut uids = HashSet::new(); + let mut special_count = 0; + let mut appearance_count = 0; + for site in rows { + let id = positive(&site["mysekaiSiteId"], "mysekaiSiteId")?; + if !site_ids.insert(id) { + return Err(format!("Duplicate housing site {id}")); + } + let master_site = self.row("mysekaiSites", id)?; + let site_type = text(&master_site["mysekaiSiteType"], "site type")?; + if !matches!( + site_type, + "home_site" | "first_floor" | "second_floor" | "third_floor" + ) { + return Err(format!("Site {id} is not a housing site")); + } + let level = self.site_level(id, rank)?; + let level_id = positive(&level["id"], "site level ID")?; + let floor = self.tables["mysekaiSiteLayouts"] + .values() + .find(|row| { + row["mysekaiSiteLevelId"].as_u64() == Some(level_id as u64) + && row["mysekaiLayoutType"].as_str() == Some("floor") + }) + .ok_or_else(|| format!("Site {id} has no floor for its released level"))?; + let width = positive(&floor["width"], "floor width")?; + let depth = positive(&floor["depth"], "floor depth")?; + if width > 256 || depth > 256 { + return Err("Floor exceeds the signed grid domain".into()); + } + let mut fixtures = Vec::new(); + let mut layers = HashSet::new(); + for group in array( + &site["mysekaiSiteHousingLayouts"], + "mysekaiSiteHousingLayouts", + )? { + let layer = parse_layout(text(&group["mysekaiLayoutType"], "mysekaiLayoutType")?)?; + if !layers.insert(layer) { + return Err(format!("Site {id} has duplicate layout layers")); + } + for array_name in ARRAYS { + for fixture in optional_array(group, array_name)? { + if uids.len() >= MAX_FIXTURES { + return Err("Too many furniture instances in player data".into()); + } + let uid = text(&fixture["mysekaiUniqueId"], "mysekaiUniqueId")?.to_owned(); + if uid.len() > 256 || !uids.insert(uid.clone()) { + return Err("Furniture IDs must be unique, non-empty strings of at most 256 bytes".into()); + } + let custom_id = fixture + .get("mysekaiCustomFixtureId") + .filter(|v| !v.is_null()) + .map(|v| positive(v, "mysekaiCustomFixtureId")) + .transpose()?; + let custom = custom_id + .map(|key| self.row("mysekaiCustomFixtures", key)) + .transpose()?; + let fixture_id = fixture + .get("mysekaiFixtureId") + .filter(|v| v.as_u64() != Some(0)) + .or_else(|| custom.and_then(|v| v.get("mysekaiFixtureId"))) + .ok_or("Furniture record has no master fixture ID")?; + let fixture_id = positive(fixture_id, "mysekaiFixtureId")?; + let master = self.row("mysekaiFixtures", fixture_id)?; + let asset = text(&master["assetbundleName"], "fixture assetbundleName")?; + if !asset + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'_') + { + return Err("Fixture asset name is not a catalog identifier".into()); + } + let package = format!("mysekai__fixture__{asset}"); + if !self.packages.contains(&package) { + return Err(format!("Furniture {fixture_id} has no exported model; update the asset catalog")); + } + let grid = custom.unwrap_or(&master["gridSize"]); + let size = Vector3Int { + x: dimension(&grid["width"])? as i32, + y: dimension(&grid["height"])? as i32, + z: dimension(&grid["depth"])? as i32, + }; + let position = &fixture["position"]; + let center = GridPosition::new( + grid_byte(&position["x"], "position.x")?, + grid_byte(&position["y"], "position.y")?, + grid_byte(&position["z"], "position.z")?, + ); + let center = wall_to_site(center, layer, width, depth)?; + let direction = if let Some(direction) = + moly_law::fixture::position::wall_direction(layer) + { + direction + } else { + let rotation = fixture["rotation"] + .as_i64() + .ok_or("rotation must be an integer number of degrees")?; + if !(-360..=360).contains(&rotation) || rotation % 90 != 0 { + return Err( + "Furniture rotation must be a cardinal angle in degrees".into(), + ); + } + Direction::from_u8((rotation.rem_euclid(360) / 90) as u8).unwrap() + }; + moly_law::fixture::position::layout_footprint( + center, size, direction, layer, + )?; + let texture_id = fixture + .get("textureId") + .filter(|v| !v.is_null()) + .map(|v| positive(v, "textureId")) + .transpose()? + .unwrap_or(1); + if texture_id != 1 + && !optional_array(master, "mysekaiFixtureAnotherColors")? + .iter() + .any(|v| v["textureId"].as_u64() == Some(texture_id as u64)) + { + return Err(format!("Furniture {fixture_id} has no texture {texture_id} in this master version")); + } + if texture_id != 1 + && self + .colors + .get(&package) + .and_then(|colors| colors.get(texture_id.to_string())) + .is_none() + { + return Err(format!("Furniture {fixture_id} needs color {texture_id}; export the player catalog with its source color textures")); + } + if array_name != "mysekaiFixtures" { + special_count += 1; + } + fixtures.push(ImportedFixture { + uid, + fixture_id: i32::try_from(fixture_id) + .map_err(|_| "Fixture ID exceeds i32")?, + package, + center, + grid_size: size, + layout: layer, + direction, + texture_id, + source: fixture.clone(), + source_array: array_name.to_owned(), + }); + } + } + } + appearance_count += optional_array(site, "mysekaiFixtureSurfaceAppearances")?.len(); + sites.push(ImportedSite { + id, + site_type: site_type.to_owned(), + level: positive(&level["level"], "site level")?, + fixtures, + source: site.clone(), + }); + } + sites.sort_by_key(|site| site.id); + let mut notices = Vec::new(); + if special_count > 0 { + notices.push(format!("{special_count} custom furniture records retained. Custom images and ornaments are not rendered by this importer yet.")); + } + if appearance_count > 0 { + notices.push(format!("{appearance_count} floor/wall skin records retained. The scene still uses its supplied floor/wall textures.")); + } + let game_data = updated + .get("userGamedata") + .or_else(|| root.get("userGamedata")); + let player_name = game_data + .and_then(|v| v["name"].as_str()) + .or_else(|| root["name"].as_str()) + .map(str::to_owned); + let player_id = game_data + .and_then(|v| v.get("userId")) + .or_else(|| root.get("userId")) + .and_then(opaque_id); + Ok(ImportedPlayerData { + rank, + region: self.region.clone(), + player_name, + player_id, + sites, + source, + notices, + }) + } + + fn row(&self, table: &str, id: u32) -> Result<&Value, String> { + self.tables[table] + .get(&id) + .ok_or_else(|| format!("Missing {table} master ID {id}; update matching region assets")) + } + + fn site_level(&self, id: u32, rank: u32) -> Result<&Value, String> { + let released: HashSet = self.tables["mysekaiRankReleases"] + .values() + .filter(|row| { + row["mysekaiRankRelaseType"].as_str() == Some("mysekai_site_level") + && row["mysekaiRank"] + .as_u64() + .is_some_and(|r| r <= rank as u64) + }) + .filter_map(|row| row["externalId"].as_u64()) + .collect(); + self.tables["mysekaiSiteLevels"] + .values() + .filter(|row| { + row["mysekaiSiteId"].as_u64() == Some(id as u64) + && row["id"].as_u64().is_some_and(|v| released.contains(&v)) + }) + .max_by_key(|row| row["level"].as_u64().unwrap_or(0)) + .ok_or_else(|| format!("Site {id} is not unlocked at Mysekai rank {rank}")) + } +} + +fn positive(value: &Value, label: &str) -> Result { + value + .as_u64() + .and_then(|v| u32::try_from(v).ok()) + .filter(|v| *v != 0) + .ok_or_else(|| format!("{label} must be a positive integer")) +} + +fn dimension(value: &Value) -> Result { + positive(value, "fixture grid dimension").and_then(|v| { + if v <= 127 { + Ok(v) + } else { + Err("Fixture grid dimension exceeds signed grid domain".into()) + } + }) +} + +fn grid_byte(value: &Value, label: &str) -> Result { + value + .as_i64() + .and_then(|v| i8::try_from(v).ok()) + .ok_or_else(|| format!("{label} must be an integer in -128..127")) +} + +fn text<'a>(value: &'a Value, label: &str) -> Result<&'a str, String> { + value + .as_str() + .filter(|v| !v.trim().is_empty()) + .ok_or_else(|| format!("{label} must be a non-empty string")) +} + +fn array<'a>(value: &'a Value, label: &str) -> Result<&'a [Value], String> { + value + .as_array() + .map(Vec::as_slice) + .ok_or_else(|| format!("{label} must be an array")) +} + +fn optional_array<'a>(value: &'a Value, key: &str) -> Result<&'a [Value], String> { + match value.get(key) { + None | Some(Value::Null) => Ok(&[]), + Some(v) => array(v, key), + } +} + +fn opaque_id(value: &Value) -> Option { + value + .as_str() + .map(str::to_owned) + .or_else(|| value.as_u64().map(|v| v.to_string())) +} + +fn parse_layout(value: &str) -> Result { + match value { + "floor" => Ok(layout_type::FLOOR), + "rug" => Ok(layout_type::RUG), + "road" => Ok(layout_type::ROAD), + "wall_front" => Ok(layout_type::WALL_FRONT), + "wall_back" => Ok(layout_type::WALL_BACK), + "wall_left" => Ok(layout_type::WALL_LEFT), + "wall_right" => Ok(layout_type::WALL_RIGHT), + _ => Err(format!("Unknown housing layout type {value}")), + } +} + +fn wall_to_site( + position: GridPosition, + layer: u8, + width: u32, + depth: u32, +) -> Result { + let x = position.x as i32; + let w = width.div_ceil(2) as i32; + let d = depth.div_ceil(2) as i32; + let (x, z) = match layer { + layout_type::WALL_FRONT => (!x, -d), + layout_type::WALL_BACK => (x, d), + layout_type::WALL_LEFT => (-w, x), + layout_type::WALL_RIGHT => (w, !x), + _ => return Ok(position), + }; + Ok(GridPosition::new( + i8::try_from(x).map_err(|_| "Wall X exceeds signed grid domain")?, + position.y, + i8::try_from(z).map_err(|_| "Wall Z exceeds signed grid domain")?, + )) +} diff --git a/crates/moly-assets/src/read_limits.rs b/crates/moly-assets/src/read_limits.rs new file mode 100644 index 0000000..d3f84e1 --- /dev/null +++ b/crates/moly-assets/src/read_limits.rs @@ -0,0 +1,150 @@ +//! Budgets for application-owned transfer and decoded asset buffers. + +use async_lock::Semaphore; +use bevy::asset::io::{Reader, ReaderNotSeekableError, SeekableReader}; +use futures_lite::io::{AsyncRead, AsyncSeek}; +use std::{ + io::{self, SeekFrom}, + pin::Pin, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, OnceLock, + }, + task::{Context, Poll}, +}; + +pub(crate) const MAX_DOCUMENT_BYTES: usize = 16 * 1024 * 1024; +pub(crate) const MAX_BLOB_BYTES: usize = 128 * 1024 * 1024; +pub(crate) const MAX_ASSET_BYTES: usize = 256 * 1024 * 1024; +pub(crate) const MAX_IN_FLIGHT_BYTES: usize = 512 * 1024 * 1024; +pub(crate) const MAX_EXPANSION_RATIO: usize = 256; + +pub(crate) struct Budget { + used: AtomicUsize, + pub(crate) slots: Semaphore, +} + +pub(crate) fn shared_budget() -> Arc { + static BUDGET: OnceLock> = OnceLock::new(); + BUDGET + .get_or_init(|| { + Arc::new(Budget { + used: AtomicUsize::new(0), + slots: Semaphore::new(8), + }) + }) + .clone() +} + +impl Budget { + pub(crate) fn reserve(self: &Arc, bytes: usize) -> io::Result { + self.used + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| { + used.checked_add(bytes) + .filter(|total| *total <= MAX_IN_FLIGHT_BYTES) + }) + .map_err(|_| { + io::Error::other( + "Asset buffer budget exhausted (512 MiB); retry after pending reads finish", + ) + })?; + Ok(Reservation { + budget: self.clone(), + bytes, + }) + } +} + +pub(crate) struct Reservation { + budget: Arc, + bytes: usize, +} + +impl Reservation { + pub(crate) fn shrink_to(&mut self, bytes: usize) { + assert!(bytes <= self.bytes); + self.budget + .used + .fetch_sub(self.bytes - bytes, Ordering::AcqRel); + self.bytes = bytes; + } +} + +impl Drop for Reservation { + fn drop(&mut self) { + self.budget.used.fetch_sub(self.bytes, Ordering::AcqRel); + } +} + +pub(crate) struct Buffer { + pub(crate) bytes: Vec, + pub(crate) _reservation: Reservation, +} + +/// Shared buffers stay accounted for until the last reader releases them. +pub(crate) struct SharedReader { + data: Arc, + position: usize, +} + +impl SharedReader { + pub(crate) fn new(data: Arc) -> Self { + Self { data, position: 0 } + } +} + +impl AsyncRead for SharedReader { + fn poll_read( + mut self: Pin<&mut Self>, + _: &mut Context<'_>, + buffer: &mut [u8], + ) -> Poll> { + let length = buffer + .len() + .min(self.data.bytes.len().saturating_sub(self.position)); + if length != 0 { + buffer[..length] + .copy_from_slice(&self.data.bytes[self.position..self.position + length]); + self.position += length; + } + Poll::Ready(Ok(length)) + } +} + +impl AsyncSeek for SharedReader { + fn poll_seek( + mut self: Pin<&mut Self>, + _: &mut Context<'_>, + position: SeekFrom, + ) -> Poll> { + let position = match position { + SeekFrom::Start(value) => value as i128, + SeekFrom::End(value) => self.data.bytes.len() as i128 + value as i128, + SeekFrom::Current(value) => self.position as i128 + value as i128, + }; + match usize::try_from(position) { + Ok(position) => { + self.position = position; + Poll::Ready(Ok(position as u64)) + } + Err(_) => Poll::Ready(Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Invalid asset seek", + ))), + } + } +} + +impl Reader for SharedReader { + fn seekable(&mut self) -> Result<&mut dyn SeekableReader, ReaderNotSeekableError> { + Ok(self) + } +} + +pub(crate) fn buffer(capacity: usize) -> io::Result> { + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(capacity) + .map_err(|_| io::Error::other("Could not allocate bounded asset buffer"))?; + Ok(bytes) +} diff --git a/crates/moly-game/Cargo.toml b/crates/moly-game/Cargo.toml index eeceae2..f226324 100644 --- a/crates/moly-game/Cargo.toml +++ b/crates/moly-game/Cargo.toml @@ -11,12 +11,19 @@ bevy = { workspace = true } serde_json = "1" # 字形光栅化与度量:bevy_text 的同源光栅器,锁内已有 0.2.10,不新增下载。 swash = "0.2.10" +ehttp = "0.6" +rfd = "0.15" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +arboard = { version = "3", default-features = false } +tempfile = "3" [target.'cfg(target_arch = "wasm32")'.dependencies] # 生日门要读墙钟的 epoch 毫秒;wasm 上 std 的 SystemTime 是未实现的桩 # (sys/pal/unsupported:SystemTime::now() 运行时 panic),epoch 走 JS 的 # Date.now。版本与 moly-app 同源(锁内 0.3.104,不新增下载)。 js-sys = "0.3" +wasm-bindgen-futures = "0.4" # 本地档(音量设置持久化)在浏览器侧落 localStorage:Window + Storage # 绑定。与 moly-app 同一版本源(锁内已有,不新增下载)。 -web-sys = { version = "0.3", features = ["Window", "Storage", "Location", "UrlSearchParams"] } +web-sys = { version = "0.3", features = ["Window", "Storage", "Location", "UrlSearchParams", "Navigator", "Clipboard"] } diff --git a/crates/moly-game/src/balloon.rs b/crates/moly-game/src/balloon.rs index 63ac994..784a02f 100644 --- a/crates/moly-game/src/balloon.rs +++ b/crates/moly-game/src/balloon.rs @@ -2154,6 +2154,9 @@ pub(crate) fn order_smoke( _ => panic!("layer-order probe needs 2+ master rows"), }; *spawned = true; + if let Ok(run_id) = std::env::var("MOLY_BALLOON_ORDER_RUN_ID") { + info!("[tweet-order] run={} version={}", run_id, crate::VERSION); + } let eye = cam_transform.translation(); let fwd = cam_transform.forward(); // 近锚 = 眼 + 视线 × 近距 + 投影偏移(与真实气泡同一偏移);远锚 = diff --git a/crates/moly-game/src/fence.rs b/crates/moly-game/src/fence.rs index 33ca2c8..a596b14 100644 --- a/crates/moly-game/src/fence.rs +++ b/crates/moly-game/src/fence.rs @@ -60,12 +60,12 @@ pub(super) fn update_connections( return; } let mut rows: Vec<_> = fences.iter().map(|(entity, index, _)| { - let row = placements.rows[index.0]; + let row = &placements.rows[index.0]; (index.0, entity.to_bits(), row, row.placed()) }).collect(); rows.sort_by_key(|r| r.0); for (_, index, nodes) in &fences { - let row = placements.rows[index.0]; + let row = &placements.rows[index.0]; let placed = row.placed(); let positions = neighbor_positions(placed.min, placed.max, row.direction); let neighbors = positions.map(|p| { diff --git a/crates/moly-game/src/fixture.rs b/crates/moly-game/src/fixture.rs index 785416f..598df2c 100644 --- a/crates/moly-game/src/fixture.rs +++ b/crates/moly-game/src/fixture.rs @@ -36,7 +36,7 @@ use bevy::gltf::Gltf; use bevy::prelude::*; use bevy::scene::{SceneInstanceReady, SceneRoot}; use moly_law::fixture::position::{ - direction_yaw_degrees, field_position, layout_type, placed_footprint, WALL_LAYOUT_MASK, + direction_yaw_degrees, field_position, layout_type, WALL_LAYOUT_MASK, }; use moly_law::fixture::{Direction, GridPosition}; @@ -56,20 +56,20 @@ use moly_law::fixture::{Direction, GridPosition}; const PLACEMENTS: [PlacementMock; 38] = [ // Rug coverage: a rectangular picnic sheet under the birthday chair, // and an alpha-clipped star beside the player, in the initial camera. - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_env0001_rug_picnicsheet1", min: GridPosition { x: -22, y: 0, z: 3 }, max: GridPosition { x: -17, y: 0, z: 8 }, center_y: 0, layout: layout_type::RUG, direction: Direction::Front, fixture_id: 0, }, - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_cncollect_rug_star3", min: GridPosition { x: -12, y: 0, z: 9 }, max: GridPosition { x: -9, y: 0, z: 12 }, center_y: 0, layout: layout_type::RUG, direction: Direction::Front, fixture_id: 0, }, // 生日椅:不透明、无 clip 的基形。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_bir1103_fixture_chair1", min: GridPosition { x: -20, y: 0, z: 6 }, max: GridPosition { x: -19, y: 0, z: 6 }, @@ -79,7 +79,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ fixture_id: 0, }, // 生日蛋糕:clip 变体;同包还有一条粒子族材质(范围外保留桶)。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_bir1103_fixture_cake1", min: GridPosition { x: -13, y: 0, z: 0 }, max: GridPosition { x: -12, y: 0, z: 0 }, @@ -89,7 +89,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ fixture_id: 0, }, // 生日气球:基形。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_bir1103_fixture_balloon1", min: GridPosition { x: 1, y: 0, z: -31 }, max: GridPosition { x: 1, y: 0, z: -31 }, @@ -99,7 +99,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ fixture_id: 0, }, // 生日花饰:基形。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_bir1103_fixture_flower1", min: GridPosition { x: 3, y: 0, z: -31 }, max: GridPosition { x: 3, y: 0, z: -31 }, @@ -110,7 +110,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ }, // 墙架:墙布局(前墙,法线 (0,0,1),z −= 0.125),Center.Y=6 // (世界 y=1.5,墙面高度)。墙布局的 ShadowCaster 标记面挂在这条。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_con0004_fixture_wallshelf1", min: GridPosition { x: 0, y: 6, z: -34 }, max: GridPosition { x: 0, y: 6, z: -34 }, @@ -122,7 +122,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ // 窗:墙布局(后墙,法线 (0,0,−1),z += 0.125),Center.Y=8(世界 // y=2.0)。窗外观材质是二维选择表 usage=1 的两格(26/27),同包 // 还有 Object 族模板材质(范围外保留桶)。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_env0002_window_window1", min: GridPosition { x: 4, y: 8, z: -36 }, max: GridPosition { x: 4, y: 8, z: -36 }, @@ -134,7 +134,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ // 屏风(对话锚定,fixtureId 455):单人/两人对话剧本的锚点。格 // 足迹 4 宽 × 2 深(motionArea 同形),落在广场中部——名册三名 // 成员的巡区最近路点都在配对半径内(世界位 (0.75, 0, −7.375))。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_env0002_fixture_byoubu1", min: GridPosition { x: 1, y: 0, z: -30 }, max: GridPosition { x: 4, y: 0, z: -29 }, @@ -145,7 +145,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ }, // 沙发(对话锚定,fixtureId 695):三人目两人剧本的锚点,与屏风 // 对称放在广场另一侧(世界位 (−0.5, 0, −7.375))。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_ext0019_fixture_sofa1", min: GridPosition { x: -4, y: 0, z: -30 }, max: GridPosition { x: -1, y: 0, z: -29 }, @@ -164,7 +164,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ // 恒落两人档,蛋锚定的单人段(语料里该族全部单人)永远中不了签。 // 摆在簇半径之外(4.1m)让「近蛋」成为独占状态:unit1 每圈路过东 // 角,期间池里没有两人档段,单人段进得去也抽得出。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_clb1102_fixture_egg1", min: GridPosition { x: 19, y: 0, z: -31 }, max: GridPosition { x: 19, y: 0, z: -31 }, @@ -180,7 +180,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ // (3.3m)之外 ⇒ 走到北腿期间池里只有蛋锚定的单人段,单人段抽得出。 // unit1 的环西北角 (1, −6) 也会进蛋 2 半径(2.7m)——那是附带的 // 现场族,不破坏独占带。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_clb1102_fixture_egg2", min: GridPosition { x: 1, y: 0, z: -14 }, max: GridPosition { x: 1, y: 0, z: -14 }, @@ -195,7 +195,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ // 南段。蛋 4(840)不放:三个角位里剩下的一族离 455/695 簇都在 // 半径内(3.4m 边缘以内),摆不出独占带;语料族在,后续换巡逻域 // 布局即可达。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_clb1102_fixture_egg3", min: GridPosition { x: -14, y: 0, z: -43 }, max: GridPosition { x: -14, y: 0, z: -43 }, @@ -213,7 +213,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ // 与出生清空带只读非 0 行 ⇒ 这批对对话域零扰动;个别件落在蛋锚 // 的配对半径(3.3m)内也只改光照面,不进配对池。 // 立灯与椅、蛋糕共同置于近景。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_ext0001_fixture_lamp1", min: GridPosition { x: -19, y: 0, z: 0 }, max: GridPosition { x: -19, y: 0, z: 0 }, @@ -224,7 +224,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ }, // 路灯(ext0009):0.89m 立灯,屏风东北侧(世界位 // (1.625, 0, −7.375))。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_ext0009_fixture_lamp1", min: GridPosition { x: 6, y: 0, z: -30 }, max: GridPosition { x: 6, y: 0, z: -30 }, @@ -234,7 +234,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ fixture_id: 0, }, // 路灯(ext0010):广场西北(世界位 (−2.125, 0, −8.375))。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_ext0010_fixture_lamp1", min: GridPosition { x: -9, y: 0, z: -34 }, max: GridPosition { x: -9, y: 0, z: -34 }, @@ -244,7 +244,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ fixture_id: 0, }, // 路灯(ext0008):广场西南(世界位 (−2.125, 0, −6.875))。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_ext0008_fixture_lamp1", min: GridPosition { x: -9, y: 0, z: -28 }, max: GridPosition { x: -9, y: 0, z: -28 }, @@ -255,7 +255,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ }, // 路灯(ext0019):细杆形(杆身 0.03m 见方),广场东北(世界位 // (2.375, 0, −8.375))。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_ext0019_fixture_lamp1", min: GridPosition { x: 9, y: 0, z: -34 }, max: GridPosition { x: 9, y: 0, z: -34 }, @@ -266,7 +266,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ }, // 灯(env0002):立灯,广场中部(世界位 (−0.375, 0, −8.125))。 // 同包的 motionArea 是普查里唯一非空的一条(6×3 里 4 格真)。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_env0002_fixture_lamp1", min: GridPosition { x: -2, y: 0, z: -33 }, max: GridPosition { x: -2, y: 0, z: -33 }, @@ -276,7 +276,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ fixture_id: 0, }, // 灯(env0012):立灯,广场东南(世界位 (2.375, 0, −6.625))。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_env0012_fixture_lamp1", min: GridPosition { x: 9, y: 0, z: -27 }, max: GridPosition { x: 9, y: 0, z: -27 }, @@ -288,7 +288,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ // 台灯(mis0001):mis 族六件同尺寸(实测包围盒 0.209×0.305×0.209m、 // motionArea 空 ⇒ 足迹退化单格)之一,广场中部(世界位 // (−0.375, 0, −7.625))。同族余五件见下,铺桌群一侧。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_mis0001_fixture_lamp1", min: GridPosition { x: -2, y: 0, z: -31 }, max: GridPosition { x: -2, y: 0, z: -31 }, @@ -299,7 +299,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ }, // 台灯(mis0002):mis 族第二员,桌(西)与桌面电脑(东)之间的 // 中缝南位(世界位 (3.125, 0, −8.125))。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_mis0002_fixture_lamp1", min: GridPosition { x: 12, y: 0, z: -33 }, max: GridPosition { x: 12, y: 0, z: -33 }, @@ -310,7 +310,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ }, // 台灯(mis0003):中缝北位,与 mis0002 同列(世界位 // (3.125, 0, −8.375))。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_mis0003_fixture_lamp1", min: GridPosition { x: 12, y: 0, z: -34 }, max: GridPosition { x: 12, y: 0, z: -34 }, @@ -320,7 +320,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ fixture_id: 0, }, // 台灯(mis0004):笔记本电脑东侧(世界位 (3.625, 0, −7.875))。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_mis0004_fixture_lamp1", min: GridPosition { x: 14, y: 0, z: -32 }, max: GridPosition { x: 14, y: 0, z: -32 }, @@ -331,7 +331,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ }, // 台灯(mis0005):桌面电脑东端、桌群北缘(世界位 // (3.875, 0, −8.375))。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_mis0005_fixture_lamp1", min: GridPosition { x: 15, y: 0, z: -34 }, max: GridPosition { x: 15, y: 0, z: -34 }, @@ -343,7 +343,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ // 台灯(mis0006):桌群东南角(世界位 (3.875, 0, −7.875))。与 // mis0001 的包围盒逐轴相同、两 mesh 之一字节全同,另一 mesh 顶点 // 数据不同——同形族里的近亲件,不是同一模型。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_mis0006_fixture_lamp1", min: GridPosition { x: 15, y: 0, z: -32 }, max: GridPosition { x: 15, y: 0, z: -32 }, @@ -355,7 +355,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ // 壁灯(env0004):墙布局(前墙,法线 (0,0,1),z −= 0.125), // Center.Y=6(世界 y=1.5)。扁平壁灯壳(0.33×0.38×0.15m),与 // 墙架同一面墙线(世界位 (0.625, 1.5, −8.5))。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_env0004_fixture_lamp1", min: GridPosition { x: 2, y: 6, z: -34 }, max: GridPosition { x: 2, y: 6, z: -34 }, @@ -366,7 +366,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ }, // 地灯(non9001):2cm 薄圆盘,贴地单格(世界位 // (0.125, 0, −6.625))。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_non9001_fixture_groundlight1", min: GridPosition { x: 0, y: 0, z: -27 }, max: GridPosition { x: 0, y: 0, z: -27 }, @@ -376,7 +376,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ fixture_id: 0, }, // 桌(twcollect):1.02m 高,2×2 足迹。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_twcollect_fixture_table1", min: GridPosition { x: -6, y: 0, z: 5 }, max: GridPosition { x: -5, y: 0, z: 6 }, @@ -387,7 +387,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ }, // 桌面电脑(non0005):2×1 足迹,桌东北侧(世界位 // (3.5, 0, −8.375))。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_non0005_system_desktop1", min: GridPosition { x: 13, y: 0, z: -34 }, max: GridPosition { x: 14, y: 0, z: -34 }, @@ -397,7 +397,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ fixture_id: 0, }, // 笔记本电脑(non0005):单格,放在近景桌面高度。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_non0005_system_laptop1", min: GridPosition { x: -5, y: 4, z: 5 }, max: GridPosition { x: -5, y: 4, z: 5 }, @@ -408,7 +408,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ }, // 盆栽(twcollect):0.85×0.72m,带水面材质,3×3 足迹(世界位 // (1.625, 0, −6.625))。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_twcollect_fixture_planter1", min: GridPosition { x: 5, y: 0, z: -28 }, max: GridPosition { x: 7, y: 0, z: -26 }, @@ -419,7 +419,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ }, // 茶摊(cncollect):带 On/Off 动画的立件,2×1 足迹(世界位 // (−0.75, 0, −8.125))。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_cncollect_fixture_tea3", min: GridPosition { x: -4, y: 0, z: -33 }, max: GridPosition { x: -3, y: 0, z: -33 }, @@ -429,7 +429,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ fixture_id: 0, }, // 珊瑚(con0002):2×2 足迹,广场西侧(世界位 (−2.0, 0, −7.5))。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_con0002_fixture_coral1", min: GridPosition { x: -9, y: 0, z: -31 }, max: GridPosition { x: -8, y: 0, z: -30 }, @@ -447,7 +447,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ // 盘外、丢掉第一圈过境,见 `npc.rs` 的 `tour_seed`)。 // 锚定序号进对话抽签种子(`talk.rs` 的 `seed_for` 按锚序给成员 // 号移位),重排本表条目会换掉抽签序列——追加在尾,别插队。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_chr0004_fixture_nenerobo1", min: GridPosition { x: -46, y: 0, z: -17 }, max: GridPosition { x: -37, y: 0, z: -13 }, @@ -462,7 +462,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ // 同蛋 1–3。蛋 3 注里「蛋 4 不放」是三员巡逻代的取位结论(角位 // 离广场簇摆不出独占带);31 员巡游环下该族的 30 段单员对话靠 // 整环逐圈过境进池,不依赖独占带。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_clb1102_fixture_egg4", min: GridPosition { x: 41, y: 0, z: 0 }, max: GridPosition { x: 41, y: 0, z: 0 }, @@ -479,7 +479,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ // 站定报出——冒烟照实抓到后迁到这里)。与全部既有摆放不重叠(最近 // 的蛋 1 在 (19,-31),距 15 格)。追加在尾:锚定序号进对话抽签种子, // 重排本表条目会换掉抽签序列。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_mis0001_fixture_chair1", min: GridPosition { x: 33, y: 0, z: -25 }, max: GridPosition { x: 34, y: 0, z: -24 }, @@ -493,7 +493,7 @@ const PLACEMENTS: [PlacementMock; 38] = [ // 里),动作点解算按「挂点条目缺」回落环带并照实报。它是 1×1 格 // 件,与椅隔一格摆在 (31, -24),同在可行走面上(环带八格全中; // 对话道的座 13 挂点面门也过——unit 12 抽中它时走正臂)。 - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_ext0008_fixture_gameconsole1", min: GridPosition { x: 31, y: 0, z: -24 }, max: GridPosition { x: 31, y: 0, z: -24 }, @@ -506,8 +506,9 @@ const PLACEMENTS: [PlacementMock; 38] = [ /// 一条摆放(服务端域 mock 的行形状,与存档列一一对应)。 #[derive(Clone, Copy)] -struct PlacementMock { - package: &'static str, +struct PlacementMock

{ + package: P, + texture_id: u32, min: GridPosition, max: GridPosition, center_y: i8, @@ -536,7 +537,7 @@ struct PlacedRow { yaw: f32, } -impl PlacementMock { +impl PlacementMock

{ /// 本条摆放的落位。位置与足迹都过律,不在这里算。 /// /// 足迹先按朝向重算再喂位置式:源里 min/max 不是存档列,而是从 @@ -546,7 +547,10 @@ impl PlacementMock { /// 足迹颠倒是摆放表的数据断点,具名 panic —— 与本模块其余对账同一 /// 处置(静默取一个错落点远差于响亮拒绝)。 fn placed(&self) -> PlacedRow { - let (min, max) = placed_footprint(self.min, self.max, self.direction) + let (mut center, size) = moly_law::fixture::position::footprint_to_center_size(self.min, self.max) + .expect("validated fixture grid"); + center.y = self.center_y; + let (min, max) = moly_law::fixture::position::layout_footprint(center, size, self.direction, self.layout) .unwrap_or_else(|err| panic!("摆放 mock 的足迹无效({}):{err}", self.package)); let position = field_position(min, max, self.center_y, self.layout) .unwrap_or_else(|err| panic!("摆放 mock 的布局无格类别({}):{err}", self.package)); @@ -559,6 +563,14 @@ impl PlacementMock { } } +impl PlacementMock { + fn into_owned(self) -> PlacementMock { + PlacementMock { texture_id: self.texture_id, package: self.package.to_owned(), min: self.min, max: self.max, + center_y: self.center_y, layout: self.layout, direction: self.direction, + fixture_id: self.fixture_id } + } +} + /// 冒烟旋钮 `MOLY_FIXTURE_DIRECTION`:把摆放表的朝向整片改写成 /// 0=Front / 1=Left / 2=Back / 3=Right。 /// @@ -586,7 +598,7 @@ fn direction_override() -> Option { /// 摆放表资源(可下发形态:解析自上表;接服务端下发时换成回包解析)。 #[derive(Resource, Clone, Default)] pub struct FixturePlacements { - rows: Vec, + rows: Vec>, /// Opaque identities allocated by the offline layout owner. They do not /// change when a placed instance moves or rotates. instance_uids: Vec, @@ -597,13 +609,21 @@ pub struct FixturePlacements { floor: Option, } +pub struct PlacedFixture<'a> { + pub uid: &'a str, + pub package: &'a str, + pub position: [f32; 3], + pub yaw: f32, +} + /// Owned editable record. UID is the placed/offline item identity, not a /// package or a screen-space proxy. Center/grid_size stay in the source's /// unrotated layout frame; all readers derive the footprint through the law. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct EditableFixture { pub uid: String, - pub package: &'static str, + pub package: String, + pub texture_id: u32, pub fixture_id: i32, pub center: GridPosition, pub grid_size: moly_law::fixture::Vector3Int, @@ -613,20 +633,22 @@ pub(crate) struct EditableFixture { impl EditableFixture { pub(crate) fn footprint(&self) -> (GridPosition, GridPosition) { - moly_law::fixture::position::footprint_rotated(self.center, self.grid_size, self.direction) + moly_law::fixture::position::layout_footprint(self.center, self.grid_size, self.direction, self.layout) + .expect("validated fixture layout") } pub(crate) fn occupancy(&self) -> OccupancyRow { let (min, max) = self.footprint(); OccupancyRow { - uid: self.uid.clone(), package: self.package, min, max, + uid: self.uid.clone(), package: self.package.clone(), min, max, center_y: self.center.y, layout: self.layout, direction: self.direction, layout_center: self.center, layout_grid_size: self.grid_size, } } pub(crate) fn pose(&self) -> Result { - let (min, max) = self.footprint(); + let (min, max) = moly_law::fixture::position::layout_footprint( + self.center, self.grid_size, self.direction, self.layout)?; let position = field_position(min, max, self.center.y, self.layout)?; Ok(Transform::from_translation(Vec3::from(position)).with_rotation( Quat::from_rotation_y(direction_yaw_degrees(self.direction).to_radians()))) @@ -653,7 +675,7 @@ impl FixturePlacements { let (mut center, grid_size) = moly_law::fixture::position::footprint_to_center_size(row.min, row.max) .expect("validated offline layout footprint"); center.y = row.center_y; - EditableFixture { uid: uid.clone(), package: row.package, fixture_id: row.fixture_id, + EditableFixture { texture_id: row.texture_id, uid: uid.clone(), package: row.package.clone(), fixture_id: row.fixture_id, center, grid_size, layout: row.layout, direction: row.direction } }).collect() } @@ -681,7 +703,7 @@ impl FixturePlacements { return Err(format!("{} footprint exceeds the signed grid domain", row.uid)); } row.pose()?; - next.rows.push(PlacementMock { package: row.package, fixture_id: row.fixture_id, + next.rows.push(PlacementMock { texture_id: row.texture_id, package: row.package.clone(), fixture_id: row.fixture_id, min, max, center_y: row.center.y, layout: row.layout, direction: row.direction }); next.instance_uids.push(row.uid.clone()); } @@ -699,7 +721,7 @@ impl FixturePlacements { } let (min, max) = moly_law::fixture::position::footprint_front(center, grid_size); moly_law::fixture::position::footprint_to_center_size(min, max)?; - self.rows.push(PlacementMock { package, min, max, center_y: center.y, + self.rows.push(PlacementMock { texture_id: 1, package: package.to_owned(), min, max, center_y: center.y, layout: layout_type::FLOOR, direction, fixture_id }); self.instance_uids.push(uid); Ok(()) @@ -715,11 +737,11 @@ impl FixturePlacements { } /// 锚定摆放(fixtureId 非 0):对话演出侧按 (id, 模型包名) 取件。 - pub fn anchored(&self) -> Vec<(i32, &'static str)> { + pub fn anchored(&self) -> Vec<(i32, &str)> { self.rows .iter() .filter(|row| row.fixture_id != 0) - .map(|row| (row.fixture_id, row.package)) + .map(|row| (row.fixture_id, row.package.as_str())) .collect() } @@ -738,16 +760,23 @@ impl FixturePlacements { /// 世界位的组合表按它算(`fixture_attach`)——扫描面是**全部已 /// 摆放实例**(含未锚定的普通摆放),与对话锚定面([`Self::anchored`]) /// 是两个口径。 - pub fn placed_rows(&self) -> Vec<(&'static str, [f32; 3], f32)> { + pub fn placed_rows(&self) -> Vec<(&str, [f32; 3], f32)> { self.rows .iter() .map(|row| { let placed = row.placed(); - (row.package, placed.position, placed.yaw) + (row.package.as_str(), placed.position, placed.yaw) }) .collect() } + pub fn placed_instances(&self) -> Vec> { + self.rows.iter().zip(&self.instance_uids).map(|(row, uid)| { + let placed = row.placed(); + PlacedFixture { uid, package: &row.package, position: placed.position, yaw: placed.yaw } + }).collect() + } + /// 已摆放行的占用面(摆放编辑面对账用:重叠校验要把这些行的格占 /// 算进站点占用表)。足迹是**当前朝向下的**(同 spawn 的入口)—— /// 返回存档列那对角会让非 Front 的摆放在编辑面上占错格。 @@ -764,7 +793,7 @@ impl FixturePlacements { uid: self.instance_uids[index].clone(), layout_center: center, layout_grid_size: grid_size, - package: row.package, + package: row.package.clone(), min: placed.min, max: placed.max, center_y: row.center_y, @@ -784,7 +813,7 @@ pub struct OccupancyRow { pub layout_center: GridPosition, /// The owner's declared footprint; consumers compare it to the master. pub layout_grid_size: moly_law::fixture::Vector3Int, - pub package: &'static str, + pub package: String, pub min: GridPosition, pub max: GridPosition, pub center_y: i8, @@ -813,7 +842,7 @@ pub struct FixtureSource(pub Handle); #[derive(Component)] struct FixtureInstanceSeed { uid: String, - package: &'static str, + package: String, master: i32, } @@ -987,7 +1016,7 @@ fn plan_when_ready( .unwrap_or_else(|| panic!("家具包清单缺 packages 对象")); let mut handles = Vec::with_capacity(placements.total()); for row in &placements.rows { - let entry = packages.get(row.package).unwrap_or_else(|| { + let entry = packages.get(&row.package).unwrap_or_else(|| { panic!("摆放 mock 点名的包不在清单里:{}", row.package) }); let status = entry.get("status").and_then(|v| v.as_str()).unwrap_or(""); @@ -1040,6 +1069,7 @@ fn spawn_when_ready( if assets.0.is_empty() && scenes_ready.is_none() { commands.insert_resource(FixtureScenesReady); } + let occupancy = placements.occupancy_rows(); for (index, handle) in assets.0.iter().enumerate() { if index < spawned.0 { continue; @@ -1064,7 +1094,7 @@ fn spawn_when_ready( let Some(gltf) = gltfs.get(handle) else { return; }; - let row = placements.rows[index]; + let row = &placements.rows[index]; // 默认 scene = fixture 视图变体(提取侧约定,实测 999 包全对上)。 let Some(scene) = gltf.default_scene.clone() else { panic!("家具 glb 没有默认 scene:{}", row.package); @@ -1074,14 +1104,15 @@ fn spawn_when_ready( SceneRoot(scene), FixtureRoot, FixtureVisualRoot { layout: row.layout }, + crate::fixture_colors::FixtureColorChoice { package: row.package.clone(), texture_id: row.texture_id }, fence::FixtureRow(index), FixturePlacement { layout: row.layout, fixture_id: row.fixture_id, }, FixtureSource(handle.clone()), - FixtureInstanceSeed { uid: placements.instance_uids[index].clone(), package: row.package, master: row.fixture_id }, - crate::fixture_scene_inputs::FixtureScenePlacement(placements.occupancy_rows()[index].clone()), + FixtureInstanceSeed { uid: placements.instance_uids[index].clone(), package: row.package.clone(), master: row.fixture_id }, + crate::fixture_scene_inputs::FixtureScenePlacement(occupancy[index].clone()), Transform::from_translation(Vec3::from(placed.position)) .with_rotation(Quat::from_rotation_y(placed.yaw)), // Imported vertex colors encode shader masks, not albedo. Reveal @@ -1224,7 +1255,7 @@ fn bind_activity_identities( } else { tables.unique_fixture_master_for_model(model) }; if let Some(master) = master { commands.entity(entity).insert(crate::fixture_activity_state::FixtureActivityIdentity { - uid: seed.uid.clone(), master_id: master.id, model_package: seed.package.into(), + uid: seed.uid.clone(), master_id: master.id, model_package: seed.package.clone(), }); } else { warn!("[fixture-activity] offline layout needs an explicit matching master: {} ({})", seed.uid, seed.package); @@ -1243,7 +1274,7 @@ fn bind_source_views( ) { let (Some(_), Some(points)) = (ready, points) else { return; }; for (root, seed) in &roots { - let Some(source) = points.instance_view(seed.package) else { continue; }; + let Some(source) = points.instance_view(&seed.package) else { continue; }; let mut stack = vec![root]; let mut matches = Vec::new(); while let Some(entity) = stack.pop() { diff --git a/crates/moly-game/src/fixture_attach.rs b/crates/moly-game/src/fixture_attach.rs index 1e9bb3d..5324ce4 100644 --- a/crates/moly-game/src/fixture_attach.rs +++ b/crates/moly-game/src/fixture_attach.rs @@ -251,8 +251,9 @@ fn read_quat(cell: Option<&serde_json::Value>) -> Option { /// 旋转——摆放表朝向绕 Y、档案侧朝向实测全为恒等或纯绕 Y——所以 /// 组合朝向恒纯绕 Y,贴合支的朝向律可以按欧拉 y 直读)。 pub struct AttachWorld { + pub uid: String, /// 摆放包名(组合表的键的一半)。 - pub package: &'static str, + pub package: String, /// 挂点 id(StartLoc 名字数字)。 pub id_value: i32, pub position: [f32; 3], @@ -268,10 +269,10 @@ pub struct AttachWorlds { impl AttachWorlds { /// 某摆放实例某 id 的挂点世界位(决策侧动作点支的取件面)。 - pub(crate) fn entry(&self, package: &str, id_value: i32) -> Option<&AttachWorld> { + pub(crate) fn entry(&self, uid: &str, id_value: i32) -> Option<&AttachWorld> { self.worlds .iter() - .find(|world| world.package == package && world.id_value == id_value) + .find(|world| world.uid == uid && world.id_value == id_value) } /// 身份判定过滤器:目标位与全表挂点比 x/z 两维(引擎 Approximately @@ -291,7 +292,8 @@ impl AttachWorlds { /// 后者的形状是键在、表空)。 fn compose(placements: &FixturePlacements, points: &AttachPoints) -> AttachWorlds { let mut worlds = Vec::new(); - for (package, position, yaw) in placements.placed_rows() { + for placed in placements.placed_instances() { + let (package, position, yaw) = (placed.package, placed.position, placed.yaw); let entries = points.packages.get(package).unwrap_or_else(|| { panic!("摆放包 {package} 不在挂点档案的键上(两份清单不一致)") }); @@ -299,7 +301,8 @@ fn compose(placements: &FixturePlacements, points: &AttachPoints) -> AttachWorld for entry in entries { let world_position = Vec3::from(position) + instance * Vec3::from(entry.position); worlds.push(AttachWorld { - package, + uid: placed.uid.to_owned(), + package: package.to_owned(), id_value: entry.id_value, position: [world_position.x, world_position.y, world_position.z], rotation: instance * entry.rotation, diff --git a/crates/moly-game/src/fixture_colors.rs b/crates/moly-game/src/fixture_colors.rs new file mode 100644 index 0000000..5c2fd39 --- /dev/null +++ b/crates/moly-game/src/fixture_colors.rs @@ -0,0 +1,290 @@ +//! Per-instance source color selection before furniture material conversion. + +use bevy::{ + asset::LoadState, + image::{ + ImageAddressMode, ImageFilterMode, ImageLoaderSettings, ImageSampler, + ImageSamplerDescriptor, + }, + prelude::*, +}; +use moly_assets::{json::JsonAsset, material_textures::SourceMaterialTextures}; +use serde_json::Value; +use std::collections::HashMap; + +#[derive(Component, Clone)] +pub(crate) struct FixtureColorChoice { + pub package: String, + pub texture_id: u32, +} + +#[derive(Component)] +struct ColorApplied; + +type MaterialKey = ( + AssetId, + AssetId, + Option>, +); + +#[derive(Resource)] +pub(crate) struct FixtureColors { + ready: bool, + revision: u64, + request: Option>, + palettes: Option, + images: HashMap>, + materials: HashMap>, + error: Option, +} + +impl Default for FixtureColors { + fn default() -> Self { + Self { + ready: true, + revision: 0, + request: None, + palettes: None, + images: HashMap::new(), + materials: HashMap::new(), + error: None, + } + } +} + +pub(crate) fn ready(colors: Res) -> bool { + colors.ready +} + +fn sampler(value: &Value) -> ImageSamplerDescriptor { + let address = |key: &str| match value[key].as_u64() { + Some(33071) => ImageAddressMode::ClampToEdge, + Some(33648) => ImageAddressMode::MirrorRepeat, + _ => ImageAddressMode::Repeat, + }; + let min = value["minFilter"].as_u64().unwrap_or(9729); + ImageSamplerDescriptor { + address_mode_u: address("wrapS"), + address_mode_v: address("wrapT"), + mag_filter: if value["magFilter"].as_u64() == Some(9728) { + ImageFilterMode::Nearest + } else { + ImageFilterMode::Linear + }, + min_filter: if matches!(min, 9728 | 9984 | 9986) { + ImageFilterMode::Nearest + } else { + ImageFilterMode::Linear + }, + mipmap_filter: if matches!(min, 9986 | 9987) { + ImageFilterMode::Linear + } else { + ImageFilterMode::Nearest + }, + ..default() + } +} + +fn image( + world: &World, + colors: &mut FixtureColors, + value: &Value, +) -> Result>, String> { + if value.is_null() { + return Ok(None); + } + let path = value["path"] + .as_str() + .ok_or("Color texture path is missing")?; + if !path.starts_with("fixture-models/colors/") + || !path.ends_with(".png") + || path.contains("..") + || path.contains('\\') + || path.contains(':') + { + return Err("Color texture path is outside the furniture catalog".into()); + } + if let Some(handle) = colors.images.get(path) { + return Ok(Some(handle.clone())); + } + let sampler = sampler(&value["sampler"]); + let handle = world.resource::().load_with_settings( + format!("moly://{path}"), + move |settings: &mut ImageLoaderSettings| { + settings.sampler = ImageSampler::Descriptor(sampler.clone()); + settings.asset_usage = bevy::asset::RenderAssetUsages::all(); + }, + ); + colors.images.insert(path.to_owned(), handle.clone()); + Ok(Some(handle)) +} + +fn apply(world: &mut World, colors: &mut FixtureColors) -> Result<(), String> { + let revision = world.resource::().0; + if revision != colors.revision { + colors.revision = revision; + colors.materials.clear(); + colors.images.clear(); + colors.error = None; + } + let pending: Vec<_> = world + .query_filtered::<( + Entity, + &FixtureColorChoice, + Option<&crate::fixture::FixtureVisualSceneReady>, + ), Without>() + .iter(world) + .map(|(entity, choice, ready)| (entity, choice.clone(), ready.is_some())) + .collect(); + colors.ready = pending.is_empty(); + if pending.is_empty() { + return Ok(()); + } + if pending.iter().any(|(_, choice, _)| choice.texture_id != 1) && colors.palettes.is_none() { + let handle = colors + .request + .get_or_insert_with(|| { + world + .resource::() + .load("moly://fixture-models/player-data.json") + }) + .clone(); + if matches!( + world.resource::().load_state(&handle), + LoadState::Failed(_) + ) { + return Err("Furniture color catalog could not be loaded".into()); + } + let Some(asset) = world.resource::>().get(&handle) else { + return Ok(()); + }; + let document: Value = + serde_json::from_str(&asset.0).map_err(|e| format!("Furniture color catalog: {e}"))?; + if !document["colorTextures"].is_object() { + return Err("Furniture color textures have not been exported".into()); + } + colors.palettes = Some(document["colorTextures"].clone()); + } + let mut remaining = 0; + for (root, choice, scene_ready) in pending { + if choice.texture_id == 1 { + world.entity_mut(root).insert(ColorApplied); + continue; + } + if !scene_ready { + remaining += 1; + continue; + } + let palette = colors + .palettes + .as_ref() + .and_then(|p| p.get(&choice.package)) + .and_then(|p| p.get(choice.texture_id.to_string())) + .cloned() + .ok_or_else(|| { + format!( + "Furniture color {} is not in the exported catalog", + choice.texture_id + ) + })?; + let Some(main) = image(world, colors, &palette["main"])? else { + world.entity_mut(root).insert(ColorApplied); + continue; + }; + let emission = image(world, colors, &palette["emission"])?; + let mut loaded = true; + for handle in std::iter::once(&main).chain(emission.iter()) { + match world.resource::().load_state(handle) { + LoadState::Failed(_) => return Err( + "A furniture color texture could not be loaded; the import backup is available" + .into(), + ), + LoadState::Loaded => {} + _ => loaded = false, + } + } + if !loaded { + remaining += 1; + continue; + } + let mut stack = vec![root]; + while let Some(entity) = stack.pop() { + if let Some(children) = world.get::(entity) { + stack.extend(children.iter()); + } + let Some(source) = world + .get::>(entity) + .map(|v| v.0.clone()) + else { + continue; + }; + let mut textures = world + .get::(entity) + .cloned() + .unwrap_or_default(); + let Some(original) = world + .resource::>() + .get(&source) + .cloned() + else { + continue; + }; + if original.base_color_texture.is_none() && !textures.0.contains_key("_MainTex") { + continue; + } + let key = (source.id(), main.id(), emission.as_ref().map(Handle::id)); + let handle = if let Some(handle) = colors.materials.get(&key) { + handle.clone() + } else { + let mut replacement = original; + replacement.base_color_texture = Some(main.clone()); + let handle = world + .resource_mut::>() + .add(replacement); + colors.materials.insert(key, handle.clone()); + handle + }; + textures.0.insert("_MainTex".into(), main.clone()); + if let Some(emission) = &emission { + textures + .0 + .insert("_EmissionMaskTex".into(), emission.clone()); + } + world + .entity_mut(entity) + .insert((MeshMaterial3d(handle), textures)); + } + world.entity_mut(root).insert(ColorApplied); + } + colors.ready = remaining == 0; + Ok(()) +} + +pub(crate) fn prepare(world: &mut World) { + let Some(mut colors) = world.remove_resource::() else { + return; + }; + if let Err(error) = apply(world, &mut colors) { + colors.ready = false; + if colors.error.as_ref() != Some(&error) { + warn!("[player-data] {error}"); + if let Some(mut state) = + world.get_resource_mut::() + { + state.status = error.clone(); + } + if let Some(mut panel) = world.get_resource_mut::() + { + panel.open = true; + panel.player_data = true; + } + colors.error = Some(error); + } + } + world.insert_resource(colors); +} + +pub(crate) fn install(app: &mut App) { + app.init_resource::() + .add_systems(Update, prepare.after(crate::fixture::FixtureLayoutSet)); +} diff --git a/crates/moly-game/src/fixture_compact.rs b/crates/moly-game/src/fixture_compact.rs index 7661ebd..ccb723d8 100644 --- a/crates/moly-game/src/fixture_compact.rs +++ b/crates/moly-game/src/fixture_compact.rs @@ -6,13 +6,13 @@ use super::{Direction, GridPosition, PlacementMock, layout_type}; pub(super) const PLACEMENTS: [PlacementMock; 6] = [ - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_mis0001_fixture_chair1", min: GridPosition { x: -16, y: 0, z: 0 }, max: GridPosition { x: -15, y: 1, z: 1 }, center_y: 0, layout: layout_type::FLOOR, direction: Direction::Front, fixture_id: 8, }, - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_ext0001_fixture_lamp1", // Fits even the source L1 home grid [-18,18), without changing any // already-saved layout. This is a starter position, not source geometry. @@ -20,25 +20,25 @@ pub(super) const PLACEMENTS: [PlacementMock; 6] = [ max: GridPosition { x: -17, y: 3, z: 1 }, center_y: 0, layout: layout_type::FLOOR, direction: Direction::Front, fixture_id: 146, }, - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_ext0002_fixture_fridge1", min: GridPosition { x: -16, y: 0, z: 8 }, max: GridPosition { x: -15, y: 3, z: 9 }, center_y: 0, layout: layout_type::FLOOR, direction: Direction::Front, fixture_id: 157, }, - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_ext0014_fixture_synthesizer1", min: GridPosition { x: -4, y: 0, z: 8 }, max: GridPosition { x: -1, y: 1, z: 9 }, center_y: 0, layout: layout_type::FLOOR, direction: Direction::Front, fixture_id: 297, }, - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_chr0004_fixture_nenerobo1", min: GridPosition { x: -10, y: 0, z: 0 }, max: GridPosition { x: -9, y: 2, z: 1 }, center_y: 0, layout: layout_type::FLOOR, direction: Direction::Front, fixture_id: 423, }, - PlacementMock { + PlacementMock { texture_id: 1, package: "mysekai__fixture__mdl_cncollect_rug_star3", min: GridPosition { x: -14, y: 0, z: 10 }, max: GridPosition { x: -7, y: 0, z: 17 }, diff --git a/crates/moly-game/src/fixture_edit.rs b/crates/moly-game/src/fixture_edit.rs index 0a34216..2be3e35 100644 --- a/crates/moly-game/src/fixture_edit.rs +++ b/crates/moly-game/src/fixture_edit.rs @@ -71,7 +71,8 @@ pub(crate) enum EditPhase { #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct EditItemView { pub uid: String, - pub package: &'static str, + pub package: String, + pub texture_id: u32, pub fixture_id: i32, pub center: GridPosition, pub direction: Direction, @@ -84,7 +85,8 @@ impl From<&EditableFixture> for EditItemView { fn from(item: &EditableFixture) -> Self { Self { uid: item.uid.clone(), - package: item.package, + package: item.package.clone(), + texture_id: item.texture_id, fixture_id: item.fixture_id, center: item.center, direction: item.direction, @@ -229,13 +231,6 @@ pub(crate) enum FixtureEditSystems { View, } -pub(crate) fn canonical_package(name: &str) -> Option<&'static str> { - CANDIDATES - .iter() - .find(|row| row.package == name) - .map(|row| row.package) -} - pub(crate) fn has_unsaved_layout(session: &EditSession) -> bool { session.dirty() || session.selected.is_some() || session.pending_reload.is_some() } @@ -578,9 +573,9 @@ fn apply_command(world: &mut World, session: &mut EditSession, command: EditComm } session.next_fixture_uid = next_serial; session.catalog_index = index; - let item = EditableFixture { + let item = EditableFixture { texture_id: 1, uid, - package: row.package, + package: row.package.to_owned(), fixture_id: row.fixture_id, center: GridPosition::ZERO, grid_size: row.grid_size, diff --git a/crates/moly-game/src/fixture_edit/presentation.rs b/crates/moly-game/src/fixture_edit/presentation.rs index 4154e3c..4ae0c7a 100644 --- a/crates/moly-game/src/fixture_edit/presentation.rs +++ b/crates/moly-game/src/fixture_edit/presentation.rs @@ -201,12 +201,12 @@ fn sync_previews(world: &mut World, rows: &[EditableFixture]) -> bool { continue; } let handle = world.resource_scope(|world, mut candidates: Mut| { - if let Some(handle) = candidates.glbs.get(row.package) { + if let Some(handle) = candidates.glbs.get(&row.package) { return Some(handle.clone()); } - let path = candidates.paths.get(row.package)?.clone(); + let path = candidates.paths.get(&row.package)?.clone(); let handle = world.resource::().load::(path); - candidates.glbs.insert(row.package.into(), handle.clone()); + candidates.glbs.insert(row.package.clone(), handle.clone()); Some(handle) }); let Some(handle) = handle else { @@ -234,6 +234,7 @@ fn sync_previews(world: &mut World, rows: &[EditableFixture]) -> bool { world.spawn(( SceneRoot(scene), crate::fixture::FixtureVisualRoot { layout: row.layout }, + crate::fixture_colors::FixtureColorChoice { package: row.package.clone(), texture_id: row.texture_id }, DraftPreview { uid: row.uid.clone(), }, diff --git a/crates/moly-game/src/fixture_edit/validation.rs b/crates/moly-game/src/fixture_edit/validation.rs index 168dd50..a77a6f4 100644 --- a/crates/moly-game/src/fixture_edit/validation.rs +++ b/crates/moly-game/src/fixture_edit/validation.rs @@ -133,7 +133,7 @@ pub(super) fn save( return Err("地图等级尺寸尚未就绪".into()); }; for owner in rows { - if let Some(meta) = areas.cutscene.get(owner.package) { + if let Some(meta) = areas.cutscene.get(&owner.package) { let protected = cutscene_cells(meta, owner); if protected .iter() @@ -155,8 +155,8 @@ pub(super) fn save( } const ANIMATION_SPACE_ENFORCED: [&str; 0] = []; for item in rows { - if ANIMATION_SPACE_ENFORCED.contains(&item.package) { - let Some(meta) = areas.motion.get(item.package) else { + if ANIMATION_SPACE_ENFORCED.contains(&item.package.as_str()) { + let Some(meta) = areas.motion.get(&item.package) else { return Err(format!( "ErrorAnimationSpace(2):{}缺少源动作区域", item.uid diff --git a/crates/moly-game/src/fixture_edit_ui.rs b/crates/moly-game/src/fixture_edit_ui.rs index b83031d..1a5c794 100644 --- a/crates/moly-game/src/fixture_edit_ui.rs +++ b/crates/moly-game/src/fixture_edit_ui.rs @@ -500,9 +500,9 @@ pub(crate) fn click( fn source_world_position(selection: &EditSelectionView) -> Option { let item = &selection.item; - let pose = EditableFixture { + let pose = EditableFixture { texture_id: item.texture_id, uid: item.uid.clone(), - package: item.package, + package: item.package.clone(), fixture_id: item.fixture_id, center: item.center, grid_size: item.grid_size, diff --git a/crates/moly-game/src/fixture_gallery.rs b/crates/moly-game/src/fixture_gallery.rs index e7449c7..a1fa49b 100644 --- a/crates/moly-game/src/fixture_gallery.rs +++ b/crates/moly-game/src/fixture_gallery.rs @@ -100,7 +100,7 @@ pub(super) fn append_preview(rows: &mut Vec) { .unwrap_or_else(|| panic!("家具预览参数无效:{selection:?};支持 surface-0..{} 或 gimmick-0..{}", SURFACES.len() - 1, GIMMICKS.len() - 1)); let (package, [width, height, depth], id) = catalog[index]; let min = GridPosition { x: -16, y: 0, z: 14 }; - rows.push(PlacementMock { package, min, + rows.push(PlacementMock { texture_id: 1, package, min, max: GridPosition { x: min.x + width - 1, y: height - 1, z: min.z + depth - 1 }, center_y: 0, layout: layout_type::FLOOR, direction: Direction::Front, fixture_id: id }); bevy::log::info!("家具预览:{package},master fixture {id}"); @@ -124,7 +124,7 @@ pub(super) fn append_preview(rows: &mut Vec) { for &(x, z, direction) in cells { // CN master: all fences occupy 2x2 floor cells; green hedge is one // cell high (fixture117), the other six are two cells high. - rows.push(PlacementMock { + rows.push(PlacementMock { texture_id: 1, package: catalog[index], min: GridPosition { x, y: 0, z }, max: GridPosition { x: x + 1, y: if road || index == 1 { 0 } else { 1 }, z: z + 1 }, @@ -134,9 +134,3 @@ pub(super) fn append_preview(rows: &mut Vec) { } bevy::log::info!("家具预览:{} {layout},{} 个实例;道路/围栏使用源邻接规则", catalog[index], cells.len()); } - -pub(super) fn canonical_package(name: &str) -> Option<&'static str> { - FENCES.iter().chain(ROADS.iter()).copied() - .chain(SURFACES.iter().chain(GIMMICKS.iter()).map(|(package, _, _)| *package)) - .find(|package| *package == name) -} diff --git a/crates/moly-game/src/fixture_layout_inventory.rs b/crates/moly-game/src/fixture_layout_inventory.rs index d8e8ad1..b608c3a 100644 --- a/crates/moly-game/src/fixture_layout_inventory.rs +++ b/crates/moly-game/src/fixture_layout_inventory.rs @@ -56,6 +56,7 @@ pub(super) fn read(document: &Value) -> Result, String> { .and_then(|v| i32::try_from(v).ok()) .ok_or("returned mysekaiFixtureId must be i32")?; let item = EditableFixture { + texture_id: record.get("textureId").map(|v| super::positive_u32(v, "textureId")).transpose()?.unwrap_or(1), uid: uid.into(), package, fixture_id, @@ -179,6 +180,7 @@ pub(super) fn encode( ("centerY", json!(item.center.y)), ("layoutType", json!(item.layout)), ("rotation", json!(item.direction as u8)), + ("textureId", json!(item.texture_id)), ] { object.insert(key.to_owned(), value); } diff --git a/crates/moly-game/src/fixture_layouts.rs b/crates/moly-game/src/fixture_layouts.rs index b2e0a21..a78f9b4 100644 --- a/crates/moly-game/src/fixture_layouts.rs +++ b/crates/moly-game/src/fixture_layouts.rs @@ -13,7 +13,7 @@ use crate::{settings_store, site::OfflineSceneContent}; #[path = "fixture_layout_inventory.rs"] mod inventory; -const SECTION: &str = "OfflineSiteLayouts"; +pub(crate) const SECTION: &str = "OfflineSiteLayouts"; const VERSION: u64 = 1; /// Private optimistic editor baseline. Full records, including unknown fields, @@ -95,6 +95,10 @@ impl Default for SiteFixtureLayouts { } impl SiteFixtureLayouts { + pub(crate) fn from_document(document: Value) -> Self { + Self { document: Ok(document), visited: HashMap::new() } + } + pub(crate) fn saved_level(&self, site_id: u32) -> Result, String> { if let Some(layout) = self.visited.get(&site_id) { return Ok(Some(layout.level)); @@ -185,7 +189,7 @@ impl SiteFixtureLayouts { .map(|serial| format!("offline-fixture-{site_id}-{serial}")) .collect(); Ok(FixturePlacements { - rows, instance_uids, site_id, site_type: site_type.to_owned(), level, + rows: rows.into_iter().map(PlacementMock::into_owned).collect(), instance_uids, site_id, site_type: site_type.to_owned(), level, next_edit_uid: 1, floor: None, }) } @@ -239,11 +243,8 @@ fn decode(saved: &Value, site_id: u32, site_type: &str, level: u32) -> Result Result Option<&'static str> { - PLACEMENTS.iter().chain(compact::PLACEMENTS.iter()) - .find(|row| row.package == name).map(|row| row.package) - .or_else(|| gallery::canonical_package(name)) - .or_else(|| crate::fixture_edit::canonical_package(name)) +fn canonical_package(name: &str) -> Option { + let suffix = name.strip_prefix("mysekai__fixture__")?; + (!suffix.is_empty() && suffix.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_')) + .then(|| name.to_owned()) +} + +/// Build every imported site before the caller replaces the local document. +pub(crate) fn import_section( + data: &moly_assets::player_data::ImportedPlayerData, sites: &crate::site::Sites, +) -> Result { + let mut records = Map::new(); + for site in &data.sites { + if sites.site_id(&site.site_type) != Some(site.id) { + return Err(format!("Site {} does not match the loaded scene catalog", site.id)); + } + let floor = sites.floor_grid(&site.site_type, site.level)?; + let fixtures: Vec<_> = site.fixtures.iter().map(|row| { + let (min, max) = moly_law::fixture::position::footprint_front(row.center, row.grid_size); + json!({ "mysekaiUniqueId": row.uid, "package": row.package, + "mysekaiFixtureId": row.fixture_id, "textureId": row.texture_id, + "minimum": grid_json(min), "maximum": grid_json(max), "centerY": row.center.y, + "layoutType": row.layout, "rotation": row.direction as u8, + "sourceArray": row.source_array, "sourceData": row.source }) + }).collect(); + let record = json!({ "mysekaiSiteId": site.id, "siteType": site.site_type, + "level": site.level, "nextEditUid": 1, "fixtures": fixtures, + "mysekaiFixtureSurfaceAppearances": site.source.get("mysekaiFixtureSurfaceAppearances") + .cloned().unwrap_or_else(|| json!([])), + "mysekaiPhenomenaId": site.source.get("mysekaiPhenomenaId") }); + let layout = decode(&record, site.id, &site.site_type, site.level)?; + validate_floor_layout(&layout, floor)?; + records.insert(site.id.to_string(), record); + } + Ok(json!({ "version": VERSION, "sites": records, "returnedFixtures": [] })) +} + +pub(crate) fn validate_document(document: &Value, sites: &crate::site::Sites) -> Result<(), String> { + let Some(section) = document.get(SECTION) else { return Ok(()); }; + if section["version"].as_u64() != Some(VERSION) { return Err("Unsupported layout backup version".into()); } + for (key, record) in section["sites"].as_object().ok_or("Backup sites must be an object")? { + let id = key.parse::().map_err(|_| "Backup site ID is invalid")?; + saved_site(document, id)?; + let site_type = record["siteType"].as_str().ok_or("Backup site type is missing")?; + if sites.site_id(site_type) != Some(id) { return Err("Backup site identity does not match the catalog".into()); } + let level = positive_u32(&record["level"], "backup site level")?; + validate_floor_layout(&decode(record, id, site_type, level)?, sites.floor_grid(site_type, level)?)?; + } + inventory::read(document)?; + inventory::previous_records(document)?; + Ok(()) } /// Existing rows are checked too: a level override may not turn a large saved @@ -298,7 +345,7 @@ pub(crate) fn validate_floor_layout(layout: &FixturePlacements, floor: crate::si /// Layout and returned items share the existing settings write. Validate the /// exact Enter/last-success snapshot and build the patch on the SAME fresh -/// document used by merge/write. This is synchronous stale-preflight, not CAS. +/// document used by merge/write under the exclusive settings transaction. pub(crate) fn persist_edit( layout: &FixturePlacements, returned: &[super::EditableFixture], @@ -326,7 +373,7 @@ pub(crate) fn persist_edit( ("mysekaiFixtureId", json!(row.fixture_id)), ("minimum", grid_json(row.min)), ("maximum", grid_json(row.max)), ("centerY", json!(row.center_y)), ("layoutType", json!(row.layout)), - ("rotation", json!(row.direction as u8)), + ("rotation", json!(row.direction as u8)), ("textureId", json!(row.texture_id)), ] { object.insert(key.to_owned(), value); } fixtures.push(record); } diff --git a/crates/moly-game/src/fixture_material.rs b/crates/moly-game/src/fixture_material.rs index 29a7125..8a244de 100644 --- a/crates/moly-game/src/fixture_material.rs +++ b/crates/moly-game/src/fixture_material.rs @@ -1863,7 +1863,8 @@ fn build_swap_plan( } } } - None => None, + None => source_textures.get(entity).ok() + .and_then(|textures| textures.0.get("_EmissionMaskTex")).cloned(), }; planned.push(Planned { force_emission: resolved.force_emission, @@ -1912,7 +1913,8 @@ impl Plugin for FixtureMaterialPlugin { app.add_plugins(MaterialPlugin::::default()) .add_plugins(surfaces::FixtureSurfacePlugin) .add_systems(Update, switch_materials.in_set(FixtureMaterialSet) - .after(crate::fixture::FixtureLayoutSet)); + .after(crate::fixture::FixtureLayoutSet).after(crate::fixture_colors::prepare) + .run_if(crate::fixture_colors::ready)); bevy::asset::embedded_asset!(app, "shaders/fixture_material.wgsl"); } } diff --git a/crates/moly-game/src/fixture_scene_inputs.rs b/crates/moly-game/src/fixture_scene_inputs.rs index e33c38d..da01e3a 100644 --- a/crates/moly-game/src/fixture_scene_inputs.rs +++ b/crates/moly-game/src/fixture_scene_inputs.rs @@ -439,7 +439,7 @@ fn build_floor( } let mut add_using = areas .entries - .get(row.package) + .get(&row.package) .ok_or("AddUsingGrid package record is missing")? .clone()?; add_using.rotate(row.direction, true); diff --git a/crates/moly-game/src/fixture_surfaces.rs b/crates/moly-game/src/fixture_surfaces.rs index b70f29e..c4a2330 100644 --- a/crates/moly-game/src/fixture_surfaces.rs +++ b/crates/moly-game/src/fixture_surfaces.rs @@ -321,7 +321,8 @@ impl Plugin for FixtureSurfacePlugin { .init_resource::() .init_resource::() .add_systems(Update, (bind_surfaces, update_block_opacity).chain() - .after(crate::fixture::FixtureLayoutSet).before(FixtureMaterialSet)); + .after(crate::fixture::FixtureLayoutSet).after(crate::fixture_colors::prepare) + .run_if(crate::fixture_colors::ready).before(FixtureMaterialSet)); bevy::asset::embedded_asset!(app, "shaders/fixture_surface.wgsl"); } } diff --git a/crates/moly-game/src/game_settings.rs b/crates/moly-game/src/game_settings.rs index 055e45f..a5d9de2 100644 --- a/crates/moly-game/src/game_settings.rs +++ b/crates/moly-game/src/game_settings.rs @@ -51,11 +51,17 @@ pub(crate) struct GameSettings { #[derive(Resource, Default)] pub(crate) struct SettingsPanel { pub(crate) open: bool, + pub(crate) player_data: bool, release_guard: u8, status: String, } impl SettingsPanel { + pub(crate) fn close_after_import(&mut self) { + self.open = false; + self.release_guard = 2; + } + pub(crate) fn blocks_world_input(&self) -> bool { self.open || self.release_guard != 0 } @@ -71,6 +77,8 @@ pub(crate) enum SettingsPanelRequest { #[derive(Component)] pub(crate) struct PanelRoot; #[derive(Component)] +pub(crate) struct SettingsBody; +#[derive(Component)] struct SettingsUiCamera; #[derive(Component)] pub(crate) struct CompositeCamera; @@ -100,6 +108,8 @@ pub(crate) enum Action { Save, Defaults, Capture, + PlayerData, + SettingsPage, } #[derive(Component)] @@ -225,8 +235,15 @@ pub(crate) fn setup( .id(); commands.entity(root).add_child(content); let heading = row(&mut commands, content); - add_text(&mut commands, heading, &font, "GAME SETTINGS", 23., None); + add_text(&mut commands, heading, &font, &format!("moly v{}", crate::VERSION), 23., None); + add_button(&mut commands, heading, &font, "Audio/video", Action::SettingsPage); + add_button(&mut commands, heading, &font, "Player data", Action::PlayerData); add_button(&mut commands, heading, &font, "Close", Action::Close); + crate::player_data_ui::spawn(&mut commands, content, &font); + let settings_body = commands.spawn((Node { width: percent(100), + flex_direction: FlexDirection::Column, row_gap: px(12), ..default() }, SettingsBody)).id(); + commands.entity(content).add_child(settings_body); + let content = settings_body; for (index, title) in ["Music", "Sound effects", "Voice"].into_iter().enumerate() { let line = row(&mut commands, content); add_text(&mut commands, line, &font, title, 18., None); @@ -449,6 +466,8 @@ pub(crate) fn input( captures.write(crate::frame_capture::CaptureFrame); } _ if !panel.open => continue, + Action::PlayerData => panel.player_data = true, + Action::SettingsPage => panel.player_data = false, Action::AudioOptions => { dialogs.menu_open = false; dialogs.option_open = true; @@ -490,7 +509,8 @@ pub(crate) fn input( panel.status = if store.save(§ions) { "Saved.".into() } else { - "Save failed. Session values are still active.".into() + format!("Save failed: {}. Session values are still active.", + store.last_error.as_deref().unwrap_or("storage unavailable")) }; if let Some(error) = &store.last_error { warn!("[game-settings] {error}"); @@ -505,6 +525,7 @@ pub(crate) fn refresh_ui( settings: Res, volumes: Res, mut roots: Query<&mut Node, With>, + mut bodies: Query<&mut Node, (With, Without)>, mut labels: Query<(&ValueLabel, &mut Text)>, diagnostics: Res, time: Res>, @@ -518,6 +539,9 @@ pub(crate) fn refresh_ui( }; } if !panel.open { return; } + for mut body in &mut bodies { + body.display = if panel.player_data { Display::None } else { Display::Flex }; + } let refresh_performance = time.elapsed_secs_f64() - *last_performance >= 0.5; if refresh_performance { *last_performance = time.elapsed_secs_f64(); } for (label, mut text) in &mut labels { diff --git a/crates/moly-game/src/info.rs b/crates/moly-game/src/info.rs index 057c460..1071c6a 100644 --- a/crates/moly-game/src/info.rs +++ b/crates/moly-game/src/info.rs @@ -387,6 +387,12 @@ fn env_f32_range(name: &str, default: f32, lo: f32, hi: f32) -> f32 { } } +impl InfoMock { + pub(crate) fn set_player_rank(&mut self, rank: Option) { + self.rank_level = rank.unwrap_or_else(|| env_u32("MOLY_INFO_MOCK_RANK_LEVEL", 1)); + } +} + impl Default for InfoMock { fn default() -> Self { let access_permission = match std::env::var("MOLY_INFO_MOCK_ACCESS_PERMISSION") { @@ -404,7 +410,7 @@ impl Default for InfoMock { }; InfoMock { access_permission, - rank_level: env_u32("MOLY_INFO_MOCK_RANK_LEVEL", 1), + rank_level: crate::player_data::saved_rank().unwrap_or_else(|| env_u32("MOLY_INFO_MOCK_RANK_LEVEL", 1)), rank_gauge: env_f32_range("MOLY_INFO_MOCK_RANK_GAUGE", 0.0, 0.0, 1.0), fixture_put_limit: env_u32("MOLY_INFO_MOCK_FIXTURE_PUT_LIMIT", 20), fixture_joint_put_limit: env_u32("MOLY_INFO_MOCK_FIXTURE_JOINT_PUT_LIMIT", 10), diff --git a/crates/moly-game/src/lib.rs b/crates/moly-game/src/lib.rs index fdaddbc..8ea7a83 100644 --- a/crates/moly-game/src/lib.rs +++ b/crates/moly-game/src/lib.rs @@ -19,6 +19,7 @@ mod delayed_faces; pub mod emoticon; pub mod env; pub mod fixture; +mod fixture_colors; pub mod fixture_attach; mod fixture_activity_data; mod fixture_activity_provider; @@ -52,6 +53,8 @@ mod npc_fixture_activity; pub mod option_dialog; pub mod pick; pub mod player; +pub mod player_data; +mod player_data_ui; pub mod player_avatar; mod player_fixture_action; pub mod player_state; @@ -83,120 +86,56 @@ pub mod weather_fx; use bevy::prelude::*; -/// 唯一的 App 组装点。schedule 细节在 `schedule.rs`;这里只挂 plugin。 -/// 资产源与站点选择都在入口 crate 解析成值传入(env / URL 参数的拒绝 -/// 点全树只在那一处);wasm 版多收一份渲染后端设置(探测在 moly-app, -/// 取舍见其 `render_backend`)——0.18 的 `WgpuSettings` 只能从 -/// `RenderPlugin.render_creation` 字段进(没有世界资源口),而字段的值在 -/// `add_plugins` 时就固定,所以只能在这个组装点走 `DefaultPlugins.set`, -/// 不能 fork 插件。 -#[cfg(not(target_arch = "wasm32"))] -pub fn app(source: moly_assets::AssetSource, site: site::SiteRequest) -> App { - let mut app = App::new(); - // 先于 DefaultPlugins:AssetPlugin 构建时固化全部资产源(见 moly_assets::install)。 - moly_assets::install(&mut app, source); - app.add_plugins(DefaultPlugins); - app.add_plugins(native_graphics_diagnostics::NativeGraphicsDiagnosticsPlugin); - // 清单类 JSON 装载器落在 AssetServer 上,只能在其创建之后注册。 - moly_assets::json::register(&mut app); - moly_assets::material_textures::register(&mut app); - // 天空材质的 Assets/管线集合:先于任何引用它的 system 安装。 - sky::install(&mut app); - // 表情材质的管线集合(sprite 子矩形与粒子 billboard 共用)。 - emoticon::install(&mut app); - // 天气系统:现象切换 + 后处理轴上屏(render graph 节点)。 - // 两侧同挂——wasm 分支的站点/天空/音频消费面早已补齐,注册面 - // 也同形(此前那边漏挂,三锚永不出现)。 - app.add_plugins(weather::WeatherPlugin); - // 雨粒子材质同款:先于 schedule 里引用它的 system。 - // 站点侧 UberUnlit 粒子材质同款。 - uber_particle::install(&mut app); - // 音频域:面板与通道资源先落位(当前档资源在这里兜底建——见该模块)。 - audio::install(&mut app); - ui_layout::install(&mut app); - // 站点选择资源由入口参数起值;站点场景观察者随插件挂。 - app.add_plugins(site::SitePlugin(site)); - schedule::install(&mut app); - // 后于 DefaultPlugins:embedded_asset! 需要 AssetPlugin 的内嵌注册表。 - app.add_plugins(site_material::SiteMaterialPlugin); - app.add_plugins(material_order::MaterialOrderPlugin); - app.add_plugins(character_material::CharacterMaterialPlugin); - // 主光阴影深度图(单图路线):消费面在站点材质,native 与 wasm 都装。 - app.add_plugins(shadowmap::ShadowmapPlugin); - // 家具链(装载与摆放 mock · Basic 族材质换装):材质换装消费站点材质 - // 插件装的全局量 buffer(该插件两侧同挂)。自发光 pass(第二颜色目标 - // 直画进泛光输入缓冲)由换装侧给实体插组件,两侧都装——那边漏挂会 - // 让第二目标在那侧永远空。 - app.add_plugins(( - fixture::FixturePlugin, - fixture_material::FixtureMaterialPlugin, - fixture_emission::FixtureEmissionPlugin, - )); - // 摆放编辑面:摆放 mock 的交互化——输入与状态机 - // 面板,无材质依赖,native 与 wasm 同装。 - app.add_plugins(fixture_edit::FixtureEditPlugin); - // 采集物链同款:装载/摆放/交互在本体插件,材质换装在材质插件(消费 - // 站点插件装的全局量 buffer)。 - app.add_plugins((harvest::HarvestPlugin, harvest_material::HarvestMaterialPlugin)); - app -} +/// Product version shared by the settings panel and application entry points. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); #[cfg(target_arch = "wasm32")] +pub use settings_store::set_browser_storage_writable; + +/// Shared native/browser assembly. Only renderer initialization and native +/// diagnostics vary by platform; every gameplay plugin is installed here. pub fn app( source: moly_assets::AssetSource, site: site::SiteRequest, - web_render_settings: bevy::render::settings::WgpuSettings, + #[cfg(target_arch = "wasm32")] web_render_settings: bevy::render::settings::WgpuSettings, ) -> App { let mut app = App::new(); - // 先于 DefaultPlugins:AssetPlugin 构建时固化全部资产源(见 moly_assets::install)。 + // Asset sources must be registered before AssetPlugin is built. moly_assets::install(&mut app, source); - app.add_plugins(DefaultPlugins.set(bevy::render::RenderPlugin { + let plugins = DefaultPlugins.set(bevy::window::WindowPlugin { + primary_window: Some(Window { title: format!("moly v{VERSION}"), ..default() }), + ..default() + }); + #[cfg(target_arch = "wasm32")] + // The browser host reports failures to its retry page. Keep that hook. + let plugins = plugins.disable::().set(bevy::render::RenderPlugin { render_creation: bevy::render::settings::RenderCreation::Automatic(web_render_settings), - // 其余字段与 `RenderPlugin::default()` 一致。 - ..Default::default() - })); - // 清单类 JSON 装载器落在 AssetServer 上,只能在其创建之后注册。 + ..default() + }); + app.add_plugins(plugins); + #[cfg(not(target_arch = "wasm32"))] + app.add_plugins(native_graphics_diagnostics::NativeGraphicsDiagnosticsPlugin); + moly_assets::json::register(&mut app); moly_assets::material_textures::register(&mut app); - // 天空材质的 Assets/管线集合:先于 schedule 里引用它的 system。 - // wasm 侧此前漏挂(native 有)——schedule 的 spawn_when_ready 两分支 - // 共用,缺 Assets 即 panic,页面白屏。 sky::install(&mut app); - // 雨粒子材质的 Assets/管线集合:先于 schedule 里引用它的 system。 - // 站点侧 UberUnlit 粒子材质同款(两侧同挂:漏挂那侧粒子永不上屏)。 - uber_particle::install(&mut app); - // 表情材质的管线集合(wasm 分支同样上屏)。 emoticon::install(&mut app); - // 天气系统:现象切换 + 后处理轴上屏(render graph 节点)。补挂—— - // 此前 wasm 分支漏挂(native 有),三锚(现象清单解析/天气系统就绪/ - // C 键切换)在 web 永不出现、无现象可切;清单 JSON 的装载本身两侧 - // 同形且正常(sky/audio 消费同一份在先),缺的只是这条注册。 app.add_plugins(weather::WeatherPlugin); - // 音频域:面板与通道资源先落位(当前档资源在这里兜底建——见该模块; - // 天气插件两侧同挂后此兜底照旧,init_resource 幂等)。 + uber_particle::install(&mut app); audio::install(&mut app); ui_layout::install(&mut app); - // 站点选择资源由入口参数起值;站点场景观察者随插件挂。 app.add_plugins(site::SitePlugin(site)); schedule::install(&mut app); - // 后于 DefaultPlugins:embedded_asset! 需要 AssetPlugin 的内嵌注册表。 - // 站点材质换装(含全局量 buffer 的建立):wasm 此前未装,站点网格 - // 一直以 PBR 默认材质渲染(浏览器端「草地贴图丢失/素」的根因)。 app.add_plugins(site_material::SiteMaterialPlugin); app.add_plugins(material_order::MaterialOrderPlugin); app.add_plugins(character_material::CharacterMaterialPlugin); - // 主光阴影深度图同款:站点材质在 wasm 一样收影,深度 pass 两侧都装。 app.add_plugins(shadowmap::ShadowmapPlugin); - // 家具链同款:材质换装消费站点插件装的全局量 buffer。自发光 pass - // 同装(第二颜色目标两侧都要上屏)。 app.add_plugins(( fixture::FixturePlugin, fixture_material::FixtureMaterialPlugin, fixture_emission::FixtureEmissionPlugin, )); - // 摆放编辑面同款:输入与状态机面板,无材质依赖。 app.add_plugins(fixture_edit::FixtureEditPlugin); - // 采集物链同款。 app.add_plugins((harvest::HarvestPlugin, harvest_material::HarvestMaterialPlugin)); app } diff --git a/crates/moly-game/src/menu_dialog.rs b/crates/moly-game/src/menu_dialog.rs index 73e0989..3bff5d2 100644 --- a/crates/moly-game/src/menu_dialog.rs +++ b/crates/moly-game/src/menu_dialog.rs @@ -366,6 +366,12 @@ fn env_bool(name: &str, default: bool) -> bool { } } +impl MenuMock { + pub(crate) fn set_player_rank(&mut self, rank: Option) { + self.rank_level = rank.unwrap_or_else(|| env_u32("MOLY_MENU_MOCK_RANK_LEVEL", 3)); + } +} + impl Default for MenuMock { fn default() -> Self { MenuMock { @@ -373,7 +379,7 @@ impl Default for MenuMock { stamina_enhance: env_i32("MOLY_MENU_MOCK_STAMINA_ENHANCE", 0), stamina_boost: env_i32("MOLY_MENU_MOCK_STAMINA_BOOST", 0), stamina_max: env_u32("MOLY_MENU_MOCK_STAMINA_MAX", 240), - rank_level: env_u32("MOLY_MENU_MOCK_RANK_LEVEL", 3), + rank_level: crate::player_data::saved_rank().unwrap_or_else(|| env_u32("MOLY_MENU_MOCK_RANK_LEVEL", 3)), rank_exp_next: env_u32("MOLY_MENU_MOCK_RANK_EXP_NEXT", 450), visiting: env_bool("MOLY_MENU_MOCK_VISITING", false), photo_shot_allowed: env_bool("MOLY_MENU_MOCK_PHOTO_SHOT_ALLOWED", true), diff --git a/crates/moly-game/src/npc_fixture_activity/areas.rs b/crates/moly-game/src/npc_fixture_activity/areas.rs index 1306c69..824f0a8 100644 --- a/crates/moly-game/src/npc_fixture_activity/areas.rs +++ b/crates/moly-game/src/npc_fixture_activity/areas.rs @@ -52,7 +52,7 @@ impl NpcFixtureAreas { rows: &[OccupancyRow], floor: &FixtureFloorTiles, ) -> Result { - let mut motion = self.row(row.package)?.0.clone(); + let mut motion = self.row(&row.package)?.0.clone(); motion.rotate(row.direction, true); // FixtureManager first collects the other fixtures through the floor // grid cells of UpdateMotionAreaBoundList. Rugs do not become floor @@ -81,7 +81,7 @@ impl NpcFixtureAreas { if others.next().is_some() { return Err(format!("motion-area occupant {uid} is ambiguous")); } - let mut add = self.row(other.package)?.1.clone(); + let mut add = self.row(&other.package)?.1.clone(); add.rotate(other.direction, true); let add_center = rotated_center_grid(other.layout_center, other.layout_grid_size, other.direction); diff --git a/crates/moly-game/src/npc_objective.rs b/crates/moly-game/src/npc_objective.rs index e12c05a..f29872b 100644 --- a/crates/moly-game/src/npc_objective.rs +++ b/crates/moly-game/src/npc_objective.rs @@ -586,8 +586,9 @@ impl objective::Permute for PermuteSource<'_> { /// 靠近家具链的环带按盒算,抽签按 fixtureId 记账,动作点支按包取 /// 挂点条目。 struct AnchoredFixture { + uid: String, fixture_id: i32, - package: &'static str, + package: String, min: Cell, max: Cell, } @@ -599,25 +600,16 @@ pub(crate) struct AnchoredFixtureCache { rows: Option>, } -/// 锚定摆放联结表:锚定行(fixtureId 非 0)按包名对上占用行取格界。 -/// 包名是摆放表的主键(一条摆放一个包),对不上即响亮拒绝。 +/// Preserve each placed instance's footprint, including repeated furniture IDs. fn anchored_fixtures(placements: &FixturePlacements) -> Vec { - let rows = placements.occupancy_rows(); - placements - .anchored() - .into_iter() - .map(|(fixture_id, package)| { - let Some(row) = rows.iter().find(|row| row.package == package) else { - panic!("锚定家具 {fixture_id} 的摆放包 {package} 不在占用表里"); - }; - AnchoredFixture { - fixture_id, - package, - min: (row.min.x as i32, row.min.z as i32), - max: (row.max.x as i32, row.max.z as i32), - } - }) - .collect() + placements.occupancy_rows().into_iter().zip(placements.fixture_ids()) + .filter(|(_, id)| *id != 0) + .map(|(row, fixture_id)| AnchoredFixture { + uid: row.uid, + fixture_id, package: row.package, + min: (row.min.x as i32, row.min.z as i32), + max: (row.max.x as i32, row.max.z as i32), + }).collect() } /// 道别 → 槽位类型(替身映射,见模块注释)。 @@ -704,7 +696,7 @@ enum FixtureOutcome { ActionPoint { fixture_id: i32, seat: i32, - package: &'static str, + package: String, landing: [f32; 3], }, /// 环带兜底命中:`reason` 是动作点支落空的理由(无行 / 挂点条目 @@ -744,7 +736,7 @@ fn fixture_target( // —— 动作点支:座位查表 → 挂点条目 → 面 0.3 门;命中即返回,任一 // 环落空回落环带(理由词具名,随环带行报出)。 let fallback_reason: &'static str = match seat_of(fixture.fixture_id) { - Some(seat) => match attach.entry(fixture.package, seat) { + Some(seat) => match attach.entry(&fixture.uid, seat) { Some(world) => { // 面采样门:查询点 = 挂点 x/z + 参考高度,容差 0.3;命中 // 取采样 y(挂点 local 的 y 恒零,落点高度走面——同全部 @@ -755,7 +747,7 @@ fn fixture_target( return FixtureOutcome::ActionPoint { fixture_id: fixture.fixture_id, seat, - package: fixture.package, + package: fixture.package.clone(), landing: [world.position[0], landed[1], world.position[2]], }; } @@ -1192,7 +1184,7 @@ pub(crate) fn decide( unit.0, fixture_id, seat, - package, + &package, landing, ); detail = format!( diff --git a/crates/moly-game/src/player_data.rs b/crates/moly-game/src/player_data.rs new file mode 100644 index 0000000..a79b37f --- /dev/null +++ b/crates/moly-game/src/player_data.rs @@ -0,0 +1,667 @@ +//! Player-data import, preview, durable replacement and recovery. + +use bevy::{asset::LoadState, prelude::*}; +use moly_assets::{ + json::JsonAsset, + player_data::{ImportedPlayerData, PlayerDataCatalog, MAX_IMPORT_BYTES}, +}; +use serde_json::{json, Value}; +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; + +use crate::{ + fixture::layouts::{self, SiteFixtureLayouts}, + settings_store, + site::{SiteSelection, Sites}, +}; + +pub const DEFAULT_PLAYER_API: &str = + "https://haruki-api.menardi.top/api/{region}/mysekai/{target_user_id}"; +const PROFILE: &str = "ImportedPlayerData"; +const BACKUP: &str = "PlayerDataImportBackup"; + +#[derive(Clone, Copy, Component, Debug)] +pub(crate) enum ImportAction { + Fetch, + File, + Paste, + ClearUid, + Region, + Apply, + Restore, + Cancel, +} + +struct Payload { + text: String, + region: String, + uid: Option, + apply: bool, +} +enum Incoming { + Data(u64, Result, String>), + Paste(Result), +} +type Inbox = Arc>>; + +struct Preview { + data: ImportedPlayerData, + section: Value, + before_layouts: Option, + before_profile: Option, +} + +#[derive(Resource)] +pub struct PlayerDataImport { + pub(crate) uid: String, + pub(crate) region: String, + pub(crate) status: String, + pub(crate) busy: bool, + pub(crate) action: Option, + api: String, + inbox: Inbox, + generation: u64, + pending: Option, + preview: Option, + requests: Option<(Handle, Handle)>, + catalog: Option, +} + +impl Default for PlayerDataImport { + fn default() -> Self { + #[cfg(not(target_arch = "wasm32"))] + let api = DEFAULT_PLAYER_API; + #[cfg(target_arch = "wasm32")] + let api = "/player-api/{region}/{target_user_id}"; + Self { uid: String::new(), region: "cn".into(), + status: "Enter a player UID or choose a Mysekai JSON file. Import keeps a recoverable layout backup.".into(), + busy: false, action: None, api: api.into(), inbox: Default::default(), + generation: 0, pending: None, preview: None, requests: None, catalog: None } + } +} + +impl PlayerDataImport { + /// Entry-point configuration; the game does not read env or URL parameters. + pub fn configure(&mut self, api: Option, region: Option) -> Result<(), String> { + if let Some(api) = api { + if !(api.starts_with("https://") || api.starts_with("http://") || api.starts_with('/')) + || !api.contains("{region}") + || !api.contains("{target_user_id}") + { + return Err("Player API must be an HTTP(S) URL template containing {region} and {target_user_id}".into()); + } + self.api = api; + } + if let Some(region) = region { + if !matches!(region.as_str(), "cn" | "jp") { + return Err("Player region must be cn or jp".into()); + } + self.region = region; + } + Ok(()) + } + + pub fn request_uid(&mut self, uid: String) { + self.uid = uid; + self.action = Some(ImportAction::Fetch); + } + + pub fn request_json(&mut self, text: String, apply: bool) { + self.cancel(); + self.pending = Some(Payload { + text, + region: self.region.clone(), + uid: None, + apply, + }); + self.busy = true; + self.status = "Checking player data and matching assets...".into(); + } + + pub fn request_apply(&mut self) { + self.action = Some(ImportAction::Apply); + } + + pub fn request_restore(&mut self) { + self.action = Some(ImportAction::Restore); + } + + pub fn status(&self) -> &str { + &self.status + } + + pub fn preview_summary(&self) -> Option<(u32, usize, usize)> { + self.preview.as_ref().map(|preview| { + ( + preview.data.rank, + preview.data.sites.len(), + preview.data.fixture_count(), + ) + }) + } + + pub(crate) fn has_preview(&self) -> bool { + self.preview.is_some() + } + + pub(crate) fn invalidate_preview(&mut self) { + self.cancel(); + } + + fn cancel(&mut self) { + self.generation = self.generation.wrapping_add(1); + self.pending = None; + self.preview = None; + self.busy = false; + } + + fn fetch(&mut self) -> Result<(), String> { + let uid = self.uid.trim(); + if uid.is_empty() + || uid.len() > 20 + || !uid.bytes().all(|b| b.is_ascii_digit()) + || uid.bytes().all(|b| b == b'0') + { + return Err("Player UID must contain 1–20 digits and cannot be zero".into()); + } + let uid = uid.to_owned(); + self.cancel(); + self.busy = true; + self.status = "Fetching player data...".into(); + let url = self + .api + .replace("{region}", &self.region) + .replace("{target_user_id}", &uid); + let request = ehttp::Request::get(url).with_timeout(Some(Duration::from_secs(60))); + let (inbox, ticket, region) = (self.inbox.clone(), self.generation, self.region.clone()); + ehttp::fetch(request, move |response| { + let result = response.and_then(|response| { + match response.status { + 200 => {} + 404 => { + return Err( + "Player does not exist or their Mysekai home is unavailable (HTTP 404)" + .into(), + ) + } + 401 | 403 => { + return Err(format!( + "Player API denied access (HTTP {})", + response.status + )) + } + 429 => { + return Err("Player API is rate limited; try again later (HTTP 429)".into()) + } + code => return Err(format!("Player API returned HTTP {code}")), + } + decode_bytes(response.bytes).map(|text| { + Some(Payload { + text, + region, + uid: Some(uid), + apply: false, + }) + }) + }); + inbox.lock().unwrap().push(Incoming::Data(ticket, result)); + }); + Ok(()) + } + + fn choose_file(&mut self) { + self.cancel(); + self.busy = true; + self.status = "Choose a player-data JSON file...".into(); + choose_file(self.inbox.clone(), self.generation, self.region.clone()); + } + + fn receive(&mut self) { + let messages = std::mem::take(&mut *self.inbox.lock().unwrap()); + for message in messages { + match message { + Incoming::Data(ticket, result) if ticket == self.generation => match result { + Ok(Some(payload)) => { + self.pending = Some(payload); + self.status = "Checking player data and matching assets...".into(); + } + Ok(None) => { + self.busy = false; + self.status = "File selection cancelled.".into(); + } + Err(error) => { + self.busy = false; + self.status = error; + } + }, + Incoming::Paste(Ok(text)) => { + let text = text.trim(); + if text.len() <= 20 && text.bytes().all(|b| b.is_ascii_digit()) { + self.cancel(); + self.uid = text.into(); + } else { + self.status = "The clipboard does not contain a player UID.".into(); + } + } + Incoming::Paste(Err(error)) => self.status = error, + _ => {} + } + } + } + + fn prepare(&mut self, world: &World) -> Result { + let Some(payload) = self.pending.as_ref() else { + return Ok(false); + }; + if self.catalog.is_none() { + let server = world.resource::(); + let (master, models) = self + .requests + .get_or_insert_with(|| { + ( + server.load("moly://fixture-models/player-data.json"), + server.load("moly://fixture-models/index.json"), + ) + }) + .clone(); + for handle in [&master, &models] { + if matches!(server.load_state(handle), LoadState::Failed(_)) { + self.requests = None; + return Err("Player import catalog is unavailable. Update the supplied assets, including fixture-models/player-data.json.".into()); + } + } + let jsons = world.resource::>(); + let (Some(master), Some(models)) = (jsons.get(&master), jsons.get(&models)) else { + return Ok(false); + }; + self.catalog = Some(PlayerDataCatalog::from_json(&master.0, &models.0)?); + } + let Some(sites) = world.get_resource::() else { + return Ok(false); + }; + let mut data = self + .catalog + .as_ref() + .unwrap() + .import(&payload.text, Some(&payload.region))?; + if let Some(uid) = &payload.uid { + if data.player_id.as_ref().is_some_and(|id| id != uid) { + return Err("The API returned a different player ID; no data was imported.".into()); + } + data.player_id = Some(uid.clone()); + } + let section = layouts::import_section(&data, sites)?; + let document = settings_store::read_document()?; + layouts::validate_document(&document, sites)?; + self.status = format!( + "Ready for {} {}: {} sites, {} furniture instances, Mysekai rank {}.{}", + data.region.to_uppercase(), + data.player_id + .as_deref() + .or(data.player_name.as_deref()) + .unwrap_or("selected file"), + data.sites.len(), + data.fixture_count(), + data.rank, + if data.notices.is_empty() { + String::new() + } else { + format!("\n{}", data.notices.join("\n")) + } + ); + self.preview = Some(Preview { + data, + section, + before_layouts: document.get(layouts::SECTION).cloned(), + before_profile: document.get(PROFILE).cloned(), + }); + let apply = payload.apply; + self.pending = None; + self.busy = false; + Ok(apply) + } + + fn apply(&mut self, world: &mut World) -> Result<(), String> { + require_clean_editor(world)?; + let preview = self + .preview + .as_ref() + .ok_or("Fetch or choose player data before importing")?; + let selection = SiteSelection::for_player_data(&preview.data); + let backup_selection = world.resource::().snapshot(); + let document = settings_store::update_document_checked(|document| { + if document.get(layouts::SECTION) != preview.before_layouts.as_ref() + || document.get(PROFILE) != preview.before_profile.as_ref() + { + return Err("Saved player layouts changed after preview. Fetch or choose the data again; nothing was overwritten.".into()); + } + document[BACKUP] = json!({ "version": 1, "layouts": preview.before_layouts, + "profile": preview.before_profile, "selection": backup_selection, + "hasLayouts": preview.before_layouts.is_some(), "hasProfile": preview.before_profile.is_some() }); + document[layouts::SECTION] = preview.section.clone(); + document[PROFILE] = json!({ "version": 1, "region": preview.data.region, + "rank": preview.data.rank, "playerName": preview.data.player_name, + "playerId": preview.data.player_id, "source": preview.data.source }); + Ok(()) + })?; + self.status = format!( + "Imported {} sites and {} furniture instances. The previous layout is backed up.", + preview.data.sites.len(), + preview.data.fixture_count() + ); + info!( + "[player-data] imported {} sites, {} fixtures, rank {}", + preview.data.sites.len(), + preview.data.fixture_count(), + preview.data.rank + ); + replace_live(world, document, selection); + self.preview = None; + Ok(()) + } + + fn restore(&mut self, world: &mut World) -> Result<(), String> { + require_clean_editor(world)?; + let sites = world + .get_resource::() + .ok_or("Scene catalog is still loading")?; + let mut restored_selection = None; + let document = settings_store::update_document_checked(|document| { + let backup = document + .get(BACKUP) + .cloned() + .ok_or("No player import backup is available")?; + if backup["version"].as_u64() != Some(1) { + return Err("Unsupported player import backup version".into()); + } + let mut candidate = document.clone(); + for (key, value, present) in [ + (layouts::SECTION, &backup["layouts"], &backup["hasLayouts"]), + (PROFILE, &backup["profile"], &backup["hasProfile"]), + ] { + if !present.as_bool().unwrap_or(!value.is_null()) { + candidate.as_object_mut().unwrap().remove(key); + } else { + candidate[key] = value.clone(); + } + } + layouts::validate_document(&candidate, sites)?; + let mut selection = SiteSelection::from_snapshot(&backup["selection"])?; + selection + .resolve_level(sites, &SiteFixtureLayouts::from_document(candidate.clone()))?; + candidate.as_object_mut().unwrap().remove(BACKUP); + *document = candidate; + restored_selection = Some(selection); + Ok(()) + })?; + self.cancel(); + self.status = "Restored the layout from before the import.".into(); + replace_live(world, document, restored_selection.unwrap()); + Ok(()) + } +} + +fn require_clean_editor(world: &World) -> Result<(), String> { + if world + .get_resource::() + .is_some_and(crate::fixture_edit::has_unsaved_layout) + { + Err( + "Save or discard your furniture edits before importing or restoring player data." + .into(), + ) + } else { + Ok(()) + } +} + +fn replace_live(world: &mut World, document: Value, selection: SiteSelection) { + let rank = profile_rank(&document); + if let Some(mut panel) = world.get_resource_mut::() { + panel.close_after_import(); + } + let roots = world + .query_filtered::>() + .iter(world) + .collect(); + let mut commands = world.commands(); + crate::site::queue_transition(&mut commands, roots, selection); + commands.queue(move |world: &mut World| { + if let Some(mut menu) = world.get_resource_mut::() { + menu.set_player_rank(rank); + } + if let Some(mut info) = world.get_resource_mut::() { + info.set_player_rank(rank); + } + world.insert_resource(SiteFixtureLayouts::from_document(document)); + }); +} + +fn profile_rank(document: &Value) -> Option { + if document[PROFILE]["version"].as_u64() != Some(1) { + return None; + } + document[PROFILE]["rank"] + .as_u64() + .and_then(|v| u32::try_from(v).ok()) + .filter(|v| *v > 0) +} + +pub(crate) fn saved_rank() -> Option { + settings_store::read_document() + .ok() + .as_ref() + .and_then(profile_rank) +} + +/// Prefer the imported home on restart when the entry point has no explicit site. +pub fn preferred_site() -> Option { + let document = settings_store::read_document().ok()?; + if document[PROFILE]["version"].as_u64() != Some(1) { + return None; + } + let sites = document[layouts::SECTION]["sites"].as_object()?; + [ + (1, "home_site"), + (2, "first_floor"), + (3, "second_floor"), + (4, "third_floor"), + ] + .into_iter() + .find_map(|(id, kind)| { + sites + .get(&id.to_string()) + .filter(|site| { + site["mysekaiSiteId"].as_u64() == Some(id) + && site["siteType"].as_str() == Some(kind) + }) + .map(|_| kind.to_owned()) + }) +} + +pub(crate) fn update(world: &mut World) { + let Some(mut state) = world.remove_resource::() else { + return; + }; + state.receive(); + let result = match state.action.take() { + Some(ImportAction::Fetch) => { + if let Some(mut panel) = world.get_resource_mut::() + { + panel.open = true; + panel.player_data = true; + } + state.fetch() + } + Some(ImportAction::File) => { + state.choose_file(); + Ok(()) + } + Some(ImportAction::Apply) => state.apply(world), + Some(ImportAction::Restore) => state.restore(world), + Some(ImportAction::Paste) => { + paste(state.inbox.clone()); + Ok(()) + } + Some(ImportAction::ClearUid) => { + state.cancel(); + state.uid.clear(); + Ok(()) + } + Some(ImportAction::Region) => { + state.cancel(); + state.region = if state.region == "cn" { "jp" } else { "cn" }.into(); + Ok(()) + } + Some(ImportAction::Cancel) => { + state.cancel(); + state.status = "Import cancelled.".into(); + Ok(()) + } + None => Ok(()), + } + .and_then(|_| { + state + .prepare(world) + .and_then(|apply| if apply { state.apply(world) } else { Ok(()) }) + }); + if let Err(error) = result { + state.busy = false; + state.pending = None; + state.status = error; + if let Some(mut panel) = world.get_resource_mut::() { + panel.open = true; + panel.player_data = true; + } + } + world.insert_resource(state); +} + +fn decode_bytes(bytes: Vec) -> Result { + if bytes.len() > MAX_IMPORT_BYTES { + return Err("Player data exceeds 32 MiB".into()); + } + String::from_utf8(bytes).map_err(|_| "Player data must be a UTF-8 JSON file".into()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn read_json_file(path: &std::path::Path) -> Result { + let file = std::fs::File::open(path).map_err(|e| format!("Open player data: {e}"))?; + if !file + .metadata() + .map_err(|e| format!("Read player data: {e}"))? + .is_file() + { + return Err("Choose a player-data file".into()); + } + use std::io::Read; + let mut bytes = Vec::new(); + file.take(MAX_IMPORT_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|e| format!("Read player data: {e}"))?; + decode_bytes(bytes) +} + +#[cfg(not(target_arch = "wasm32"))] +fn choose_file(inbox: Inbox, ticket: u64, region: String) { + std::thread::spawn(move || { + let result = rfd::FileDialog::new() + .add_filter("Mysekai player data", &["json"]) + .pick_file() + .map(|path| { + read_json_file(&path).map(|text| Payload { + text, + region, + uid: None, + apply: false, + }) + }) + .transpose(); + inbox.lock().unwrap().push(Incoming::Data(ticket, result)); + }); +} + +#[cfg(target_arch = "wasm32")] +fn choose_file(inbox: Inbox, ticket: u64, region: String) { + wasm_bindgen_futures::spawn_local(async move { + let result = match rfd::AsyncFileDialog::new() + .add_filter("Mysekai player data", &["json"]) + .pick_file() + .await + { + Some(file) => decode_bytes(file.read().await).map(|text| { + Some(Payload { + text, + region, + uid: None, + apply: false, + }) + }), + None => Ok(None), + }; + inbox.lock().unwrap().push(Incoming::Data(ticket, result)); + }); +} + +#[cfg(not(target_arch = "wasm32"))] +fn paste(inbox: Inbox) { + let result = arboard::Clipboard::new() + .and_then(|mut clipboard| clipboard.get_text()) + .map_err(|e| format!("Read clipboard: {e}")); + inbox.lock().unwrap().push(Incoming::Paste(result)); +} + +#[cfg(target_arch = "wasm32")] +fn paste(inbox: Inbox) { + wasm_bindgen_futures::spawn_local(async move { + let result = match web_sys::window() { + Some(window) => { + wasm_bindgen_futures::JsFuture::from(window.navigator().clipboard().read_text()) + .await + .map_err(|_| "Clipboard access failed; type the UID instead".to_owned()) + .and_then(|value| { + value + .as_string() + .ok_or_else(|| "Clipboard is not text".into()) + }) + } + None => Err("Browser window is unavailable".into()), + }; + inbox.lock().unwrap().push(Incoming::Paste(result)); + }); +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn drop_files( + mut files: MessageReader, + mut state: ResMut, + mut panel: ResMut, +) { + for event in files.read() { + if let bevy::window::FileDragAndDrop::DroppedFile { path_buf, .. } = event { + panel.open = true; + panel.player_data = true; + match read_json_file(path_buf) { + Ok(text) => state.request_json(text, false), + Err(error) => { + state.cancel(); + state.status = error; + } + } + } + } +} + +pub(crate) fn install(app: &mut App) { + app.init_resource::(); + app.add_systems(Update, update.before(crate::fixture::FixtureLayoutSet)); + app.add_systems( + PreUpdate, + crate::player_data_ui::input.after(crate::game_settings::input), + ); + app.add_systems(Update, crate::player_data_ui::refresh.after(update)); + #[cfg(not(target_arch = "wasm32"))] + app.add_systems(Update, drop_files.before(update)); +} diff --git a/crates/moly-game/src/player_data_ui.rs b/crates/moly-game/src/player_data_ui.rs new file mode 100644 index 0000000..75d3ae1 --- /dev/null +++ b/crates/moly-game/src/player_data_ui.rs @@ -0,0 +1,308 @@ +//! Player import controls inside the shared settings modal. + +use crate::{ + game_settings::{SettingsBody, SettingsPanel}, + player_data::{ImportAction, PlayerDataImport}, +}; +use bevy::{ + input::{keyboard::KeyboardInput, ButtonState}, + prelude::*, +}; + +#[derive(Component)] +pub(crate) struct ImportBody; +#[derive(Component)] +pub(crate) enum Label { + Uid, + Region, + Status, +} + +pub(crate) fn spawn(commands: &mut Commands, parent: Entity, font: &Handle) { + let body = commands + .spawn(( + Node { + display: Display::None, + width: percent(100), + flex_direction: FlexDirection::Column, + row_gap: px(14), + ..default() + }, + ImportBody, + )) + .id(); + commands.entity(parent).add_child(body); + text(commands, body, font, "PLAYER DATA", 21., None); + text( + commands, + body, + font, + "Import the home and room layouts available in your player data.", + 15., + None, + ); + let account = row(commands, body); + button( + commands, + account, + font, + "CN", + ImportAction::Region, + Some(Label::Region), + ); + text(commands, account, font, "Player UID", 17., None); + let input = commands + .spawn(( + Node { + flex_grow: 1., + min_width: px(165), + padding: UiRect::all(px(10)), + border_radius: BorderRadius::all(px(6)), + ..default() + }, + BackgroundColor(Color::srgb(0.035, 0.06, 0.10)), + )) + .id(); + commands.entity(account).add_child(input); + text( + commands, + input, + font, + "Type UID here", + 18., + Some(Label::Uid), + ); + let actions = row(commands, body); + button( + commands, + actions, + font, + "Paste UID", + ImportAction::Paste, + None, + ); + button( + commands, + actions, + font, + "Clear UID", + ImportAction::ClearUid, + None, + ); + button( + commands, + actions, + font, + "Fetch player", + ImportAction::Fetch, + None, + ); + button( + commands, + actions, + font, + "Choose JSON", + ImportAction::File, + None, + ); + text( + commands, + body, + font, + "Choose the matching region. UID requests read player data only.", + 14., + None, + ); + text(commands, body, font, "", 15., Some(Label::Status)); + let commit = row(commands, body); + button( + commands, + commit, + font, + "Import preview", + ImportAction::Apply, + None, + ); + button( + commands, + commit, + font, + "Restore backup", + ImportAction::Restore, + None, + ); + button(commands, commit, font, "Cancel", ImportAction::Cancel, None); + text(commands, body, font, "Import replaces housing layouts and keeps one backup. Graphics and audio settings are retained.", 14., None); +} + +fn row(commands: &mut Commands, parent: Entity) -> Entity { + let entity = commands + .spawn(Node { + width: percent(100), + align_items: AlignItems::Center, + flex_wrap: FlexWrap::Wrap, + column_gap: px(10), + row_gap: px(8), + ..default() + }) + .id(); + commands.entity(parent).add_child(entity); + entity +} + +fn text( + commands: &mut Commands, + parent: Entity, + font: &Handle, + value: &str, + size: f32, + label: Option

正在加载游戏…

+ +
diff --git a/web/main.mjs b/web/main.mjs index 48621fd..6aea93d 100644 --- a/web/main.mjs +++ b/web/main.mjs @@ -1,40 +1,94 @@ -// 引导:先实例化 wasm,再由真实用户手势同步调用唯一 start()。 -// 资产基址 ?assets= 仍由 Rust 侧自己读,这里不做第二套解析。 -import init, { start } from "./pkg/moly-app.js"; +import { selectRenderer, holdSettingsWriter } from "./boot.mjs"; const panel = document.querySelector("#boot-panel"); const button = document.querySelector("#boot-start"); +const retry = document.querySelector("#boot-retry"); +const fallback = document.querySelector("#boot-webgl"); const status = document.querySelector("#boot-status"); -let started = false; +let phase = "loading"; +let backend; +let wasm; +let writer; +let startupTimer; + +function releaseWriter() { + if (!writer) return; + const lease = writer; + try { + wasm.set_storage_writable(false); + lease.release(); + writer = undefined; + } + catch (error) { console.error(error); } +} function fail(error) { console.error(error); + // A Rust panic reports its cause before the resulting generic wasm trap. + if (phase === "failed") return; + clearTimeout(startupTimer); + phase = "failed"; + releaseWriter(); panel.hidden = false; button.disabled = true; - status.textContent = `failed to start: ${error}`; + retry.hidden = false; + fallback.hidden = backend === "webgl2"; + const reason = String(error); + status.textContent = /Unable to find a GPU|did not provide an adapter|adapter request/i.test(reason) + ? "无法取得可用的图形设备。请重新加载,或切换到 WebGL2 后重试。" + : `游戏启动或运行失败:${reason}`; } +retry.addEventListener("click", () => location.reload()); +fallback.addEventListener("click", () => { + const url = new URL(location.href); + url.searchParams.set("renderer", "webgl2"); + location.replace(url.href); +}); +window.addEventListener("moly-ready", () => { + if (phase !== "starting") return; + clearTimeout(startupTimer); + phase = "running"; + panel.hidden = true; +}); +window.addEventListener("moly-error", event => fail(event.detail ?? "Renderer initialization failed")); +window.addEventListener("error", event => fail(event.error ?? event.message)); +window.addEventListener("unhandledrejection", event => fail(event.reason)); +window.addEventListener("pagehide", releaseWriter); +window.addEventListener("pageshow", event => { + // A restored event loop must not resume with a released storage lease. + if (event.persisted) location.reload(); +}); + button.addEventListener("click", (event) => { - if (!event.isTrusted || button.disabled || started) return; - // 启动手势不再传播给刚装好的游戏输入;双击也只能创建一次 event loop。 + if (!event.isTrusted || button.disabled || phase !== "ready") return; event.stopPropagation(); - started = true; + phase = "starting"; button.disabled = true; - panel.hidden = true; + status.textContent = `正在启动 ${backend}…`; + startupTimer = setTimeout(() => fail("渲染器启动超时,请重新加载或尝试 WebGL2。"), 45_000); try { - // 不在这里 await init() 或任何网络操作。原 Bevy/cpal AudioContext - // 在 start -> app -> DefaultPlugins 内同步创建和 resume,保留用户激活。 - // 没有第二个播放器,也不修改全局 AudioContext 或用户音量。 - start(); + // Keep this synchronous: DefaultPlugins creates/resumes audio in this gesture. + // A failed renderer is retried by navigation, never by a second App here. + wasm.start(backend, writer.writable); } catch (error) { fail(error); } }); try { - // wasm bin main 不运行 app;init 只实例化模块,start 才创建音频输出。 - await init(); - status.textContent = "点击开始,启用游戏声音"; + phase = "preflight"; + status.textContent = "正在检查图形支持并加载游戏…"; + const requested = new URL(location.href).searchParams.get("renderer") ?? "auto"; + const renderer = await selectRenderer(requested, { navigator, document }); + backend = renderer.backend; + wasm = await import(`./pkg/${backend}/moly-app.js`); + await wasm.default(); + writer = await holdSettingsWriter(navigator.locks); + phase = "ready"; + status.textContent = `点击开始,启用游戏声音(${backend})` + + (renderer.notice ? "\nWebGPU 暂不可用,将使用 WebGL2。" : "") + + (writer.writable ? "" : "\n本页只读:无法取得独占存档权限。关闭其他 moly 页面后重新加载即可重试。"); button.disabled = false; } catch (error) { fail(error); diff --git a/web/player-api-worker.mjs b/web/player-api-worker.mjs new file mode 100644 index 0000000..3d83288 --- /dev/null +++ b/web/player-api-worker.mjs @@ -0,0 +1,11 @@ +// Optional Worker entry point for a deployment with an ASSETS binding. +import { playerDataResponse, DEFAULT_PLAYER_API } from "./player-api.mjs"; + +export default { + fetch(request, env) { + if (new URL(request.url).pathname.startsWith("/player-api/")) { + return playerDataResponse(request, env.MOLY_PLAYER_API ?? DEFAULT_PLAYER_API); + } + return env.ASSETS.fetch(request); + }, +}; diff --git a/web/player-api.mjs b/web/player-api.mjs new file mode 100644 index 0000000..153b2ec --- /dev/null +++ b/web/player-api.mjs @@ -0,0 +1,51 @@ +// Same-origin player-data relay. The upstream does not expose CORS headers. +export const DEFAULT_PLAYER_API = "https://haruki-api.menardi.top/api/{region}/mysekai/{target_user_id}"; +const MAX_BYTES = 32 * 1024 * 1024; + +export async function playerDataResponse(request, template = DEFAULT_PLAYER_API) { + const headers = { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }; + const error = (status, message) => new Response(JSON.stringify({ error: message }), { status, headers }); + if (request.method !== "GET") return error(405, "GET only"); + const match = new URL(request.url).pathname.match(/^\/player-api\/(cn|jp)\/([0-9]{1,20})$/); + if (!match || /^0+$/.test(match[2])) return error(400, "Invalid region or player UID"); + if (!/^https?:\/\//.test(template) || !template.includes("{region}") || !template.includes("{target_user_id}")) { + return error(503, "Player API is not configured correctly"); + } + const endpoint = template.replaceAll("{region}", match[1]).replaceAll("{target_user_id}", match[2]); + try { + const upstream = await fetch(endpoint, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(60_000), + redirect: "error", + }); + if (!upstream.ok) { + await upstream.body?.cancel(); + return error(upstream.status >= 400 ? upstream.status : 502, `Player API returned HTTP ${upstream.status}`); + } + if (Number(upstream.headers.get("content-length")) > MAX_BYTES) { + await upstream.body?.cancel(); + return error(502, "Player response exceeds 32 MiB"); + } + const reader = upstream.body?.getReader(); + if (!reader) return error(502, "Player API returned an empty response"); + const chunks = []; + let length = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + length += value.byteLength; + if (length > MAX_BYTES) { + await reader.cancel(); + return error(502, "Player response exceeds 32 MiB"); + } + chunks.push(value); + } + const body = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; } + // Preserve the original bytes; parsing JSON here can round 64-bit user IDs. + return new Response(body, { status: 200, headers }); + } catch (cause) { + return error(cause?.name === "TimeoutError" ? 504 : 502, "Could not fetch player data"); + } +} diff --git a/web/serve.mjs b/web/serve.mjs index 17d5269..a63b3be 100644 --- a/web/serve.mjs +++ b/web/serve.mjs @@ -5,9 +5,10 @@ // 用法:node web/serve.mjs [port] (默认 8000,只绑 127.0.0.1) import { createServer } from "node:http"; -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { closeSync, constants, fstatSync, openSync, realpathSync, readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { playerDataResponse, DEFAULT_PLAYER_API } from "./player-api.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); // .../web const workspaceRoot = path.resolve(here, ".."); @@ -33,9 +34,9 @@ if (packRoot && !statSync(packRoot, { throwIfNoEntry: false })?.isDirectory()) { // (Windows 上「盘符:/…」形的 MOLY_ASSET_ROOT),startsWith 对每个 // 文件都对不上,全部 404。 const mounts = [ - ...(packRoot ? [["/packs/", path.resolve(packRoot)]] : []), - ["/assets/", path.resolve(assetRoot)], - ["/", path.join(here)], + ...(packRoot ? [["/packs/", realpathSync(packRoot)]] : []), + ["/assets/", realpathSync(assetRoot)], + ["/", realpathSync(here)], ]; const contentTypes = new Map([ @@ -53,51 +54,95 @@ const contentTypes = new Map([ [".bin", "application/octet-stream"], ]); +function httpError(status, message) { + return Object.assign(new Error(message), { status }); +} + +function within(root, candidate) { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith(".." + path.sep) && relative !== ".." && !path.isAbsolute(relative)); +} + +// Mounts contain trusted, read-only development assets. Links are allowed only +// when their canonical target remains in the same mount. function resolveFile(urlPath) { for (const [prefix, root] of mounts) { if (!urlPath.startsWith(prefix)) continue; const relative = urlPath.slice(prefix.length); - const absolute = path.resolve(root, "." + path.posix.normalize("/" + relative)); - // 拒绝走出挂载根:resolve 已把 ../ 折掉,前缀对不上就是走出来了 - // (根自身无尾分隔符,须单独放行,否则裸根 404)。 - if (absolute !== root && !absolute.startsWith(root + path.sep)) return null; - let candidate = absolute; - const stats = statSync(candidate, { throwIfNoEntry: false }); - if (stats?.isDirectory()) { - candidate = path.join(candidate, "index.html"); + let candidate = path.resolve(root, "." + path.posix.normalize("/" + relative)); + if (!within(root, candidate)) throw httpError(403, "Path leaves its mount"); + candidate = realpathSync(candidate); + if (!within(root, candidate)) throw httpError(403, "Link leaves its mount"); + if (statSync(candidate).isDirectory()) { + candidate = realpathSync(path.join(candidate, "index.html")); + if (!within(root, candidate)) throw httpError(403, "Link leaves its mount"); } - if (statSync(candidate, { throwIfNoEntry: false })?.isFile()) return candidate; - return null; + return candidate; } - return null; + throw httpError(404, "Not found"); } -const server = createServer((request, response) => { - const urlPath = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname); - const file = resolveFile(urlPath); +const server = createServer(async (request, response) => { const send = (status, headers, body) => { - const immutable = status === 200 && urlPath.startsWith("/packs/blobs/") && /\/[a-f0-9]{64}\.(?:bin|gzz)$/.test(urlPath); - response.writeHead(status, { "Cache-Control": immutable ? "public, max-age=31536000, immutable" : "no-store", ...headers }); - response.end(body); + response.writeHead(status, { "Cache-Control": "no-store", ...headers }); + response.end(request.method === "HEAD" ? undefined : body); }; - if (!file) { - console.log("404", urlPath); - send(404, { "Content-Type": "text/plain; charset=utf-8" }, `not found: ${urlPath}\n`); - return; - } - if (request.method !== "GET" && request.method !== "HEAD") { - send(405, { "Content-Type": "text/plain; charset=utf-8" }, "GET/HEAD only\n"); - return; + try { + const raw = request.url ?? "/"; + if (!raw.startsWith("/") || raw.startsWith("//") || /[\\\\\u0000-\u001f\u007f]/.test(raw)) { + throw httpError(400, "Invalid request path"); + } + let urlPath; + try { + urlPath = decodeURIComponent(new URL(raw, "http://localhost").pathname); + } catch { + throw httpError(400, "Invalid URL encoding"); + } + if (/[\\\\\u0000-\u001f\u007f]/.test(urlPath)) throw httpError(400, "Invalid request path"); + if (urlPath.startsWith("/player-api/")) { + if (request.method !== "GET") { + send(405, { "Content-Type": "application/json", Allow: "GET" }, '{"error":"GET only"}'); + return; + } + const proxied = await playerDataResponse( + new Request(new URL(raw, "http://localhost"), { method: request.method }), + process.env.MOLY_PLAYER_API ?? DEFAULT_PLAYER_API, + ); + send(proxied.status, Object.fromEntries(proxied.headers), Buffer.from(await proxied.arrayBuffer())); + return; + } + if (request.method !== "GET" && request.method !== "HEAD") { + send(405, { "Content-Type": "text/plain; charset=utf-8", Allow: "GET, HEAD" }, "GET/HEAD only\n"); + return; + } + const file = resolveFile(urlPath); + const descriptor = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const stat = fstatSync(descriptor); + if (!stat.isFile()) throw httpError(404, "Not a file"); + const type = contentTypes.get(path.extname(file).toLowerCase()) ?? "application/octet-stream"; + const body = request.method === "HEAD" ? undefined : readFileSync(descriptor); + const immutable = urlPath.startsWith("/packs/blobs/") && /\/[a-f0-9]{64}\.(?:bin|gzz)$/.test(urlPath); + send(200, { "Content-Type": type, "Content-Length": body?.length ?? stat.size, + "Cache-Control": immutable ? "public, max-age=31536000, immutable" : "no-store" }, body); + } finally { + closeSync(descriptor); + } + } catch (error) { + const status = error.status ?? ({ ENOENT: 404, ENOTDIR: 404, EACCES: 403, EPERM: 403, ELOOP: 403 }[error.code] ?? 500); + if (status === 500) console.error("Request failed:", error); + if (!response.headersSent) { + send(status, { "Content-Type": "text/plain; charset=utf-8" }, `${status === 500 ? "Request failed" : error.message}\n`); + } else { + response.destroy(); + } } - const type = contentTypes.get(path.extname(file).toLowerCase()) ?? "application/octet-stream"; - const body = request.method === "HEAD" ? undefined : readFileSync(file); - send(200, { "Content-Type": type, "Content-Length": statSync(file).size }, body); }); // 产物对源码的 mtime 提示(建议性,不拦服务):陈产物页面看起来和新产物 // 一模一样。锚文件必须被走到,否则这个「没找到更新的」是走错目录的假安全。 function reportFreshness() { - const wasmArtifact = path.join(here, "pkg", "moly-app_bg.wasm"); + const wasmArtifacts = ["webgpu", "webgl2"].map(backend => path.join(here, "pkg", backend, "moly-app_bg.wasm")); const anchor = path.join("crates", "moly-app", "src", "main.rs"); let newest = { mtimeMs: -1, file: null }; let sawAnchor = false; @@ -105,7 +150,7 @@ function reportFreshness() { for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith(".rs")) { + else if (/\.(rs|wgsl|toml)$/.test(entry.name)) { if (path.relative(workspaceRoot, full) === anchor) sawAnchor = true; const mtimeMs = statSync(full).mtimeMs; if (mtimeMs > newest.mtimeMs) newest = { mtimeMs, file: full }; @@ -113,23 +158,26 @@ function reportFreshness() { } }; walk(path.join(workspaceRoot, "crates")); - const artifactMtime = statSync(wasmArtifact, { throwIfNoEntry: false })?.mtimeMs ?? -1; + newest.mtimeMs = Math.max(newest.mtimeMs, statSync(path.join(workspaceRoot, "Cargo.toml")).mtimeMs, + statSync(path.join(workspaceRoot, "Cargo.lock")).mtimeMs); + const artifactMtime = Math.min(...wasmArtifacts.map(file => statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? -1)); if (artifactMtime < 0) { console.log("wasm artifact MISSING -> run: node web/build-wasm.mjs"); } else if (!sawAnchor) { console.log("wasm artifact UNCHECKED (source walk missed its anchor file)"); } else if (newest.mtimeMs > artifactMtime) { - console.log(`wasm artifact STALE: ${newest.file} is newer -> run: node web/build-wasm.mjs`); + console.log("wasm artifacts STALE: source or manifests are newer -> run: node web/build-wasm.mjs"); } else { - console.log("wasm artifact ok (newest .rs under crates/ is older than pkg/moly-app_bg.wasm)"); + console.log("wasm artifacts ok (both renderer modules are newer than source and manifests)"); } } reportFreshness(); server.listen(port, "127.0.0.1", () => { - console.log(`serving on http://127.0.0.1:${port}/`); + const listeningPort = server.address().port; + console.log(`serving on http://127.0.0.1:${listeningPort}/`); console.log(` page : / (index.html)`); console.log(` assets : /assets/ -> ${assetRoot}`); if (packRoot) console.log(` packs : /packs/ -> ${packRoot}`); - console.log(`open: http://127.0.0.1:${port}/index.html?assets=/assets/`); + console.log(`open: http://127.0.0.1:${listeningPort}/index.html?assets=/assets/`); }); From b2e6b1b8037acecc4500c675650063b8b8af7e1b Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Mon, 14 Sep 2026 22:15:57 +0800 Subject: [PATCH 2/3] ci: Check whitespace against complete change history --- .github/workflows/ci.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a07b7a..eb43134 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: '22' @@ -32,4 +34,16 @@ jobs: - name: Check public source boundary run: node tools/boundary/check.mjs - name: Check commit whitespace - run: git show --format= --check + shell: bash + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PUSH_BASE_SHA: ${{ github.event.before }} + run: | + if [[ -n "$PR_BASE_SHA" ]]; then + git diff --check "$PR_BASE_SHA...$PR_HEAD_SHA" + elif [[ -n "$PUSH_BASE_SHA" && ! "$PUSH_BASE_SHA" =~ ^0+$ ]]; then + git diff --check "$PUSH_BASE_SHA" "$GITHUB_SHA" + else + git show --format= --check "$GITHUB_SHA" + fi From 88bf7e016829f9bc2d490f0b0c3f155632f92313 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Tue, 15 Sep 2026 00:40:03 +0800 Subject: [PATCH 3/3] moly: Handle edit candidates and bound concurrent reads Return placement errors for invalid editing footprints and preserve drafts and stored layouts. Queue admissible pack reads, release consumed reader budgets, and reserve identity data without a decompression buffer. Stream and cancel player HTTP and file reads, check browser file sizes before opening streams, serialize import transfers, and discard stale completions. Add synthetic headless regressions and include delay fields in the existing talk test fixtures. --- Cargo.lock | 439 +++++++++++++++-- README.md | 11 +- crates/moly-assets/Cargo.toml | 1 + crates/moly-assets/src/packs.rs | 24 +- crates/moly-assets/src/packs/tests.rs | 168 +++++++ crates/moly-assets/src/read_limits.rs | 177 ++++--- crates/moly-assets/src/read_limits/tests.rs | 115 +++++ crates/moly-game/Cargo.toml | 11 +- crates/moly-game/src/fixture.rs | 22 +- crates/moly-game/src/fixture_edit.rs | 3 + .../src/fixture_edit/presentation.rs | 6 +- crates/moly-game/src/fixture_edit/tests.rs | 215 +++++++++ .../moly-game/src/fixture_edit/validation.rs | 73 ++- crates/moly-game/src/lib.rs | 1 + crates/moly-game/src/player_data.rs | 217 ++++----- crates/moly-game/src/player_data/tests.rs | 43 ++ crates/moly-game/src/player_data_io.rs | 455 ++++++++++++++++++ crates/moly-game/src/player_data_io/tests.rs | 247 ++++++++++ crates/moly-law/src/talk/row.rs | 2 + crates/moly-law/src/talk/step.rs | 7 +- 20 files changed, 1932 insertions(+), 305 deletions(-) create mode 100644 crates/moly-assets/src/packs/tests.rs create mode 100644 crates/moly-assets/src/read_limits/tests.rs create mode 100644 crates/moly-game/src/fixture_edit/tests.rs create mode 100644 crates/moly-game/src/player_data/tests.rs create mode 100644 crates/moly-game/src/player_data_io.rs create mode 100644 crates/moly-game/src/player_data_io/tests.rs diff --git a/Cargo.lock b/Cargo.lock index e4cecfc..86bf6eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -230,7 +230,7 @@ dependencies = [ "enumflags2", "futures-channel", "futures-util", - "rand", + "rand 0.9.5", "raw-window-handle", "serde", "serde_repr", @@ -1132,7 +1132,7 @@ dependencies = [ "glam", "itertools 0.14.0", "libm", - "rand", + "rand 0.9.5", "rand_distr", "serde", "thiserror 2.0.20", @@ -1959,6 +1959,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "clang-sys" version = "1.9.1" @@ -2408,20 +2419,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" -[[package]] -name = "ehttp" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04499d3c719edecfad5c9b46031726c8540905d73be6d7e4f9788c4a298da908" -dependencies = [ - "document-features", - "js-sys", - "ureq", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "either" version = "1.18.0" @@ -2774,8 +2771,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -2797,8 +2796,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -2855,7 +2857,7 @@ dependencies = [ "bytemuck", "encase", "libm", - "rand", + "rand 0.9.5", "serde_core", ] @@ -3090,6 +3092,104 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -3254,6 +3354,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "itertools" version = "0.13.0" @@ -3505,6 +3611,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "mach2" version = "0.4.3" @@ -3597,6 +3709,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moly-app" version = "0.0.1" @@ -3624,6 +3747,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "tokio", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -3634,15 +3758,20 @@ name = "moly-game" version = "0.0.1" dependencies = [ "arboard", + "async-lock", "bevy", - "ehttp", + "futures-channel", + "futures-util", "js-sys", "moly-assets", "moly-law", + "reqwest", "rfd", "serde_json", "swash", "tempfile", + "tokio", + "wasm-bindgen", "wasm-bindgen-futures", "web-sys", ] @@ -4447,6 +4576,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -4481,7 +4666,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -4491,7 +4687,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -4503,6 +4699,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_distr" version = "0.5.1" @@ -4510,7 +4712,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand", + "rand 0.9.5", +] + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", ] [[package]] @@ -4621,6 +4832,44 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "rfd" version = "0.15.4" @@ -4742,7 +4991,6 @@ version = "0.23.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" dependencies = [ - "log", "once_cell", "ring", "rustls-pki-types", @@ -4757,6 +5005,7 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] @@ -4786,6 +5035,12 @@ dependencies = [ "twox-hash", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -4893,6 +5148,18 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -5032,6 +5299,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spin" version = "0.10.1" @@ -5122,6 +5399,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -5289,6 +5575,30 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -5340,6 +5650,51 @@ dependencies = [ "winnow 1.0.4", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -5424,6 +5779,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -5516,22 +5877,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64", - "flate2", - "log", - "once_cell", - "rustls", - "rustls-pki-types", - "url", - "webpki-roots 0.26.11", -] - [[package]] name = "url" version = "2.5.8" @@ -5608,6 +5953,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -5806,15 +6160,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.9", -] - [[package]] name = "webpki-roots" version = "1.0.9" diff --git a/README.md b/README.md index 2c7a389..8176fbf 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,10 @@ WebGPU 不可用时会选择 WebGL2;启动失败页可重新加载,或用 `? 分包模式(`?packs=1`)限制目录/清单为 16 MiB、单 blob 为 128 MiB、单个解码资产为 256 MiB(分包载荷),解压大小最多为 blob 大小的 256 倍加 64 KiB。最多同时读取 8 份资源,传输与解码 缓冲合计预算为 512 MiB。浏览器分包响应逐块读取,超限或超时即中止;这些预算不包含 -解析后的目录、资源及场景对象、GPU 资源和浏览器内部缓存。 +加载器已经接收的数据、解析后的目录、资源及场景对象、GPU 资源和浏览器内部缓存。 +暂时的预算不足会按请求顺序等待,取消读取会释放排队和已占用的预算;单个请求超过上限时 +直接拒绝。identity 数据只预留传输缓冲,gzip 同时预留传输与解码缓冲。分包读取器按顺序 +消费,读完或丢弃时释放读取预算;需要 seek 的自定义加载器可使用 Bevy 的 VecReader 回退。 ## 玩家数据导入 @@ -51,6 +54,10 @@ WebGPU 不可用时会选择 WebGL2;启动失败页可重新加载,或用 `? 有未保存的家具编辑、数据无效或资源不匹配时不会覆盖存档。自定义图片、饰件和墙纸地板 记录会保留,但这些外观的渲染尚未接入。 +JSON 输入上限为 32 MiB。UID 请求和文件选择使用受限流式读取;浏览器先检查文件大小, +再打开读取流。同一应用进程同时只读取一份导入正文,取消会中止正在读取的请求或文件, +迟到结果不会进入预览。超限、取消或读取失败均不修改当前布局和存档。 + 资源根需要提供 `fixture-models/player-data.json` 和其中引用的配色贴图。该目录包含 区服及家具、场地、等级解锁主表,不含玩家数据。各区服需使用对应的主表和模型资源。 @@ -71,6 +78,8 @@ UID 与文件启动参数只能选择一种。 GitHub Actions 在推送、拉取请求和手动触发时执行版本一致性、JavaScript 语法、 开源边界及提交空白检查。CI 不编译、不打包,也不需要游戏资源。 版本和边界检查可在本地运行 `python tools/version/check.py`、`node tools/boundary/check.mjs`。 +本地无 GPU 的核心与回归测试可运行 `cargo test --release --workspace --lib -- --test-threads=1`, +其中的网络和存档检查使用回环服务、合成输入及临时文件,不需要游戏资源。 ## 代码结构 diff --git a/crates/moly-assets/Cargo.toml b/crates/moly-assets/Cargo.toml index b1907a8..02125fb 100644 --- a/crates/moly-assets/Cargo.toml +++ b/crates/moly-assets/Cargo.toml @@ -16,6 +16,7 @@ async-lock = "3" flate2 = "1" futures-lite = "2" sha2 = "0.10" +tokio = { version = "1", default-features = false, features = ["sync"] } [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3" diff --git a/crates/moly-assets/src/packs.rs b/crates/moly-assets/src/packs.rs index f2eede3..6f37e4d 100644 --- a/crates/moly-assets/src/packs.rs +++ b/crates/moly-assets/src/packs.rs @@ -69,6 +69,18 @@ struct Entry { xf: Option, } +impl Entry { + fn buffer_peak(&self) -> Result { + let transfer = self.blob_bytes.checked_add(1); + let peak = match self.codec.as_str() { + "identity" if self.blob_bytes == self.bytes => transfer, + "gzip" => transfer.and_then(|value| value.checked_add(self.bytes)?.checked_add(1)), + _ => return Err(invalid("Packed representation has incompatible codec or lengths")), + }; + peak.ok_or_else(|| invalid("Asset buffer size overflow")) + } +} + #[derive(Hash, PartialEq, Eq)] struct Representation { path: String, @@ -168,7 +180,7 @@ impl PackReader { async fn document_bytes(&self, path: &str) -> Result { let _slot = self.budget.slots.acquire().await; - let reservation = self.budget.reserve(MAX_DOCUMENT_BYTES + 1)?; + let reservation = self.budget.reserve(MAX_DOCUMENT_BYTES + 1).await?; Ok(Buffer { bytes: self.bytes(path, MAX_DOCUMENT_BYTES).await?, _reservation: reservation, @@ -316,12 +328,7 @@ impl PackReader { let bytes = flight .get_or_try_init(|| async { let _slot = self.budget.slots.acquire().await; - let peak = entry - .blob_bytes - .checked_add(entry.bytes) - .and_then(|value| value.checked_add(2)) - .ok_or_else(|| invalid("Asset buffer size overflow"))?; - let mut reservation = self.budget.reserve(peak)?; + let mut reservation = self.budget.reserve(entry.buffer_peak()?).await?; let bytes = self.bytes(path, entry.blob_bytes).await?; if bytes.len() != entry.blob_bytes || format!("{:x}", Sha256::digest(&bytes)) != entry.blob_sha256 @@ -420,3 +427,6 @@ impl AssetReader for PackReader { .any(|p| p.starts_with(&prefix))) } } + +#[cfg(test)] +mod tests; diff --git a/crates/moly-assets/src/packs/tests.rs b/crates/moly-assets/src/packs/tests.rs new file mode 100644 index 0000000..d078517 --- /dev/null +++ b/crates/moly-assets/src/packs/tests.rs @@ -0,0 +1,168 @@ +use super::*; +use futures_lite::future::{block_on, poll_once}; +use tokio::sync::Semaphore; + +#[derive(Clone)] +struct DelayedReader { + files: Arc>>, + gates: Arc>, + opened: Arc>>, +} + +impl AssetReader for DelayedReader { + async fn read<'a>(&'a self, path: &'a Path) -> Result { + let key = path.to_string_lossy().to_string(); + self.opened.lock().unwrap().push(key.clone()); + self.gates[&key].acquire().await.unwrap().forget(); + Ok(VecReader::new(self.files[&key].clone())) + } + async fn read_meta<'a>(&'a self, path: &'a Path) -> Result { + Err::(AssetReaderError::NotFound(path.to_owned())) + } + async fn read_directory<'a>( + &'a self, + _: &'a Path, + ) -> Result, AssetReaderError> { + Ok(Box::new(futures_lite::stream::empty())) + } + async fn is_directory<'a>(&'a self, _: &'a Path) -> Result { + Ok(false) + } +} + +fn entry(path: &str, bytes: &[u8]) -> Entry { + let hash = format!("{:x}", Sha256::digest(bytes)); + Entry { + path: path.into(), + blob: path.into(), + blob_bytes: bytes.len(), + bytes: bytes.len(), + blob_sha256: hash.clone(), + content_sha256: hash, + codec: "identity".into(), + http_encoding: "identity".into(), + xf: None, + } +} + +fn delayed(count: usize, budget: usize) -> (PackReader, DelayedReader, Vec) { + let files: HashMap<_, _> = (0..count) + .map(|i| (i.to_string(), vec![i as u8; 16])) + .collect(); + let entries = (0..count) + .map(|i| entry(&i.to_string(), &files[&i.to_string()])) + .collect(); + let source = DelayedReader { + gates: Arc::new( + files + .keys() + .map(|key| (key.clone(), Semaphore::new(0))) + .collect(), + ), + files: Arc::new(files), + opened: Default::default(), + }; + let mut reader = PackReader::new(Box::new(source.clone())); + reader.budget = Arc::new(Budget::new(budget, 8)); + (reader, source, entries) +} + +async fn consume(reader: &PackReader, entry: &Entry) -> Result, AssetReaderError> { + let data = reader.payload(&entry.path, entry).await?; + let mut stream = SharedReader::new(data); + let mut output = Vec::new(); + stream.read_to_end(&mut output).await?; + Ok(output) +} + +#[test] +fn legal_delayed_reads_queue_in_both_completion_orders() { + for reverse in [false, true] { + block_on(async { + let (reader, source, entries) = delayed(6, 34); + let mut tasks: Vec<_> = entries + .iter() + .map(|entry| Some(Box::pin(consume(&reader, entry)))) + .collect(); + for task in &mut tasks { + assert!(poll_once(task.as_mut().unwrap()).await.is_none()); + } + assert_eq!(source.opened.lock().unwrap().as_slice(), ["0", "1"]); + let mut finished: Vec = Vec::new(); + while finished.len() < tasks.len() { + let opened = source.opened.lock().unwrap().clone(); + let mut active: Vec<_> = opened + .iter() + .filter(|key| !finished.contains(*key)) + .cloned() + .collect(); + if reverse { + active.reverse(); + } + let key = active + .first() + .expect("queued reads continue after a buffer is released") + .clone(); + source.gates[&key].add_permits(1); + let mut progressed = false; + for (index, task) in tasks.iter_mut().enumerate() { + let Some(pending) = task else { + continue; + }; + if let Some(result) = poll_once(pending).await { + assert_eq!(result.unwrap(), vec![index as u8; 16]); + finished.push(index.to_string()); + *task = None; + progressed = true; + } + } + assert!(progressed); + } + assert_eq!(source.opened.lock().unwrap().len(), 6); + }); + } +} + +#[test] +fn cancelling_a_transfer_releases_bytes_and_slots() { + block_on(async { + let (reader, source, entries) = delayed(2, 17); + let mut first = Box::pin(consume(&reader, &entries[0])); + let mut second = Box::pin(consume(&reader, &entries[1])); + assert!(poll_once(&mut first).await.is_none()); + assert!(poll_once(&mut second).await.is_none()); + assert_eq!(source.opened.lock().unwrap().as_slice(), ["0"]); + drop(first); + assert!(poll_once(&mut second).await.is_none()); + assert_eq!(source.opened.lock().unwrap().as_slice(), ["0", "1"]); + source.gates["1"].add_permits(1); + assert_eq!(second.await.unwrap(), vec![1; 16]); + }); +} + +#[test] +fn oversized_reservation_is_rejected_before_io() { + block_on(async { + let (reader, source, entries) = delayed(1, 16); + assert!(consume(&reader, &entries[0]).await.is_err()); + assert!(source.opened.lock().unwrap().is_empty()); + }); +} + +#[test] +fn identity_peak_does_not_reserve_a_decompression_buffer() { + block_on(async { + let mut item = entry("large", &[]); + item.blob_bytes = 48 * 1024 * 1024; + item.bytes = item.blob_bytes; + assert_eq!(item.buffer_peak().unwrap(), 48 * 1024 * 1024 + 1); + let budget = Arc::new(Budget::new(read_limits::MAX_IN_FLIGHT_BYTES, 8)); + let mut reservations = Vec::new(); + for _ in 0..6 { + reservations.push(budget.reserve(item.buffer_peak().unwrap()).await.unwrap()); + } + drop(reservations); + item.codec = "gzip".into(); + assert_eq!(item.buffer_peak().unwrap(), 96 * 1024 * 1024 + 2); + }); +} diff --git a/crates/moly-assets/src/read_limits.rs b/crates/moly-assets/src/read_limits.rs index d3f84e1..6c08130 100644 --- a/crates/moly-assets/src/read_limits.rs +++ b/crates/moly-assets/src/read_limits.rs @@ -1,17 +1,17 @@ //! Budgets for application-owned transfer and decoded asset buffers. use async_lock::Semaphore; -use bevy::asset::io::{Reader, ReaderNotSeekableError, SeekableReader}; -use futures_lite::io::{AsyncRead, AsyncSeek}; +use bevy::asset::io::{ + Reader, ReaderNotSeekableError, SeekableReader, StackFuture, STACK_FUTURE_SIZE, +}; +use futures_lite::io::AsyncRead; use std::{ - io::{self, SeekFrom}, + io, pin::Pin, - sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, OnceLock, - }, + sync::{Arc, OnceLock}, task::{Context, Poll}, }; +use tokio::sync::{OwnedSemaphorePermit, Semaphore as ByteSemaphore}; pub(crate) const MAX_DOCUMENT_BYTES: usize = 16 * 1024 * 1024; pub(crate) const MAX_BLOB_BYTES: usize = 128 * 1024 * 1024; @@ -20,59 +20,77 @@ pub(crate) const MAX_IN_FLIGHT_BYTES: usize = 512 * 1024 * 1024; pub(crate) const MAX_EXPANSION_RATIO: usize = 256; pub(crate) struct Budget { - used: AtomicUsize, + bytes: Arc, + limit: usize, + bytes_per_permit: usize, pub(crate) slots: Semaphore, } pub(crate) fn shared_budget() -> Arc { static BUDGET: OnceLock> = OnceLock::new(); BUDGET - .get_or_init(|| { - Arc::new(Budget { - used: AtomicUsize::new(0), - slots: Semaphore::new(8), - }) - }) + .get_or_init(|| Arc::new(Budget::new(MAX_IN_FLIGHT_BYTES, 8))) .clone() } impl Budget { - pub(crate) fn reserve(self: &Arc, bytes: usize) -> io::Result { - self.used - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| { - used.checked_add(bytes) - .filter(|total| *total <= MAX_IN_FLIGHT_BYTES) - }) - .map_err(|_| { - io::Error::other( - "Asset buffer budget exhausted (512 MiB); retry after pending reads finish", - ) - })?; + pub(crate) fn new(limit: usize, slots: usize) -> Self { + Self::with_permit_limit(limit, slots, ByteSemaphore::MAX_PERMITS) + } + + fn with_permit_limit(limit: usize, slots: usize, max_permits: usize) -> Self { + assert!(limit <= u32::MAX as usize); + // A byte-sized permit would exceed the semaphore's count on wasm32. + // Round each request up and the capacity down to keep the byte cap. + let bytes_per_permit = limit.div_ceil(max_permits).max(1); + let permits = limit / bytes_per_permit; + Self { + bytes: Arc::new(ByteSemaphore::new(permits)), + limit: permits * bytes_per_permit, + bytes_per_permit, + slots: Semaphore::new(slots), + } + } + + /// FIFO weighted admission. Dropping a pending acquisition removes it from + /// the queue; a request that can never fit fails without waiting. + pub(crate) async fn reserve(self: &Arc, bytes: usize) -> io::Result { + if bytes > self.limit { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Asset buffer exceeds the total byte budget", + )); + } + let permit = self + .bytes + .clone() + .acquire_many_owned(bytes.div_ceil(self.bytes_per_permit) as u32) + .await + .map_err(|_| io::Error::other("Asset buffer budget is closed"))?; Ok(Reservation { - budget: self.clone(), - bytes, + permit, + bytes_per_permit: self.bytes_per_permit, }) } } pub(crate) struct Reservation { - budget: Arc, - bytes: usize, + permit: OwnedSemaphorePermit, + bytes_per_permit: usize, } impl Reservation { pub(crate) fn shrink_to(&mut self, bytes: usize) { - assert!(bytes <= self.bytes); - self.budget - .used - .fetch_sub(self.bytes - bytes, Ordering::AcqRel); - self.bytes = bytes; - } -} - -impl Drop for Reservation { - fn drop(&mut self) { - self.budget.used.fetch_sub(self.bytes, Ordering::AcqRel); + let released = self + .permit + .num_permits() + .checked_sub(bytes.div_ceil(self.bytes_per_permit)) + .expect("a decoded buffer fits its peak reservation"); + drop( + self.permit + .split(released) + .expect("reservation owns its permits"), + ); } } @@ -81,15 +99,20 @@ pub(crate) struct Buffer { pub(crate) _reservation: Reservation, } -/// Shared buffers stay accounted for until the last reader releases them. +/// Forward-only readers release their transfer buffer when consumed or dropped. +/// A loader can then await dependent assets without retaining this budget. +/// Seeking loaders use Bevy's documented VecReader fallback. pub(crate) struct SharedReader { - data: Arc, + data: Option>, position: usize, } impl SharedReader { pub(crate) fn new(data: Arc) -> Self { - Self { data, position: 0 } + Self { + data: Some(data), + position: 0, + } } } @@ -99,48 +122,62 @@ impl AsyncRead for SharedReader { _: &mut Context<'_>, buffer: &mut [u8], ) -> Poll> { + let Some(data) = &self.data else { + return Poll::Ready(Ok(0)); + }; let length = buffer .len() - .min(self.data.bytes.len().saturating_sub(self.position)); + .min(data.bytes.len().saturating_sub(self.position)); + let end = self.position + length; + let consumed = end == data.bytes.len(); if length != 0 { - buffer[..length] - .copy_from_slice(&self.data.bytes[self.position..self.position + length]); - self.position += length; + buffer[..length].copy_from_slice(&data.bytes[self.position..end]); + self.position = end; + } + if consumed { + self.data = None; } Poll::Ready(Ok(length)) } } -impl AsyncSeek for SharedReader { - fn poll_seek( - mut self: Pin<&mut Self>, - _: &mut Context<'_>, - position: SeekFrom, - ) -> Poll> { - let position = match position { - SeekFrom::Start(value) => value as i128, - SeekFrom::End(value) => self.data.bytes.len() as i128 + value as i128, - SeekFrom::Current(value) => self.position as i128 + value as i128, - }; - match usize::try_from(position) { - Ok(position) => { - self.position = position; - Poll::Ready(Ok(position as u64)) +impl Reader for SharedReader { + fn read_to_end<'a>( + &'a mut self, + output: &'a mut Vec, + ) -> StackFuture<'a, io::Result, STACK_FUTURE_SIZE> { + StackFuture::from(async move { + let Some(data) = self.data.take() else { + return Ok(0); + }; + let count = data.bytes.len().saturating_sub(self.position); + if self.position == 0 && output.is_empty() { + match Arc::try_unwrap(data) { + Ok(Buffer { + bytes, + _reservation, + }) => { + *output = bytes; + drop(_reservation); + } + Err(data) => output.extend_from_slice(&data.bytes), + } + } else { + output.extend_from_slice(&data.bytes[self.position..]); } - Err(_) => Poll::Ready(Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Invalid asset seek", - ))), - } + self.position += count; + Ok(count) + }) } -} -impl Reader for SharedReader { fn seekable(&mut self) -> Result<&mut dyn SeekableReader, ReaderNotSeekableError> { - Ok(self) + Err(ReaderNotSeekableError) } } +#[cfg(test)] +mod tests; + pub(crate) fn buffer(capacity: usize) -> io::Result> { let mut bytes = Vec::new(); bytes diff --git a/crates/moly-assets/src/read_limits/tests.rs b/crates/moly-assets/src/read_limits/tests.rs new file mode 100644 index 0000000..f7bf70e --- /dev/null +++ b/crates/moly-assets/src/read_limits/tests.rs @@ -0,0 +1,115 @@ +use super::*; +use futures_lite::future::{block_on, poll_once}; + +#[test] +fn byte_budget_fits_the_wasm32_semaphore_counter() { + block_on(async { + let budget = Arc::new(Budget::with_permit_limit( + MAX_IN_FLIGHT_BYTES, + 8, + (u32::MAX as usize) >> 3, + )); + let held = budget.reserve(MAX_IN_FLIGHT_BYTES).await.unwrap(); + let mut pending = Box::pin(budget.reserve(1)); + assert!(poll_once(&mut pending).await.is_none()); + drop(held); + assert!(pending.await.is_ok()); + }); +} + +#[test] +fn temporary_pressure_waits_and_impossible_requests_fail() { + block_on(async { + let budget = Arc::new(Budget::new(10, 8)); + let held = budget.reserve(10).await.unwrap(); + let mut pending = Box::pin(budget.reserve(1)); + assert!(poll_once(&mut pending).await.is_none()); + assert!(budget.reserve(11).await.is_err()); + drop(held); + let acquired = pending.await.unwrap(); + assert_eq!(budget.bytes.available_permits(), 9); + drop(acquired); + assert_eq!(budget.bytes.available_permits(), 10); + }); +} + +#[test] +fn weighted_waiters_are_fair_and_cancellation_releases_the_queue() { + block_on(async { + let budget = Arc::new(Budget::new(10, 8)); + let held = budget.reserve(8).await.unwrap(); + let mut large = Box::pin(budget.reserve(3)); + let mut small = Box::pin(budget.reserve(2)); + assert!(poll_once(&mut large).await.is_none()); + assert!(poll_once(&mut small).await.is_none()); + drop(large); + let small = small.await.unwrap(); + drop(small); + let mut cancelled_after_wake = Box::pin(budget.reserve(10)); + assert!(poll_once(&mut cancelled_after_wake).await.is_none()); + drop(held); + drop(cancelled_after_wake); + assert_eq!(budget.bytes.available_permits(), 10); + }); +} + +#[test] +fn shrinking_a_peak_wakes_waiters() { + block_on(async { + let budget = Arc::new(Budget::new(10, 8)); + let mut held = budget.reserve(10).await.unwrap(); + let mut pending = Box::pin(budget.reserve(6)); + assert!(poll_once(&mut pending).await.is_none()); + held.shrink_to(4); + let acquired = pending.await.unwrap(); + assert_eq!(budget.bytes.available_permits(), 0); + drop((acquired, held)); + assert_eq!(budget.bytes.available_permits(), 10); + }); +} + +#[test] +fn consumed_reader_releases_budget_before_loading_a_dependency() { + block_on(async { + let budget = Arc::new(Budget::new(4, 8)); + let bytes = vec![1, 2, 3, 4]; + let pointer = bytes.as_ptr(); + let data = Arc::new(Buffer { + bytes, + _reservation: budget.reserve(4).await.unwrap(), + }); + let mut reader = SharedReader::new(data); + assert!(reader.seekable().is_err()); + let mut output = Vec::new(); + assert_eq!(reader.read_to_end(&mut output).await.unwrap(), 4); + assert_eq!(output, [1, 2, 3, 4]); + assert_eq!(output.as_ptr(), pointer); + assert_eq!(budget.bytes.available_permits(), 4); + let dependency = budget.reserve(4).await.unwrap(); + assert_eq!(reader.read_to_end(&mut output).await.unwrap(), 0); + drop(dependency); + }); +} + +#[test] +fn shared_buffers_are_released_after_the_last_consumer() { + block_on(async { + let budget = Arc::new(Budget::new(4, 8)); + let data = Arc::new(Buffer { + bytes: vec![1, 2, 3, 4], + _reservation: budget.reserve(4).await.unwrap(), + }); + let mut first = SharedReader::new(data.clone()); + let mut second = SharedReader::new(data); + let mut bytes = [0; 4]; + futures_lite::AsyncReadExt::read_exact(&mut first, &mut bytes) + .await + .unwrap(); + assert_eq!(bytes, [1, 2, 3, 4]); + assert_eq!(budget.bytes.available_permits(), 0); + let mut output = vec![9]; + second.read_to_end(&mut output).await.unwrap(); + assert_eq!(output, [9, 1, 2, 3, 4]); + assert_eq!(budget.bytes.available_permits(), 4); + }); +} diff --git a/crates/moly-game/Cargo.toml b/crates/moly-game/Cargo.toml index f226324..15db360 100644 --- a/crates/moly-game/Cargo.toml +++ b/crates/moly-game/Cargo.toml @@ -11,19 +11,24 @@ bevy = { workspace = true } serde_json = "1" # 字形光栅化与度量:bevy_text 的同源光栅器,锁内已有 0.2.10,不新增下载。 swash = "0.2.10" -ehttp = "0.6" -rfd = "0.15" +async-lock = "3" +futures-util = "0.3" +futures-channel = "0.3" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] arboard = { version = "3", default-features = false } tempfile = "3" +rfd = "0.15" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "time", "net", "fs", "io-util"] } [target.'cfg(target_arch = "wasm32")'.dependencies] # 生日门要读墙钟的 epoch 毫秒;wasm 上 std 的 SystemTime 是未实现的桩 # (sys/pal/unsupported:SystemTime::now() 运行时 panic),epoch 走 JS 的 # Date.now。版本与 moly-app 同源(锁内 0.3.104,不新增下载)。 js-sys = "0.3" +wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" # 本地档(音量设置持久化)在浏览器侧落 localStorage:Window + Storage # 绑定。与 moly-app 同一版本源(锁内已有,不新增下载)。 -web-sys = { version = "0.3", features = ["Window", "Storage", "Location", "UrlSearchParams", "Navigator", "Clipboard"] } +web-sys = { version = "0.3", features = ["Window", "Storage", "Location", "UrlSearchParams", "Navigator", "Clipboard", "File", "Blob", "FileList", "Document", "HtmlInputElement", "HtmlElement", "Element", "Node", "Event", "EventTarget", "AbortController", "AbortSignal", "ReadableStream", "ReadableStreamDefaultReader", "Request", "RequestInit", "RequestRedirect", "Response", "Headers"] } diff --git a/crates/moly-game/src/fixture.rs b/crates/moly-game/src/fixture.rs index 598df2c..d3b306f 100644 --- a/crates/moly-game/src/fixture.rs +++ b/crates/moly-game/src/fixture.rs @@ -632,23 +632,22 @@ pub(crate) struct EditableFixture { } impl EditableFixture { - pub(crate) fn footprint(&self) -> (GridPosition, GridPosition) { + /// Editor candidates can leave the grid domain before Decide validates them. + pub(crate) fn footprint(&self) -> Result<(GridPosition, GridPosition), String> { moly_law::fixture::position::layout_footprint(self.center, self.grid_size, self.direction, self.layout) - .expect("validated fixture layout") } - pub(crate) fn occupancy(&self) -> OccupancyRow { - let (min, max) = self.footprint(); - OccupancyRow { + pub(crate) fn occupancy(&self) -> Result { + let (min, max) = self.footprint()?; + Ok(OccupancyRow { uid: self.uid.clone(), package: self.package.clone(), min, max, center_y: self.center.y, layout: self.layout, direction: self.direction, layout_center: self.center, layout_grid_size: self.grid_size, - } + }) } pub(crate) fn pose(&self) -> Result { - let (min, max) = moly_law::fixture::position::layout_footprint( - self.center, self.grid_size, self.direction, self.layout)?; + let (min, max) = self.footprint()?; let position = field_position(min, max, self.center.y, self.layout)?; Ok(Transform::from_translation(Vec3::from(position)).with_rotation( Quat::from_rotation_y(direction_yaw_degrees(self.direction).to_radians()))) @@ -664,6 +663,13 @@ pub(crate) struct FixtureLayoutRevision(pub u64); pub(crate) struct FixtureLayoutSet; impl FixturePlacements { + #[cfg(test)] + pub(crate) fn test_layout(rows: &[EditableFixture], floor: crate::site::FloorGridLayout) -> Self { + Self { site_id: 1, site_type: "first_floor".into(), level: floor.level, + floor: Some(floor), ..Default::default() } + .with_editor_rows(rows, 1).expect("valid synthetic layout") + } + pub(crate) fn site_id(&self) -> u32 { self.site_id } pub(crate) fn site_type(&self) -> &str { &self.site_type } pub(crate) fn floor_grid(&self) -> Option { self.floor } diff --git a/crates/moly-game/src/fixture_edit.rs b/crates/moly-game/src/fixture_edit.rs index 2be3e35..21e9df3 100644 --- a/crates/moly-game/src/fixture_edit.rs +++ b/crates/moly-game/src/fixture_edit.rs @@ -17,6 +17,9 @@ mod input; mod presentation; mod validation; +#[cfg(test)] +mod tests; + use crate::audio::{SeClass, SeRequest, SeRequests}; use crate::fixture::{EditableFixture, FixturePlacements, OccupancyRow}; use assets::{CANDIDATES, FixtureAreas}; diff --git a/crates/moly-game/src/fixture_edit/presentation.rs b/crates/moly-game/src/fixture_edit/presentation.rs index 4ae0c7a..c2cc04b 100644 --- a/crates/moly-game/src/fixture_edit/presentation.rs +++ b/crates/moly-game/src/fixture_edit/presentation.rs @@ -283,10 +283,12 @@ fn sync_marker( invalid, }); } - let Ok(pose) = selected.pose() else { + let (Ok(pose), Ok((min, max))) = (selected.pose(), selected.footprint()) else { + for entity in markers { + world.despawn(entity); + } return; }; - let (min, max) = selected.footprint(); let transform = Transform::from_translation(pose.translation + Vec3::Y * 0.01).with_scale(Vec3::new( (max.x as i32 - min.x as i32 + 1) as f32 * TILE_SIZE, diff --git a/crates/moly-game/src/fixture_edit/tests.rs b/crates/moly-game/src/fixture_edit/tests.rs new file mode 100644 index 0000000..d0afe7f --- /dev/null +++ b/crates/moly-game/src/fixture_edit/tests.rs @@ -0,0 +1,215 @@ +use super::*; +use crate::site::FloorGridLayout; +use bevy::ecs::system::RunSystemOnce; + +fn floor() -> FloorGridLayout { + FloorGridLayout { + level: 1, + layout_id: 1, + width: 64, + height: 16, + depth: 64, + } +} + +fn rectangle(center: GridPosition, direction: Direction) -> EditableFixture { + EditableFixture { + uid: "synthetic-rectangle".into(), + package: "mysekai__fixture__synthetic_rectangle".into(), + texture_id: 1, + fixture_id: 1, + center, + grid_size: Vector3Int { x: 1, y: 1, z: 2 }, + layout: layout_type::FLOOR, + direction, + } +} + +#[test] +fn rotated_candidates_reject_grid_overflow_without_panicking() { + let overflow = rectangle(GridPosition::new(127, 0, 0), Direction::Left); + assert!(overflow.footprint().is_err()); + for (x, direction) in [ + (127, Direction::Left), + (126, Direction::Left), + (127, Direction::Front), + ] { + let candidate = rectangle(GridPosition::new(x, 0, 0), direction); + if x == 126 || direction == Direction::Front { + assert!(candidate.footprint().is_ok()); + } + assert!(matches!( + validation::put(&candidate, &[], Some(floor())), + PutStatus::OutOfBounds { .. } + )); + } + for direction in [ + Direction::Front, + Direction::Left, + Direction::Back, + Direction::Right, + ] { + assert_eq!( + validation::put( + &rectangle(GridPosition::ZERO, direction), + &[], + Some(floor()) + ), + PutStatus::Ok + ); + for (x, z) in [(i8::MIN, 0), (i8::MAX, 0), (0, i8::MIN), (0, i8::MAX)] { + for (width, depth) in [(1, 2), (2, 1), (3, 5)] { + let mut candidate = rectangle(GridPosition::new(x, 0, z), direction); + candidate.grid_size.x = width; + candidate.grid_size.z = depth; + assert!(matches!( + validation::put(&candidate, &[], Some(floor())), + PutStatus::OutOfBounds { .. } + )); + } + } + } +} + +#[test] +fn malformed_draft_rows_return_layout_errors() { + let valid = rectangle(GridPosition::ZERO, Direction::Front); + let mut invalid = rectangle(GridPosition::new(127, 0, 0), Direction::Left); + invalid.uid = "invalid-neighbor".into(); + assert!(matches!( + validation::put(&valid, &[invalid.clone()], Some(floor())), + PutStatus::OutOfBounds { .. } + )); + let areas = FixtureAreas { + loaded: true, + ..Default::default() + }; + assert!(validation::save(&[invalid], Some(floor()), &areas) + .unwrap_err() + .starts_with("ErrorLayout(1)")); + assert!(validation::save(&[valid], Some(floor()), &areas).is_ok()); +} + +#[test] +fn invalid_candidate_save_preserves_storage() { + // The child owns its settings path, so this check cannot redirect storage + // for another concurrently running unit test. + if std::env::var_os("MOLY_EDITOR_TEST_CHILD").is_none() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("settings.json"); + let original = b"{\"unrelated\":{\"keep\":true}}"; + std::fs::write(&path, original).unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "fixture_edit::tests::invalid_candidate_save_preserves_storage", + "--nocapture", + ]) + .env("MOLY_EDITOR_TEST_CHILD", "1") + .env("MOLY_SETTINGS_FILE", &path) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(std::fs::read(path).unwrap(), original); + return; + } + + let original = rectangle(GridPosition::ZERO, Direction::Front); + let baseline = FixturePlacements::test_layout(&[original.clone()], floor()); + let mut session = EditSession { + baseline: Some(baseline.clone()), + rows: vec![original.clone()], + phase: EditPhase::Browsing, + revision: 1, + ..Default::default() + }; + let mut world = World::new(); + world.insert_resource(baseline.clone()); + world.insert_resource(FixtureAreas { + loaded: true, + ..Default::default() + }); + world.init_resource::(); + + apply_command( + &mut world, + &mut session, + EditCommand::SelectPlaced { + uid: original.uid.clone(), + }, + ); + apply_command( + &mut world, + &mut session, + EditCommand::MoveTo { + center: GridPosition::new(126, 0, 0), + }, + ); + apply_command(&mut world, &mut session, EditCommand::Nudge { x: 1, z: 0 }); + apply_command(&mut world, &mut session, EditCommand::Rotate); + world.insert_resource(session); + world.run_system_once(publish_view).unwrap(); + assert!(matches!( + world + .resource::() + .selected + .as_ref() + .unwrap() + .put_status, + PutStatus::OutOfBounds { .. } + )); + assert!(!world.resource::().can_save); + let mut session = world.remove_resource::().unwrap(); + + apply_command(&mut world, &mut session, EditCommand::Decide); + apply_command(&mut world, &mut session, EditCommand::Save); + assert!(session.selected.is_some()); + assert_eq!(session.rows, vec![original.clone()]); + assert_eq!( + world.resource::().editor_rows(), + vec![original.clone()] + ); + + apply_command(&mut world, &mut session, EditCommand::Cancel); + assert!(session.selected.is_none()); + assert_eq!(session.rows, vec![original.clone()]); + apply_command( + &mut world, + &mut session, + EditCommand::SelectPlaced { + uid: original.uid.clone(), + }, + ); + apply_command( + &mut world, + &mut session, + EditCommand::MoveTo { + center: GridPosition::new(127, 0, 0), + }, + ); + apply_command(&mut world, &mut session, EditCommand::Rotate); + apply_command( + &mut world, + &mut session, + EditCommand::MoveTo { + center: GridPosition::ZERO, + }, + ); + apply_command(&mut world, &mut session, EditCommand::Decide); + assert!(session.selected.is_none()); + assert_eq!(session.rows[0].direction, Direction::Left); + + session.rows[0].center = GridPosition::new(127, 0, 0); + apply_command(&mut world, &mut session, EditCommand::Save); + assert!(session.feedback.contains("ErrorLayout(1)")); + assert_eq!(session.rows[0].center.x, 127); + assert_eq!( + world.resource::().editor_rows(), + vec![original] + ); +} diff --git a/crates/moly-game/src/fixture_edit/validation.rs b/crates/moly-game/src/fixture_edit/validation.rs index a77a6f4..231e2be 100644 --- a/crates/moly-game/src/fixture_edit/validation.rs +++ b/crates/moly-game/src/fixture_edit/validation.rs @@ -1,13 +1,12 @@ //! Grid-space checks. Rendering bounds are never used as layout dimensions. use super::{ - PutStatus, assets::{FixtureAreas, MetaGrid}, + PutStatus, }; use crate::{fixture::EditableFixture, site::FloorGridLayout}; -use moly_law::fixture::Direction; -use moly_law::fixture::areas::{GridAreaData, motion_area_bounds, rotated_center_grid}; -use moly_law::fixture::position::{footprint_front, footprint_to_center_size, layout_type}; +use moly_law::fixture::areas::{motion_area_bounds, rotated_center_grid, GridAreaData}; +use moly_law::fixture::position::layout_type; use std::collections::HashSet; /// This slice edits ground furniture and rugs. Roads, walls and nonzero-height @@ -43,25 +42,17 @@ pub(super) fn put( let Some(floor) = floor else { return PutStatus::OutOfBounds { cells: 0 }; }; - let (front_min, front_max) = footprint_front(item.center, item.grid_size); - if footprint_to_center_size(front_min, front_max).is_err() - || item.grid_size.x <= 0 - || item.grid_size.y <= 0 - || item.grid_size.z <= 0 - { + let Ok((min, max)) = item.footprint() else { return PutStatus::OutOfBounds { cells: 0 }; - } - let (min, max) = item.footprint(); - let (width, depth) = match item.direction { - Direction::Front | Direction::Back => (item.grid_size.x, item.grid_size.z), - Direction::Left | Direction::Right => (item.grid_size.z, item.grid_size.x), }; - if max.x as i32 - min.x as i32 + 1 != width - || max.z as i32 - min.z as i32 + 1 != depth - || max.y as i32 - min.y as i32 + 1 != item.grid_size.y - { + let occupied: Result, _> = rows + .iter() + .filter(|row| row.uid != item.uid && row.layout == item.layout) + .map(EditableFixture::footprint) + .collect(); + let Ok(occupied) = occupied else { return PutStatus::OutOfBounds { cells: 0 }; - } + }; let mut outside = 0; let mut overlap = 0; let height = if item.layout == layout_type::FLOOR { @@ -80,19 +71,14 @@ pub(super) fn put( || y >= height { outside += 1; - } else if rows - .iter() - .filter(|row| row.uid != item.uid && row.layout == item.layout) - .any(|other| { - let (a, b) = other.footprint(); - x >= a.x as i32 - && x <= b.x as i32 - && y >= a.y as i32 - && y <= b.y as i32 - && z >= a.z as i32 - && z <= b.z as i32 - }) - { + } else if occupied.iter().any(|(a, b)| { + x >= a.x as i32 + && x <= b.x as i32 + && y >= a.y as i32 + && y <= b.y as i32 + && z >= a.z as i32 + && z <= b.z as i32 + }) { overlap += 1; } } @@ -132,6 +118,15 @@ pub(super) fn save( let Some(floor) = floor else { return Err("地图等级尺寸尚未就绪".into()); }; + // Reject malformed drafts before any volume-based save checks. Valid + // geometry still follows the source cutscene/animation/layout precedence. + let footprints: Vec<_> = rows + .iter() + .map(|row| { + row.footprint() + .map_err(|error| format!("ErrorLayout(1):{}:{error}", row.uid)) + }) + .collect::>()?; for owner in rows { if let Some(meta) = areas.cutscene.get(&owner.package) { let protected = cutscene_cells(meta, owner); @@ -140,9 +135,9 @@ pub(super) fn save( .any(|(x, z)| !axis_inside(*x, floor.width) || !axis_inside(*z, floor.depth)) || rows .iter() - .filter(|row| row.uid != owner.uid && row.layout == layout_type::FLOOR) - .any(|row| { - let (min, max) = row.footprint(); + .zip(&footprints) + .filter(|(row, _)| row.uid != owner.uid && row.layout == layout_type::FLOOR) + .any(|(_, (min, max))| { (min.x..=max.x) .any(|x| (min.z..=max.z).any(|z| protected.contains(&(x, z)))) }) @@ -172,9 +167,9 @@ pub(super) fn save( ); if bounds.iter().any(|bound| { rows.iter() - .filter(|row| row.uid != item.uid && row.layout == layout_type::FLOOR) - .any(|row| { - let (a, b) = row.footprint(); + .zip(&footprints) + .filter(|(row, _)| row.uid != item.uid && row.layout == layout_type::FLOOR) + .any(|(_, (a, b))| { bound.min.x <= b.x && bound.max.x >= a.x && bound.min.z <= b.z diff --git a/crates/moly-game/src/lib.rs b/crates/moly-game/src/lib.rs index 8ea7a83..a518342 100644 --- a/crates/moly-game/src/lib.rs +++ b/crates/moly-game/src/lib.rs @@ -54,6 +54,7 @@ pub mod option_dialog; pub mod pick; pub mod player; pub mod player_data; +mod player_data_io; mod player_data_ui; pub mod player_avatar; mod player_fixture_action; diff --git a/crates/moly-game/src/player_data.rs b/crates/moly-game/src/player_data.rs index a79b37f..5a3a6a8 100644 --- a/crates/moly-game/src/player_data.rs +++ b/crates/moly-game/src/player_data.rs @@ -6,13 +6,11 @@ use moly_assets::{ player_data::{ImportedPlayerData, PlayerDataCatalog, MAX_IMPORT_BYTES}, }; use serde_json::{json, Value}; -use std::{ - sync::{Arc, Mutex}, - time::Duration, -}; +use std::sync::{Arc, Mutex}; use crate::{ fixture::layouts::{self, SiteFixtureLayouts}, + player_data_io::{self, decode_bytes, ReadTask}, settings_store, site::{SiteSelection, Sites}, }; @@ -41,10 +39,21 @@ struct Payload { apply: bool, } enum Incoming { - Data(u64, Result, String>), + Data(Result, String>), Paste(Result), } -type Inbox = Arc>>; + +#[derive(Default)] +struct InboxState { + generation: u64, + message: Option, +} +type Inbox = Arc>; + +fn deliver(inbox: &Inbox, ticket: u64, message: Incoming) { + let mut inbox = inbox.lock().unwrap(); + if inbox.generation == ticket { inbox.message = Some(message); } +} struct Preview { data: ImportedPlayerData, @@ -62,6 +71,7 @@ pub struct PlayerDataImport { pub(crate) action: Option, api: String, inbox: Inbox, + read_task: Option, generation: u64, pending: Option, preview: Option, @@ -77,7 +87,7 @@ impl Default for PlayerDataImport { let api = "/player-api/{region}/{target_user_id}"; Self { uid: String::new(), region: "cn".into(), status: "Enter a player UID or choose a Mysekai JSON file. Import keeps a recoverable layout backup.".into(), - busy: false, action: None, api: api.into(), inbox: Default::default(), + busy: false, action: None, api: api.into(), inbox: Default::default(), read_task: None, generation: 0, pending: None, preview: None, requests: None, catalog: None } } } @@ -110,6 +120,10 @@ impl PlayerDataImport { pub fn request_json(&mut self, text: String, apply: bool) { self.cancel(); + if text.len() > MAX_IMPORT_BYTES { + self.status = "Player data exceeds the 32 MiB import limit".into(); + return; + } self.pending = Some(Payload { text, region: self.region.clone(), @@ -151,7 +165,13 @@ impl PlayerDataImport { } fn cancel(&mut self) { + self.read_task = None; self.generation = self.generation.wrapping_add(1); + { + let mut inbox = self.inbox.lock().unwrap(); + inbox.generation = self.generation; + inbox.message = None; + } self.pending = None; self.preview = None; self.busy = false; @@ -174,68 +194,60 @@ impl PlayerDataImport { .api .replace("{region}", &self.region) .replace("{target_user_id}", &uid); - let request = ehttp::Request::get(url).with_timeout(Some(Duration::from_secs(60))); let (inbox, ticket, region) = (self.inbox.clone(), self.generation, self.region.clone()); - ehttp::fetch(request, move |response| { - let result = response.and_then(|response| { - match response.status { - 200 => {} - 404 => { - return Err( - "Player does not exist or their Mysekai home is unavailable (HTTP 404)" - .into(), - ) - } - 401 | 403 => { - return Err(format!( - "Player API denied access (HTTP {})", - response.status - )) - } - 429 => { - return Err("Player API is rate limited; try again later (HTTP 429)".into()) - } - code => return Err(format!("Player API returned HTTP {code}")), - } - decode_bytes(response.bytes).map(|text| { - Some(Payload { - text, - region, - uid: Some(uid), - apply: false, - }) - }) - }); - inbox.lock().unwrap().push(Incoming::Data(ticket, result)); - }); + self.read_task = Some(player_data_io::fetch(url, move |result| { + let result = result.map(|text| Some(Payload { text, region, uid: Some(uid), apply: false })); + deliver(&inbox, ticket, Incoming::Data(result)); + })?); Ok(()) } - fn choose_file(&mut self) { + fn choose_file(&mut self) -> Result<(), String> { self.cancel(); self.busy = true; self.status = "Choose a player-data JSON file...".into(); - choose_file(self.inbox.clone(), self.generation, self.region.clone()); + let (inbox, ticket, region) = (self.inbox.clone(), self.generation, self.region.clone()); + self.read_task = Some(player_data_io::choose_file(move |result| { + let result = result.map(|text| text.map(|text| Payload { text, region, uid: None, apply: false })); + deliver(&inbox, ticket, Incoming::Data(result)); + })?); + Ok(()) + } + + #[cfg(not(target_arch = "wasm32"))] + fn read_file(&mut self, path: std::path::PathBuf) -> Result<(), String> { + self.cancel(); + self.busy = true; + self.status = "Reading player data...".into(); + let (inbox, ticket, region) = (self.inbox.clone(), self.generation, self.region.clone()); + self.read_task = Some(player_data_io::read_file(path, move |result| { + let result = result.map(|text| Some(Payload { text, region, uid: None, apply: false })); + deliver(&inbox, ticket, Incoming::Data(result)); + })?); + Ok(()) } fn receive(&mut self) { - let messages = std::mem::take(&mut *self.inbox.lock().unwrap()); - for message in messages { + let message = self.inbox.lock().unwrap().message.take(); + if let Some(message) = message { match message { - Incoming::Data(ticket, result) if ticket == self.generation => match result { - Ok(Some(payload)) => { - self.pending = Some(payload); - self.status = "Checking player data and matching assets...".into(); - } - Ok(None) => { - self.busy = false; - self.status = "File selection cancelled.".into(); - } - Err(error) => { - self.busy = false; - self.status = error; + Incoming::Data(result) => { + self.read_task = None; + match result { + Ok(Some(payload)) => { + self.pending = Some(payload); + self.status = "Checking player data and matching assets...".into(); + } + Ok(None) => { + self.busy = false; + self.status = "File selection cancelled.".into(); + } + Err(error) => { + self.busy = false; + self.status = error; + } } - }, + } Incoming::Paste(Ok(text)) => { let text = text.trim(); if text.len() <= 20 && text.bytes().all(|b| b.is_ascii_digit()) { @@ -246,7 +258,6 @@ impl PlayerDataImport { } } Incoming::Paste(Err(error)) => self.status = error, - _ => {} } } } @@ -403,6 +414,10 @@ impl PlayerDataImport { } } +impl Drop for PlayerDataImport { + fn drop(&mut self) { self.cancel(); } +} + fn require_clean_editor(world: &World) -> Result<(), String> { if world .get_resource::() @@ -495,14 +510,11 @@ pub(crate) fn update(world: &mut World) { } state.fetch() } - Some(ImportAction::File) => { - state.choose_file(); - Ok(()) - } + Some(ImportAction::File) => state.choose_file(), Some(ImportAction::Apply) => state.apply(world), Some(ImportAction::Restore) => state.restore(world), Some(ImportAction::Paste) => { - paste(state.inbox.clone()); + paste(state.inbox.clone(), state.generation); Ok(()) } Some(ImportAction::ClearUid) => { @@ -539,25 +551,19 @@ pub(crate) fn update(world: &mut World) { world.insert_resource(state); } -fn decode_bytes(bytes: Vec) -> Result { - if bytes.len() > MAX_IMPORT_BYTES { - return Err("Player data exceeds 32 MiB".into()); - } - String::from_utf8(bytes).map_err(|_| "Player data must be a UTF-8 JSON file".into()) -} - #[cfg(not(target_arch = "wasm32"))] pub fn read_json_file(path: &std::path::Path) -> Result { let file = std::fs::File::open(path).map_err(|e| format!("Open player data: {e}"))?; - if !file - .metadata() - .map_err(|e| format!("Read player data: {e}"))? - .is_file() - { + let metadata = file.metadata().map_err(|e| format!("Read player data: {e}"))?; + if !metadata.is_file() { return Err("Choose a player-data file".into()); } + if metadata.len() > MAX_IMPORT_BYTES as u64 { + return Err("Player data exceeds the 32 MiB import limit".into()); + } use std::io::Read; let mut bytes = Vec::new(); + bytes.try_reserve_exact(MAX_IMPORT_BYTES + 1).map_err(|e| format!("Allocate player-data buffer: {e}"))?; file.take(MAX_IMPORT_BYTES as u64 + 1) .read_to_end(&mut bytes) .map_err(|e| format!("Read player data: {e}"))?; @@ -565,56 +571,15 @@ pub fn read_json_file(path: &std::path::Path) -> Result { } #[cfg(not(target_arch = "wasm32"))] -fn choose_file(inbox: Inbox, ticket: u64, region: String) { - std::thread::spawn(move || { - let result = rfd::FileDialog::new() - .add_filter("Mysekai player data", &["json"]) - .pick_file() - .map(|path| { - read_json_file(&path).map(|text| Payload { - text, - region, - uid: None, - apply: false, - }) - }) - .transpose(); - inbox.lock().unwrap().push(Incoming::Data(ticket, result)); - }); -} - -#[cfg(target_arch = "wasm32")] -fn choose_file(inbox: Inbox, ticket: u64, region: String) { - wasm_bindgen_futures::spawn_local(async move { - let result = match rfd::AsyncFileDialog::new() - .add_filter("Mysekai player data", &["json"]) - .pick_file() - .await - { - Some(file) => decode_bytes(file.read().await).map(|text| { - Some(Payload { - text, - region, - uid: None, - apply: false, - }) - }), - None => Ok(None), - }; - inbox.lock().unwrap().push(Incoming::Data(ticket, result)); - }); -} - -#[cfg(not(target_arch = "wasm32"))] -fn paste(inbox: Inbox) { +fn paste(inbox: Inbox, ticket: u64) { let result = arboard::Clipboard::new() .and_then(|mut clipboard| clipboard.get_text()) .map_err(|e| format!("Read clipboard: {e}")); - inbox.lock().unwrap().push(Incoming::Paste(result)); + deliver(&inbox, ticket, Incoming::Paste(result)); } #[cfg(target_arch = "wasm32")] -fn paste(inbox: Inbox) { +fn paste(inbox: Inbox, ticket: u64) { wasm_bindgen_futures::spawn_local(async move { let result = match web_sys::window() { Some(window) => { @@ -629,7 +594,7 @@ fn paste(inbox: Inbox) { } None => Err("Browser window is unavailable".into()), }; - inbox.lock().unwrap().push(Incoming::Paste(result)); + deliver(&inbox, ticket, Incoming::Paste(result)); }); } @@ -643,12 +608,9 @@ pub(crate) fn drop_files( if let bevy::window::FileDragAndDrop::DroppedFile { path_buf, .. } = event { panel.open = true; panel.player_data = true; - match read_json_file(path_buf) { - Ok(text) => state.request_json(text, false), - Err(error) => { - state.cancel(); - state.status = error; - } + if let Err(error) = state.read_file(path_buf.clone()) { + state.cancel(); + state.status = error; } } } @@ -665,3 +627,6 @@ pub(crate) fn install(app: &mut App) { #[cfg(not(target_arch = "wasm32"))] app.add_systems(Update, drop_files.before(update)); } + +#[cfg(test)] +mod tests; diff --git a/crates/moly-game/src/player_data/tests.rs b/crates/moly-game/src/player_data/tests.rs new file mode 100644 index 0000000..7f2cac5 --- /dev/null +++ b/crates/moly-game/src/player_data/tests.rs @@ -0,0 +1,43 @@ +use super::*; + +fn payload(text: &str) -> Incoming { + Incoming::Data(Ok(Some(Payload { + text: text.into(), + region: "cn".into(), + uid: None, + apply: false, + }))) +} + +#[test] +fn cancellation_discards_completed_and_late_payloads() { + let mut state = PlayerDataImport::default(); + let old_ticket = state.generation; + deliver(&state.inbox, old_ticket, payload("old")); + state.cancel(); + assert!(state.inbox.lock().unwrap().message.is_none()); + deliver(&state.inbox, old_ticket, payload("late")); + state.receive(); + assert!(state.pending.is_none()); + assert!(!state.busy); + assert!(state.preview.is_none()); + deliver(&state.inbox, state.generation, payload("current")); + state.receive(); + assert_eq!(state.pending.as_ref().unwrap().text, "current"); +} + +#[test] +fn failed_reads_do_not_create_an_import_preview() { + let mut state = PlayerDataImport::default(); + state.busy = true; + deliver( + &state.inbox, + state.generation, + Incoming::Data(Err("body exceeds limit".into())), + ); + state.receive(); + assert!(!state.busy); + assert!(state.pending.is_none()); + assert!(!state.has_preview()); + assert_eq!(state.status(), "body exceeds limit"); +} diff --git a/crates/moly-game/src/player_data_io.rs b/crates/moly-game/src/player_data_io.rs new file mode 100644 index 0000000..a433ea1 --- /dev/null +++ b/crates/moly-game/src/player_data_io.rs @@ -0,0 +1,455 @@ +//! Bounded, cancellable player-data transfers. Only one transfer allocates a +//! body buffer at a time; cancelled operations release their queue position. + +use async_lock::Semaphore; +use futures_util::future::{AbortHandle, Abortable}; +use moly_assets::player_data::MAX_IMPORT_BYTES; +use std::{future::Future, sync::OnceLock}; + +pub(crate) struct ReadTask(AbortHandle); + +impl Drop for ReadTask { + fn drop(&mut self) { + self.0.abort(); + } +} + +fn transfers() -> &'static Semaphore { + static TRANSFERS: OnceLock = OnceLock::new(); + TRANSFERS.get_or_init(|| Semaphore::new(1)) +} + +#[cfg(not(target_arch = "wasm32"))] +fn spawn(future: impl Future + Send + 'static) -> Result { + static RUNTIME: OnceLock> = OnceLock::new(); + let runtime = RUNTIME + .get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .max_blocking_threads(2) + .thread_name("moly-import") + .enable_all() + .build() + .map_err(|error| format!("Start player-data reader: {error}")) + }) + .as_ref() + .map_err(Clone::clone)?; + let (abort, registration) = AbortHandle::new_pair(); + runtime.spawn(async move { + let _ = Abortable::new(future, registration).await; + }); + Ok(ReadTask(abort)) +} + +#[cfg(target_arch = "wasm32")] +fn spawn(future: impl Future + 'static) -> Result { + let (abort, registration) = AbortHandle::new_pair(); + wasm_bindgen_futures::spawn_local(async move { + let _ = Abortable::new(future, registration).await; + }); + Ok(ReadTask(abort)) +} + +pub(crate) fn fetch( + url: String, + complete: impl FnOnce(Result) + Send + 'static, +) -> Result { + spawn(async move { + let _permit = transfers().acquire().await; + complete( + platform::fetch(&url, MAX_IMPORT_BYTES) + .await + .and_then(decode_bytes), + ); + }) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn read_file( + path: std::path::PathBuf, + complete: impl FnOnce(Result) + Send + 'static, +) -> Result { + spawn(async move { + let _permit = transfers().acquire().await; + complete( + platform::read_file(&path, MAX_IMPORT_BYTES) + .await + .and_then(decode_bytes), + ); + }) +} + +pub(crate) fn choose_file( + complete: impl FnOnce(Result, String>) + Send + 'static, +) -> Result { + let selection = platform::pick_file()?; + spawn(async move { + let result = match selection.await { + Ok(Some(file)) => { + let _permit = transfers().acquire().await; + platform::file(&file, MAX_IMPORT_BYTES) + .await + .and_then(decode_bytes) + .map(Some) + } + Ok(None) => Ok(None), + Err(error) => Err(error), + }; + complete(result); + }) +} + +fn too_large() -> String { + "Player data exceeds the 32 MiB import limit".into() +} + +fn check_length(length: Option, limit: usize) -> Result<(), String> { + if length.is_some_and(|length| length > limit as u64) { + Err(too_large()) + } else { + Ok(()) + } +} + +fn status(code: u16) -> Result<(), String> { + match code { + 200 => Ok(()), + 404 => Err("Player does not exist or their Mysekai home is unavailable (HTTP 404)".into()), + 401 | 403 => Err(format!("Player API denied access (HTTP {code})")), + 429 => Err("Player API is rate limited; try again later (HTTP 429)".into()), + code => Err(format!("Player API returned HTTP {code}")), + } +} + +struct Body { + bytes: Vec, + limit: usize, +} + +impl Body { + fn new(limit: usize) -> Result { + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(limit.checked_add(1).ok_or_else(too_large)?) + .map_err(|error| format!("Allocate player-data buffer: {error}"))?; + Ok(Self { bytes, limit }) + } + + fn remaining(&self) -> usize { + self.limit + 1 - self.bytes.len() + } + + fn push(&mut self, bytes: &[u8]) -> Result<(), String> { + let count = self.remaining().min(bytes.len()); + self.bytes.extend_from_slice(&bytes[..count]); + if count != bytes.len() || self.bytes.len() > self.limit { + return Err(too_large()); + } + Ok(()) + } +} + +pub(crate) fn decode_bytes(bytes: Vec) -> Result { + check_length(Some(bytes.len() as u64), MAX_IMPORT_BYTES)?; + let mut text = + String::from_utf8(bytes).map_err(|_| "Player data must be UTF-8 JSON".to_owned())?; + if text.starts_with('\u{feff}') { + text.drain(..3); + } + Ok(text) +} + +#[cfg(not(target_arch = "wasm32"))] +mod platform { + use super::*; + use tokio::io::AsyncReadExt; + + pub(super) fn pick_file( + ) -> Result, String>> + Send, String> { + let selection = rfd::AsyncFileDialog::new() + .add_filter("Mysekai player data", &["json"]) + .pick_file(); + Ok(async move { Ok(selection.await) }) + } + + pub(super) async fn fetch(url: &str, limit: usize) -> Result, String> { + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(60)) + .pool_max_idle_per_host(0) + .build() + .map_err(|error| format!("Create player request: {error}"))?; + let mut response = client + .get(url) + .header("Accept", "application/json") + .header("Accept-Encoding", "identity") + .send() + .await + .map_err(|error| format!("Fetch player data: {error}"))?; + status(response.status().as_u16())?; + check_length(response.content_length(), limit)?; + let mut body = Body::new(limit)?; + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| format!("Read player data: {error}"))? + { + body.push(&chunk)?; + } + Ok(body.bytes) + } + + pub(super) async fn file(file: &rfd::FileHandle, limit: usize) -> Result, String> { + read_file(file.path(), limit).await + } + + pub(super) async fn read_file(path: &std::path::Path, limit: usize) -> Result, String> { + let mut file = tokio::fs::File::open(path) + .await + .map_err(|error| format!("Open player data: {error}"))?; + let metadata = file + .metadata() + .await + .map_err(|error| format!("Inspect player data: {error}"))?; + if !metadata.is_file() { + return Err("Choose a player-data file".into()); + } + check_length(Some(metadata.len()), limit)?; + let mut body = Body::new(limit)?; + let mut chunk = [0u8; 16 * 1024]; + loop { + let remaining = body.remaining().min(chunk.len()); + let length = file + .read(&mut chunk[..remaining]) + .await + .map_err(|error| format!("Read player data: {error}"))?; + if length == 0 { + break; + } + body.push(&chunk[..length])?; + } + Ok(body.bytes) + } +} + +#[cfg(target_arch = "wasm32")] +mod platform { + use super::*; + use std::{cell::RefCell, rc::Rc}; + use wasm_bindgen::{closure::Closure, JsCast, JsValue}; + use wasm_bindgen_futures::JsFuture; + use web_sys::{ + AbortController, ReadableStreamDefaultReader, Request, RequestInit, RequestRedirect, + Response, + }; + + struct FilePicker { + input: web_sys::HtmlInputElement, + result: Option>>, + _change: Closure, + cancel: Closure, + } + + impl FilePicker { + fn new() -> Result { + let document = web_sys::window() + .and_then(|window| window.document()) + .ok_or("Browser document is unavailable")?; + let input: web_sys::HtmlInputElement = document + .create_element("input") + .map_err(|_| "Create file picker")? + .dyn_into() + .map_err(|_| "Create file picker input")?; + input.set_id("moly-player-data-file"); + input.set_type("file"); + input.set_accept(".json"); + input.set_hidden(true); + let (send, result) = futures_channel::oneshot::channel(); + let send = Rc::new(RefCell::new(Some(send))); + let change_send = send.clone(); + let selected = input.clone(); + let change = Closure::wrap(Box::new(move |_: web_sys::Event| { + if let Some(send) = change_send.borrow_mut().take() { + let file = selected.files().and_then(|files| files.get(0)); + let _ = send.send(file); + } + }) as Box); + let cancel = Closure::wrap(Box::new(move |_: web_sys::Event| { + if let Some(send) = send.borrow_mut().take() { + let _ = send.send(None); + } + }) as Box); + input.set_onchange(Some(change.as_ref().unchecked_ref())); + input + .add_event_listener_with_callback("cancel", cancel.as_ref().unchecked_ref()) + .map_err(|_| "Listen for file cancellation")?; + let picker = Self { + input, + result: Some(result), + _change: change, + cancel, + }; + document + .body() + .ok_or("Browser body is unavailable")? + .append_child(&picker.input) + .map_err(|_| "Attach file picker")?; + picker.input.click(); + Ok(picker) + } + + async fn finish(mut self) -> Result, String> { + self.result + .take() + .expect("one file selection") + .await + .map_err(|_| "File selection was interrupted".into()) + } + } + + impl Drop for FilePicker { + fn drop(&mut self) { + self.input.set_onchange(None); + let _ = self.input.remove_event_listener_with_callback( + "cancel", + self.cancel.as_ref().unchecked_ref(), + ); + self.input.remove(); + } + } + + pub(super) fn pick_file( + ) -> Result, String>>, String> { + Ok(FilePicker::new()?.finish()) + } + + struct RequestGuard { + window: web_sys::Window, + controller: AbortController, + timer: i32, + _timeout: Closure, + } + + impl RequestGuard { + fn new() -> Result { + let window = web_sys::window().ok_or("Browser window is unavailable")?; + let controller = AbortController::new() + .map_err(|error| format!("Create player request: {error:?}"))?; + let abort = controller.clone(); + let timeout = Closure::wrap(Box::new(move || abort.abort()) as Box); + let timer = window + .set_timeout_with_callback_and_timeout_and_arguments_0( + timeout.as_ref().unchecked_ref(), + 60_000, + ) + .map_err(|error| format!("Set player request timeout: {error:?}"))?; + Ok(Self { + window, + controller, + timer, + _timeout: timeout, + }) + } + } + + impl Drop for RequestGuard { + fn drop(&mut self) { + self.window.clear_timeout_with_handle(self.timer); + self.controller.abort(); + } + } + + struct StreamGuard(ReadableStreamDefaultReader); + impl Drop for StreamGuard { + fn drop(&mut self) { + let reader = self.0.clone(); + let cancelled = reader.cancel(); + wasm_bindgen_futures::spawn_local(async move { + let _ = JsFuture::from(cancelled).await; + reader.release_lock(); + }); + } + } + + async fn read_stream(stream: web_sys::ReadableStream, limit: usize) -> Result, String> { + let reader = StreamGuard( + stream + .get_reader() + .dyn_into() + .map_err(|_| "Player data has no readable byte stream")?, + ); + let mut body = Body::new(limit)?; + loop { + let chunk = JsFuture::from(reader.0.read()) + .await + .map_err(|error| format!("Read player data: {error:?}"))?; + if js_sys::Reflect::get(&chunk, &JsValue::from_str("done")) + .ok() + .and_then(|value| value.as_bool()) + == Some(true) + { + break; + } + let bytes: js_sys::Uint8Array = + js_sys::Reflect::get(&chunk, &JsValue::from_str("value")) + .map_err(|_| "Player data stream has no bytes")? + .dyn_into() + .map_err(|_| "Player data stream is not binary")?; + let count = body.remaining().min(bytes.length() as usize); + let offset = body.bytes.len(); + body.bytes.resize(offset + count, 0); + bytes + .subarray(0, count as u32) + .copy_to(&mut body.bytes[offset..]); + if count != bytes.length() as usize || body.bytes.len() > limit { + return Err(too_large()); + } + } + Ok(body.bytes) + } + + pub(super) async fn fetch(url: &str, limit: usize) -> Result, String> { + let guard = RequestGuard::new()?; + let init = RequestInit::new(); + init.set_redirect(RequestRedirect::Error); + init.set_signal(Some(&guard.controller.signal())); + let request = Request::new_with_str_and_init(url, &init) + .map_err(|error| format!("Invalid player request: {error:?}"))?; + request + .headers() + .set("Accept", "application/json") + .map_err(|error| format!("Set player request headers: {error:?}"))?; + let response: Response = JsFuture::from(guard.window.fetch_with_request(&request)) + .await + .map_err(|error| format!("Fetch player data: {error:?}"))? + .dyn_into() + .map_err(|_| "Invalid player response")?; + status(response.status())?; + let length = response + .headers() + .get("content-length") + .ok() + .flatten() + .and_then(|value| value.parse().ok()); + check_length(length, limit)?; + let Some(body) = response.body() else { + return Ok(Vec::new()); + }; + read_stream(body, limit).await + } + + pub(super) async fn file(file: &web_sys::File, limit: usize) -> Result, String> { + read_file(file, limit).await + } + + pub(super) async fn read_file(file: &web_sys::File, limit: usize) -> Result, String> { + let size = file.size(); + if !size.is_finite() || size < 0.0 || size > limit as f64 { + return Err(too_large()); + } + read_stream(file.stream(), limit).await + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests; diff --git a/crates/moly-game/src/player_data_io/tests.rs b/crates/moly-game/src/player_data_io/tests.rs new file mode 100644 index 0000000..fa1178a --- /dev/null +++ b/crates/moly-game/src/player_data_io/tests.rs @@ -0,0 +1,247 @@ +use super::*; +use std::{ + io::{Read, Write}, + net::{TcpListener, TcpStream}, + sync::mpsc, + thread, + time::Duration, +}; + +fn server(handler: impl FnOnce(TcpStream) + Send + 'static) -> (String, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/player", listener.local_addr().unwrap()); + listener.set_nonblocking(true).unwrap(); + let thread = thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "client did not connect" + ); + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept: {error}"), + } + }; + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut byte = [0]; + while !request.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + assert!(request.len() < 8192); + } + handler(stream); + }); + (url, thread) +} + +fn fetch_with_limit(url: String, limit: usize) -> Result, String> { + let (send, receive) = mpsc::channel(); + let task = spawn(async move { + send.send(platform::fetch(&url, limit).await).unwrap(); + }) + .unwrap(); + let result = receive.recv_timeout(Duration::from_secs(10)).unwrap(); + drop(task); + result +} + +fn response(wire: &'static [u8], limit: usize) -> Result, String> { + let (url, thread) = server(move |mut stream| { + let _ = stream.write_all(wire); + }); + let result = fetch_with_limit(url, limit); + thread.join().unwrap(); + result +} + +#[test] +fn http_reads_accept_small_exact_and_unframed_bodies() { + assert_eq!( + response( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}", + 8 + ) + .unwrap(), + b"{}" + ); + assert_eq!( + response( + b"HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\n{\"x\":10}", + 8 + ) + .unwrap(), + b"{\"x\":10}" + ); + assert_eq!( + response(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n{}", 8).unwrap(), + b"{}" + ); +} + +#[test] +fn http_limits_do_not_trust_response_length_headers() { + assert!(response( + b"HTTP/1.1 200 OK\r\nContent-Length: 1000000000\r\nConnection: close\r\n\r\n", + 8 + ) + .unwrap_err() + .contains("limit")); + assert!(response( + b"HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\n{}", + 8 + ) + .is_err()); + assert!(response(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Length: 1\r\nConnection: close\r\n\r\n9\r\n123456789\r\n0\r\n\r\n", 8).is_err()); + assert!(response( + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + 8 + ) + .unwrap_err() + .contains("404")); +} + +#[test] +fn oversized_stream_is_closed_before_the_server_finishes_it() { + let (url, thread) = server(move |mut stream| { + stream + .write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n9\r\n123456789\r\n") + .unwrap(); + stream.flush().unwrap(); + // No terminating chunk: waiting for the full response would hang. + let result = stream.read(&mut [0; 1]); + assert!( + matches!(result, Ok(0)) + || result.as_ref().is_err_and(|error| matches!( + error.kind(), + std::io::ErrorKind::ConnectionReset | std::io::ErrorKind::ConnectionAborted + )), + "stream did not close: {result:?}" + ); + }); + assert!(fetch_with_limit(url, 8).unwrap_err().contains("limit")); + thread.join().unwrap(); +} + +#[test] +fn cancelling_a_stalled_transfer_closes_it_and_unblocks_the_next_import() { + let (started, wait_started) = mpsc::channel(); + let (first_url, first_server) = server(move |mut stream| { + stream + .write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n") + .unwrap(); + stream.flush().unwrap(); + started.send(()).unwrap(); + let result = stream.read(&mut [0; 1]); + assert!( + matches!(result, Ok(0)) + || result.as_ref().is_err_and(|error| matches!( + error.kind(), + std::io::ErrorKind::ConnectionReset | std::io::ErrorKind::ConnectionAborted + )), + "stream did not close: {result:?}" + ); + }); + let (first_done, first_result) = mpsc::channel(); + let first = fetch(first_url, move |result| { + first_done.send(result).unwrap(); + }) + .unwrap(); + wait_started.recv_timeout(Duration::from_secs(10)).unwrap(); + let (second_started, second_seen) = mpsc::channel(); + let (second_url, second_server) = server(move |mut stream| { + second_started.send(()).unwrap(); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}") + .unwrap(); + }); + let (second_done, second_result) = mpsc::channel(); + let second = fetch(second_url, move |result| { + second_done.send(result).unwrap(); + }) + .unwrap(); + assert!(matches!( + second_seen.recv_timeout(Duration::from_millis(100)), + Err(mpsc::RecvTimeoutError::Timeout) + )); + drop(first); + assert_eq!( + second_result + .recv_timeout(Duration::from_secs(10)) + .unwrap() + .unwrap(), + "{}" + ); + assert!(first_result.recv_timeout(Duration::from_secs(1)).is_err()); + drop(second); + first_server.join().unwrap(); + second_server.join().unwrap(); +} + +#[test] +fn cancelling_a_queued_import_never_starts_its_transfer() { + let (ready, started) = mpsc::channel(); + let (release, released) = tokio::sync::oneshot::channel(); + let blocker = spawn(async move { + let _permit = transfers().acquire().await; + ready.send(()).unwrap(); + released.await.unwrap(); + }) + .unwrap(); + started.recv_timeout(Duration::from_secs(5)).unwrap(); + let (done, received) = mpsc::channel(); + let queued = fetch("http://127.0.0.1:1/unused".into(), move |result| { + done.send(result).unwrap(); + }) + .unwrap(); + thread::sleep(Duration::from_millis(30)); + drop(queued); + release.send(()).unwrap(); + assert!(received.recv_timeout(Duration::from_secs(5)).is_err()); + drop(blocker); +} + +#[test] +fn local_file_limits_are_checked_before_buffering() { + let directory = tempfile::tempdir().unwrap(); + let small = directory.path().join("small.json"); + let large = directory.path().join("large.json"); + std::fs::write(&small, b"{}").unwrap(); + std::fs::File::create(&large) + .unwrap() + .set_len(MAX_IMPORT_BYTES as u64 + 1) + .unwrap(); + let (send, receive) = mpsc::channel(); + let task = spawn(async move { + assert_eq!(platform::read_file(&small, 8).await.unwrap(), b"{}"); + assert!(platform::read_file(&large, MAX_IMPORT_BYTES) + .await + .unwrap_err() + .contains("limit")); + send.send(()).unwrap(); + }) + .unwrap(); + receive.recv_timeout(Duration::from_secs(10)).unwrap(); + drop(task); +} + +#[test] +fn bounded_accumulation_stops_at_one_byte_past_the_limit() { + let mut body = Body::new(8).unwrap(); + body.push(b"12345678").unwrap(); + assert!(body.push(&[0; 1024]).is_err()); + assert_eq!(body.bytes.len(), 9); + let bytes = b"{\"userId\":18446744073709551615}".to_vec(); + let pointer = bytes.as_ptr(); + let text = decode_bytes(bytes).unwrap(); + assert_eq!(text.as_ptr(), pointer); + assert!(text.contains("18446744073709551615")); + assert_eq!(decode_bytes(b"\xef\xbb\xbf{}".to_vec()).unwrap(), "{}"); +} diff --git a/crates/moly-law/src/talk/row.rs b/crates/moly-law/src/talk/row.rs index a7bf99e..2cc4099 100644 --- a/crates/moly-law/src/talk/row.rs +++ b/crates/moly-law/src/talk/row.rs @@ -382,11 +382,13 @@ mod tests { who: "Characters.A".to_string(), pattern: "EyePresets.normal".to_string(), alias: "EyePresets.normal".to_string(), + delay_seconds: 0.0, }, TalkStep::ChangeNpcMouth { who: "Characters.A".to_string(), pattern: "MouthPresets.smile01".to_string(), alias: "MouthPresets.smile01".to_string(), + delay_seconds: 0.0, }, ], }; diff --git a/crates/moly-law/src/talk/step.rs b/crates/moly-law/src/talk/step.rs index ad270c4..9bcd32f 100644 --- a/crates/moly-law/src/talk/step.rs +++ b/crates/moly-law/src/talk/step.rs @@ -612,8 +612,8 @@ mod tests { talk_wait(0.0, None), TalkStep::Label { name: String::new() }, TalkStep::Voice { channel: String::new(), cue: String::new(), who: String::new() }, - TalkStep::ChangeNpcEye { who: String::new(), pattern: String::new(), alias: String::new() }, - TalkStep::ChangeNpcMouth { who: String::new(), pattern: String::new(), alias: String::new() }, + TalkStep::ChangeNpcEye { who: String::new(), pattern: String::new(), alias: String::new(), delay_seconds: 0.0 }, + TalkStep::ChangeNpcMouth { who: String::new(), pattern: String::new(), alias: String::new(), delay_seconds: 0.0 }, TalkStep::ChangeAnimation { who: String::new(), motion: String::new(), @@ -709,6 +709,7 @@ mod tests { who: "a".to_string(), pattern: "EyePresets.normal".to_string(), alias: "EyePresets.normal".to_string(), + delay_seconds: 0.0, }; assert_eq!( eye.face_change(), @@ -718,6 +719,7 @@ mod tests { who: "a".to_string(), pattern: "MouthPresets.smile01".to_string(), alias: "MouthPresets.smile01".to_string(), + delay_seconds: 0.0, }; assert_eq!( mouth.face_change(), @@ -729,6 +731,7 @@ mod tests { who: 1.0, pattern: "p".to_string(), alias: "p".to_string(), + delay_seconds: 0.0, }; assert_eq!(fx_eye.face_change(), Some((FaceSlot::Eye, "p", "p"))); }