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..eb43134 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +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 + with: + fetch-depth: 0 + - 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 + 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 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..86bf6eb 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 0.9.5", + "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" @@ -1024,7 +1132,7 @@ dependencies = [ "glam", "itertools 0.14.0", "libm", - "rand", + "rand 0.9.5", "rand_distr", "serde", "thiserror 2.0.20", @@ -1851,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" @@ -1862,6 +1981,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 +2366,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" @@ -2317,6 +2456,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 +2510,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 +2669,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 +2736,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 +2764,19 @@ 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", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -2600,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]] @@ -2658,7 +2857,7 @@ dependencies = [ "bytemuck", "encase", "libm", - "rand", + "rand 0.9.5", "serde_core", ] @@ -2870,6 +3069,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 +3092,208 @@ 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" +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" @@ -2947,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" @@ -3171,6 +3584,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" @@ -3193,8 +3612,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] -name = "mach2" -version = "0.4.3" +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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" dependencies = [ @@ -3234,6 +3659,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" @@ -3275,9 +3709,20 @@ 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.1.0" +version = "0.0.1" dependencies = [ "bevy", "console_error_panic_hook", @@ -3290,35 +3735,50 @@ 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", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] name = "moly-game" -version = "0.1.0" +version = "0.0.1" dependencies = [ + "arboard", + "async-lock", "bevy", + "futures-channel", + "futures-util", "js-sys", "moly-assets", "moly-law", + "reqwest", + "rfd", "serde_json", "swash", + "tempfile", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] [[package]] name = "moly-law" -version = "0.1.0" +version = "0.0.1" [[package]] name = "moxcms" @@ -3562,10 +4022,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 +4049,7 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -3587,7 +4060,7 @@ checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -3599,7 +4072,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -3609,6 +4082,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 +4107,7 @@ checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", "objc2-metal", ] @@ -3632,7 +4120,7 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-contacts", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -3654,6 +4142,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 +4164,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 +4183,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 +4196,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -3698,7 +4208,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 +4219,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 +4235,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 +4251,7 @@ checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -3754,7 +4264,7 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -3824,6 +4334,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -3963,6 +4483,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 +4504,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" @@ -4041,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" @@ -4075,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]] @@ -4085,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]] @@ -4097,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" @@ -4104,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]] @@ -4215,6 +4832,82 @@ 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" +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 +4985,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 = [ + "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 = [ + "web-time", + "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" @@ -4307,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" @@ -4403,6 +5137,29 @@ 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 = "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" @@ -4435,6 +5192,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" @@ -4532,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" @@ -4577,6 +5354,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 +5399,26 @@ 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" +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 +5454,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 +5550,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" @@ -4749,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" @@ -4800,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" @@ -4884,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" @@ -4917,6 +5818,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 +5871,37 @@ 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 = "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 +5953,21 @@ 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" +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 +6160,15 @@ dependencies = [ "wasm-bindgen", ] +[[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 +6824,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 +6876,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 +6951,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 +7070,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 +7141,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..8176fbf 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,75 @@ 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 资源和浏览器内部缓存。 +暂时的预算不足会按请求顺序等待,取消读取会释放排队和已占用的预算;单个请求超过上限时 +直接拒绝。identity 数据只预留传输缓冲,gzip 同时预留传输与解码缓冲。分包读取器按顺序 +消费,读完或丢弃时释放读取预算;需要 seek 的自定义加载器可使用 Bevy 的 VecReader 回退。 + +## 玩家数据导入 + +打开 **Settings(F10)→ Player data**,选择与资源一致的区服,输入 UID 并点击 +**Fetch player**,或使用 **Choose JSON** 读取玩家数据。确认预览后点击 +**Import preview**,即可加载家具布局、配色及等级对应的场地大小。 +原生程序也支持拖入 JSON 文件。支持原始 Home 响应及 `updatedResources` 包装。 +导入后会进入对应场景,重启时默认打开已导入的主页;显式站点参数仍可覆盖启动位置。 + +导入保留一份原布局备份,**Restore backup** 可以恢复;音量、画质及其他设置保持原值。 +有未保存的家具编辑、数据无效或资源不匹配时不会覆盖存档。自定义图片、饰件和墙纸地板 +记录会保留,但这些外观的渲染尚未接入。 + +JSON 输入上限为 32 MiB。UID 请求和文件选择使用受限流式读取;浏览器先检查文件大小, +再打开读取流。同一应用进程同时只读取一份导入正文,取消会中止正在读取的请求或文件, +迟到结果不会进入预览。超限、取消或读取失败均不修改当前布局和存档。 + +资源根需要提供 `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`。 +本地无 GPU 的核心与回归测试可运行 `cargo test --release --workspace --lib -- --test-threads=1`, +其中的网络和存档检查使用回环服务、合成输入及临时文件,不需要游戏资源。 + ## 代码结构 | 目录 | 职责 | 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..02125fb 100644 --- a/crates/moly-assets/Cargo.toml +++ b/crates/moly-assets/Cargo.toml @@ -16,3 +16,10 @@ 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" +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..6f37e4d 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,41 @@ struct Entry { xf: Option, } -type InFlight = OnceCell>; +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, + 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 +132,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).await?; + 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 +244,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 +256,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 +302,34 @@ 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 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 { @@ -249,10 +339,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 +356,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 +384,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 { @@ -332,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/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..6c08130 --- /dev/null +++ b/crates/moly-assets/src/read_limits.rs @@ -0,0 +1,187 @@ +//! Budgets for application-owned transfer and decoded asset buffers. + +use async_lock::Semaphore; +use bevy::asset::io::{ + Reader, ReaderNotSeekableError, SeekableReader, StackFuture, STACK_FUTURE_SIZE, +}; +use futures_lite::io::AsyncRead; +use std::{ + io, + pin::Pin, + 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; +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 { + 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::new(MAX_IN_FLIGHT_BYTES, 8))) + .clone() +} + +impl Budget { + 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 { + permit, + bytes_per_permit: self.bytes_per_permit, + }) + } +} + +pub(crate) struct Reservation { + permit: OwnedSemaphorePermit, + bytes_per_permit: usize, +} + +impl Reservation { + pub(crate) fn shrink_to(&mut self, bytes: usize) { + 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"), + ); + } +} + +pub(crate) struct Buffer { + pub(crate) bytes: Vec, + pub(crate) _reservation: Reservation, +} + +/// 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: Option>, + position: usize, +} + +impl SharedReader { + pub(crate) fn new(data: Arc) -> Self { + Self { + data: Some(data), + position: 0, + } + } +} + +impl AsyncRead for SharedReader { + fn poll_read( + mut self: Pin<&mut Self>, + _: &mut Context<'_>, + buffer: &mut [u8], + ) -> Poll> { + let Some(data) = &self.data else { + return Poll::Ready(Ok(0)); + }; + let length = buffer + .len() + .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(&data.bytes[self.position..end]); + self.position = end; + } + if consumed { + self.data = None; + } + Poll::Ready(Ok(length)) + } +} + +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..]); + } + self.position += count; + Ok(count) + }) + } + + fn seekable(&mut self) -> Result<&mut dyn SeekableReader, ReaderNotSeekableError> { + Err(ReaderNotSeekableError) + } +} + +#[cfg(test)] +mod tests; + +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-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 eeceae2..15db360 100644 --- a/crates/moly-game/Cargo.toml +++ b/crates/moly-game/Cargo.toml @@ -11,12 +11,24 @@ bevy = { workspace = true } serde_json = "1" # 字形光栅化与度量:bevy_text 的同源光栅器,锁内已有 0.2.10,不新增下载。 swash = "0.2.10" +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"] } +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/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..d3b306f 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, @@ -612,21 +632,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) + /// 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) } - pub(crate) fn occupancy(&self) -> OccupancyRow { - let (min, max) = self.footprint(); - OccupancyRow { - uid: self.uid.clone(), package: self.package, min, max, + 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) = self.footprint(); + 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()))) @@ -642,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 } @@ -653,7 +681,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 +709,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 +727,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 +743,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 +766,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 +799,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 +819,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 +848,7 @@ pub struct FixtureSource(pub Handle); #[derive(Component)] struct FixtureInstanceSeed { uid: String, - package: &'static str, + package: String, master: i32, } @@ -987,7 +1022,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 +1075,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 +1100,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 +1110,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 +1261,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 +1280,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..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}; @@ -71,7 +74,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 +88,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 +234,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 +576,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..c2cc04b 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(), }, @@ -282,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 168dd50..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,17 +118,26 @@ 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) { + if let Some(meta) = areas.cutscene.get(&owner.package) { let protected = cutscene_cells(meta, owner); if protected .iter() .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)))) }) @@ -155,8 +150,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 @@ -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/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..a518342 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,9 @@ mod npc_fixture_activity; 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; pub mod player_state; @@ -83,120 +87,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..5a3a6a8 --- /dev/null +++ b/crates/moly-game/src/player_data.rs @@ -0,0 +1,632 @@ +//! 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}; + +use crate::{ + fixture::layouts::{self, SiteFixtureLayouts}, + player_data_io::{self, decode_bytes, ReadTask}, + 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(Result, String>), + Paste(Result), +} + +#[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, + 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, + read_task: Option, + 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(), read_task: None, + 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(); + 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(), + 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.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; + } + + 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 (inbox, ticket, region) = (self.inbox.clone(), self.generation, self.region.clone()); + 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) -> Result<(), String> { + self.cancel(); + self.busy = true; + self.status = "Choose a player-data JSON file...".into(); + 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 message = self.inbox.lock().unwrap().message.take(); + if let Some(message) = message { + match message { + 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()) { + 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(()) + } +} + +impl Drop for PlayerDataImport { + fn drop(&mut self) { self.cancel(); } +} + +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(), + Some(ImportAction::Apply) => state.apply(world), + Some(ImportAction::Restore) => state.restore(world), + Some(ImportAction::Paste) => { + paste(state.inbox.clone(), state.generation); + 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); +} + +#[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}"))?; + 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}"))?; + decode_bytes(bytes) +} + +#[cfg(not(target_arch = "wasm32"))] +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}")); + deliver(&inbox, ticket, Incoming::Paste(result)); +} + +#[cfg(target_arch = "wasm32")] +fn paste(inbox: Inbox, ticket: u64) { + 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()), + }; + deliver(&inbox, ticket, 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; + if let Err(error) = state.read_file(path_buf.clone()) { + 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)); +} + +#[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-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/`); });